|null */ private ?array $fieldColumns = null; private ?bool $hasDateLookup = null; public function ready(): bool { try { return Schema::connection($this->connectionName())->hasTable(self::MAIN_TABLE) && Schema::connection($this->connectionName())->hasTable(self::SCHEMA_TABLE) && $this->initialSyncCompleted() && $this->connection()->table(self::MAIN_TABLE)->exists(); } catch (\Throwable) { return false; } } /** 首次全量同步完成前不对外使用,避免页面读到不完整的数据。 */ private function initialSyncCompleted(): bool { $state = $this->connection()->table('business_mirror_table_states') ->where('dataset_key', self::MAIN_DATASET) ->first(['initial_completed_at']); return $state !== null && $state->initial_completed_at !== null; } /** * 页面字段键 => 上游列名(undefinedN)。 * * @return array */ public function fieldColumns(): array { if ($this->fieldColumns !== null) { return $this->fieldColumns; } $headers = (array) config('order_after_sales_cost.headers', []); $map = []; $existing = array_map( 'strtolower', Schema::connection($this->connectionName())->getColumnListing(self::MAIN_TABLE), ); foreach ($this->connection()->table(self::SCHEMA_TABLE)->orderBy('id')->get(['column', 'name']) as $schema) { $label = self::normalizeLabel((string) $schema->name); $field = $headers[$label] ?? null; $column = (string) $schema->column; // 同一科目出现多次(如 800105佣金 / 800105平台佣金)时保留第一次映射。 // schemas 里有 170 条,但原表只有 160 列,提前过滤掉不存在的列。 if ($field !== null && ! isset($map[$field]) && in_array(strtolower($column), $existing, true)) { $map[$field] = $column; } } return $this->fieldColumns = $map; } /** * 按「店铺 + 店铺商品编码 + 店铺款式编码」批量读取售后镜像中的内部商品编码。 * * 仅作为商品成本历史拆分映射失败后的补充证据,不直接修改售后页面数据。 * * @param list $candidates * @return list */ public function platformSkuMappings(array $candidates): array { $keys = []; foreach ($candidates as $candidate) { $shopName = trim((string) ($candidate['shop_name'] ?? '')); $shopSkuId = trim((string) ($candidate['shop_sku_id'] ?? '')); $shopItemId = trim((string) ($candidate['shop_i_id'] ?? '')); if ($shopName === '' || $shopSkuId === '' || $shopItemId === '') { continue; } $keys[$shopName . "\0" . $shopSkuId . "\0" . $shopItemId] = [ 'shop_name' => $shopName, 'shop_sku_id' => $shopSkuId, 'shop_i_id' => $shopItemId, ]; } if ($keys === []) { return []; } $columns = $this->fieldColumns(); foreach (['shop_name', 'shop_product_code', 'shop_style_code', 'product_code'] as $field) { if (! isset($columns[$field]) || preg_match('/^[A-Za-z0-9_]+$/', (string) $columns[$field]) !== 1) { return []; } } $connection = $this->connection(); $table = '`' . self::MAIN_TABLE . '`'; $shopName = '`' . $columns['shop_name'] . '`'; $shopProductCode = '`' . $columns['shop_product_code'] . '`'; $shopStyleCode = '`' . $columns['shop_style_code'] . '`'; $productCode = '`' . $columns['product_code'] . '`'; $connection->statement('DROP TEMPORARY TABLE IF EXISTS tmp_cost_afs_sku_keys'); $connection->statement( 'CREATE TEMPORARY TABLE tmp_cost_afs_sku_keys (' . 'shop_name varchar(191) NOT NULL, ' . 'shop_sku_id varchar(128) NOT NULL, ' . 'shop_i_id varchar(128) NOT NULL, ' . 'PRIMARY KEY (shop_name, shop_sku_id, shop_i_id)' . ') ENGINE=InnoDB', ); $rows = []; try { foreach (array_chunk(array_values($keys), 500) as $chunk) { $connection->table('tmp_cost_afs_sku_keys')->insertOrIgnore($chunk); } $records = $connection->select( "SELECT DISTINCT m.{$shopName} AS shop_name, m.{$shopProductCode} AS shop_sku_id, m.{$shopStyleCode} AS shop_i_id, m.{$productCode} AS product_code FROM {$table} m STRAIGHT_JOIN tmp_cost_afs_sku_keys k ON k.shop_name = m.{$shopName} AND k.shop_sku_id = m.{$shopProductCode} AND k.shop_i_id = m.{$shopStyleCode} WHERE COALESCE(TRIM(m.{$productCode}), '') <> ''", ); foreach ($records as $record) { $rows[] = [ 'shop_name' => trim((string) $record->shop_name), 'shop_sku_id' => trim((string) $record->shop_sku_id), 'shop_i_id' => trim((string) $record->shop_i_id), 'product_code' => mb_strtoupper(trim((string) $record->product_code)), ]; } } finally { $connection->statement('DROP TEMPORARY TABLE IF EXISTS tmp_cost_afs_sku_keys'); } return $rows; } /** * 商品成本补码:读取指定订单中数量、金额均为正的商品利润组件。 * * @param list $onlineOrderNos * @return list */ public function positiveComponentRowsForOrders(array $onlineOrderNos): array { $onlineOrderNos = array_values(array_unique(array_filter(array_map( static fn (mixed $value): string => trim((string) $value), $onlineOrderNos, ), static fn (string $value): bool => $value !== ''))); sort($onlineOrderNos, SORT_STRING); if ($onlineOrderNos === [] || ! $this->ready()) { return []; } $columns = $this->fieldColumns(); foreach ([ 'online_order_no', 'shop_name', 'shop_product_code', 'shop_style_code', 'product_code', 'product_quantity', 'product_amount', ] as $field) { if (! isset($columns[$field])) { return []; } } $select = array_values(array_unique(array_values(array_intersect_key( $columns, array_flip([ 'online_order_no', 'shop_name', 'shop_product_code', 'shop_style_code', 'product_code', 'product_quantity', 'product_amount', ]), )))); $rows = []; foreach (array_chunk($onlineOrderNos, 500) as $chunk) { foreach ($this->connection()->table(self::MAIN_TABLE) ->whereIn($columns['online_order_no'], $chunk) ->orderBy('id') ->get($select) as $record) { $productQuantity = (float) ($record->{$columns['product_quantity']} ?? 0); $productAmount = (float) ($record->{$columns['product_amount']} ?? 0); $productCode = mb_strtoupper(trim((string) ($record->{$columns['product_code']} ?? ''))); if ($productQuantity <= 0 || $productAmount <= 0 || $productCode === '') { continue; } $rows[] = [ 'online_order_no' => trim((string) ($record->{$columns['online_order_no']} ?? '')), 'shop_name' => trim((string) ($record->{$columns['shop_name']} ?? '')), 'shop_sku_id' => trim((string) ($record->{$columns['shop_product_code']} ?? '')), 'shop_i_id' => trim((string) ($record->{$columns['shop_style_code']} ?? '')), 'product_code' => $productCode, 'product_quantity' => $productQuantity, 'product_amount' => $productAmount, ]; } } return $rows; } /** @param array $filters */ public function covers(array $filters): bool { $from = trim((string) ($filters['from'] ?? '')); $start = (string) config('order_after_sales_cost.goods_profit_from', '2026-07-01'); // 更早月份由导入表负责,先按日期边界退出,避免每次查询都检查宽表同步状态。 if ($start !== '' && $from !== '' && $from < $start) { return false; } if (! $this->ready()) { return false; } $columns = $this->fieldColumns(); $to = trim((string) ($filters['to'] ?? '')); $orderNo = trim((string) ($filters['order_no'] ?? '')); $orderNos = $this->orderNumbers($orderNo); // 只按单号(不限日期)查询:原表命中该单就用原表,否则交给导入历史数据兜底。 if ($from === '' && $to === '' && $orderNos !== []) { $dateColumn = $columns['order_time'] ?? null; foreach (['online_order_no', 'internal_order_no'] as $field) { if (! isset($columns[$field])) { continue; } $values = $field === 'internal_order_no' ? array_values(array_filter($orderNos, static fn (string $value): bool => mb_strlen($value) < 12)) : $orderNos; if ($values === []) { continue; } $query = $this->connection()->table(self::MAIN_TABLE) ->whereIn($columns[$field], $values); // 只有带订单时间的行才是完整明细;只有费用行的历史订单交给导入数据。 if ($dateColumn !== null) { $query->whereNotNull($dateColumn)->where($dateColumn, '<>', ''); } if ($query->limit(1)->exists()) { return true; } } return false; } // “是否覆盖”只看日期范围,不掺业务筛选:筛选条件由 paginate 应用, // 筛不到就该显示空结果,而不是回退到旧订单镜像(否则筛选会被忽略)。 if ($from === '' && $to === '') { return true; } if (! isset($columns['order_time'])) { return false; } $expression = $this->dateExpression($columns['order_time']); $range = $this->connection()->table(self::MAIN_TABLE) ->selectRaw("MAX({$expression}) as max_time") ->first(); if (! $range || $range->max_time === null) { return false; } // 起始日受 goods_profit_from 约束,结束日按原表实际最大日期判断(今天往往晚于最后一次同步)。 $anchor = $from !== '' ? $from : $to; return $anchor === '' || substr((string) $range->max_time, 0, 10) >= $anchor; } /** * @param array $filters * @return array{rows:list>,has_more:bool} */ public function paginate(array $filters, int $page, int $limit): array { $cacheKey = 'goods-profit-afs:page:' . sha1((string) json_encode([$filters, $page, $limit])); return cache()->remember($cacheKey, now()->addMinutes(2), fn (): array => $this->paginateUncached($filters, $page, $limit)); } /** * @param array $filters * @return array{rows:list>,has_more:bool} */ private function paginateUncached(array $filters, int $page, int $limit): array { $page = max($page, 1); $limit = min(max($limit, 1), 100); $columns = $this->fieldColumns(); $select = array_values(array_unique(array_merge(['id'], array_values($columns)))); $orderColumn = $columns['order_time'] ?? null; $orderNo = trim((string) ($filters['order_no'] ?? '')); $orderNos = $this->orderNumbers($orderNo); if (count($orderNos) > 1) { return $this->paginateByOrderNumbers($orderNos, $filters, $columns, $select, $page, $limit); } if ($orderNo !== '') { return $this->paginateByOrderNumber($orderNo, $filters, $columns, $select, $page, $limit); } $query = $this->filtered($filters, $columns)->select($select); if ($orderColumn !== null) { $query->orderByRaw($this->dateExpression($orderColumn) . ' desc'); } $records = $query->orderByDesc('id')->offset(($page - 1) * $limit)->limit($limit + 1)->get(); $numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', [])); $rows = []; foreach ($records as $record) { $row = []; foreach ($columns as $field => $column) { $value = $record->{$column} ?? null; if (isset($numberKeys[$field])) { $value = $value === null ? null : (float) $value; } $row[$field] = is_string($value) ? trim($value) : $value; } $rows[] = $row; } return [ 'rows' => array_slice($rows, 0, $limit), 'has_more' => count($rows) > $limit, ]; } /** * 单号查询:分别走 online/internal 两个索引,合并后在 PHP 排序分页。 * * @param array $filters * @param array $columns * @param list $select * @return array{rows:list>,has_more:bool} */ private function paginateByOrderNumber( string $orderNo, array $filters, array $columns, array $select, int $page, int $limit, ): array { $records = []; $cap = max($limit * 4, 400); foreach ($this->orderNumberFields($orderNo) as $field) { if (! isset($columns[$field])) { continue; } $query = $this->connection()->table(self::MAIN_TABLE) ->where($columns[$field], 'like', $orderNo . '%'); $this->applyDateRange($query, $filters, $columns); // 先只取主键(走索引、窄查询),再按主键取宽行,避免宽表全列扫描。 $ids = $query->limit($cap)->pluck('id')->all(); if ($ids !== []) { foreach ($this->connection()->table(self::MAIN_TABLE)->whereIn('id', $ids)->get($select) as $record) { $records[(int) $record->id] = $record; } } } $timeColumn = $columns['order_time'] ?? null; usort($records, static function ($left, $right) use ($timeColumn): int { $leftTime = $timeColumn !== null ? strtotime(str_replace('/', '-', (string) ($left->{$timeColumn} ?? ''))) : false; $rightTime = $timeColumn !== null ? strtotime(str_replace('/', '-', (string) ($right->{$timeColumn} ?? ''))) : false; return [$rightTime ?: 0, (int) $right->id] <=> [$leftTime ?: 0, (int) $left->id]; }); $numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', [])); $rows = []; foreach ($records as $record) { $row = []; foreach ($columns as $field => $column) { $value = $record->{$column} ?? null; if (isset($numberKeys[$field])) { $value = $value === null ? null : (float) $value; } $row[$field] = is_string($value) ? trim($value) : $value; } $rows[] = $row; } $offset = ($page - 1) * $limit; return [ 'rows' => array_slice($rows, $offset, $limit), 'has_more' => count($rows) > $offset + $limit, ]; } /** * 批量单号(一行一个/逗号/空格分隔):按两列索引 IN 查询后 SQL 分页。 * * @param list $orderNos * @param array $filters * @param array $columns * @param list $select * @return array{rows:list>,has_more:bool} */ private function paginateByOrderNumbers( array $orderNos, array $filters, array $columns, array $select, int $page, int $limit, ): array { $short = array_values(array_filter($orderNos, static fn (string $value): bool => mb_strlen($value) < 12)); $query = $this->connection()->table(self::MAIN_TABLE); $query->where(function ($inner) use ($columns, $orderNos, $short): void { $matched = false; if (isset($columns['online_order_no'])) { $inner->whereIn($columns['online_order_no'], $orderNos); $matched = true; } if ($short !== [] && isset($columns['internal_order_no'])) { $matched ? $inner->orWhereIn($columns['internal_order_no'], $short) : $inner->whereIn($columns['internal_order_no'], $short); $matched = true; } if (! $matched) { $inner->whereRaw('1 = 0'); } }); $this->applyDateRange($query, $filters, $columns); // 不额外排序(走单号索引直接取这 N 条);按 id 倒序会把主键反向全表扫描。 $ids = $query->forPage($page, $limit + 1)->pluck('id')->all(); $fetched = $ids === [] ? collect() : $this->connection()->table(self::MAIN_TABLE)->whereIn('id', $ids)->get($select)->keyBy('id'); $records = array_values(array_filter(array_map( static fn (int $id) => $fetched[$id] ?? null, $ids, ))); $numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', [])); $rows = []; foreach ($records as $record) { $row = []; foreach ($columns as $field => $column) { $value = $record->{$column} ?? null; if (isset($numberKeys[$field])) { $value = $value === null ? null : (float) $value; } $row[$field] = is_string($value) ? trim($value) : $value; } $rows[] = $row; } $hasMore = count($rows) > $limit; $rows = array_slice($rows, 0, $limit); return ['rows' => $rows, 'has_more' => $hasMore]; } /** * 批量单号拆成数组(与订单镜像的批量规则一致)。 * * @return list */ private function orderNumbers(string $raw): array { $raw = trim($raw); if ($raw === '') { return []; } $parts = preg_split('/[\s,;,;、]+/u', $raw) ?: []; $values = []; foreach ($parts as $part) { $part = trim((string) $part); if ($part !== '' && ! isset($values[$part])) { $values[$part] = true; } } return array_slice(array_keys($values), 0, 200); } /** * @param array $filters * @param array $columns */ private function filtered(array $filters, array $columns): \Illuminate\Database\Query\Builder { $query = $this->connection()->table(self::MAIN_TABLE); $from = trim((string) ($filters['from'] ?? '')); $to = trim((string) ($filters['to'] ?? '')); $orderNo = trim((string) ($filters['order_no'] ?? '')); $sku = trim((string) ($filters['sku_id'] ?? '')); $shops = array_values(array_filter((array) ($filters['shop_names'] ?? []), static fn ($v) => trim((string) $v) !== '')); // 原表的订单时间是字符串(如 2026/8/23 14:58:51,不补零),不能直接和 ISO 日期做字符串比较。 if (($from !== '' || $to !== '') && isset($columns['order_time'])) { $expression = $this->dateExpression($columns['order_time']); if ($from !== '') { $query->whereRaw($expression . ' >= ?', [$from . ' 00:00:00']); } if ($to !== '') { $query->whereRaw($expression . ' <= ?', [$to . ' 23:59:59']); } } if ($shops !== [] && isset($columns['shop_name'])) { $query->whereIn($columns['shop_name'], $shops); } if ($orderNo !== '') { $query->where(function ($inner) use ($columns, $orderNo): void { $matched = false; foreach (['online_order_no', 'internal_order_no'] as $field) { if (! isset($columns[$field])) { continue; } $inner->orWhere($columns[$field], 'like', $orderNo . '%'); $matched = true; } if (! $matched) { $inner->whereRaw('1 = 0'); } }); } if ($sku !== '' && isset($columns['product_code'])) { $query->where($columns['product_code'], 'like', '%' . addcslashes($sku, '%_\\') . '%'); } return $query; } /** * @param array $filters * @param array $columns */ private function applyDateRange(\Illuminate\Database\Query\Builder $query, array $filters, array $columns): void { $from = trim((string) ($filters['from'] ?? '')); $to = trim((string) ($filters['to'] ?? '')); if (($from === '' && $to === '') || ! isset($columns['order_time'])) { return; } $expression = $this->dateExpression($columns['order_time']); if ($from !== '') { $query->whereRaw($expression . ' >= ?', [$from . ' 00:00:00']); } if ($to !== '') { $query->whereRaw($expression . ' <= ?', [$to . ' 23:59:59']); } } private function dateExpression(string $column): string { // Dynamic schemas can move the source field. Keep the original parser // until the matching additive lookup has been installed. if ($column === 'undefined7') { $this->hasDateLookup ??= Schema::connection($this->connectionName()) ->hasColumn(self::MAIN_TABLE, GoodsProfitAfsLookupSchema::DATE_COLUMN); if ($this->hasDateLookup) { return '`' . GoodsProfitAfsLookupSchema::DATE_COLUMN . '`'; } } return "STR_TO_DATE(REPLACE(`{$column}`, '/', '-'), '%Y-%m-%d %H:%i:%s')"; } private function connection(): \Illuminate\Database\ConnectionInterface { return DB::connection($this->connectionName()); } private function connectionName(): string { return (string) config('upstream_mirrors.connection', 'business_mirror'); } private static function normalizeLabel(string $label): string { $label = str_replace(["\u{3000}", "\u{00A0}"], ' ', $label); $label = preg_replace('/\s+/u', ' ', $label) ?? $label; return trim($label); } /** * 线上订单号和内部订单号长度差很多(19 位 vs 7~8 位)。长单号只查线上订单号, * 避免用内部订单号列做 `like '长单号%'` 的全表扫描。 * * @return list */ private function orderNumberFields(string $orderNo): array { return mb_strlen($orderNo) >= 12 ? ['online_order_no'] : ['online_order_no', 'internal_order_no']; } }