available === null) { try { $this->available = Schema::hasTable(self::TABLE) && DB::table(self::TABLE)->exists(); } catch (\Throwable) { $this->available = false; } } return $this->available; } /** * 导入数据是否覆盖本次筛选(有至少一行命中即视为覆盖)。 * * @param array $filters */ public function covers(array $filters): bool { if (! $this->available()) { return false; } // 指定单号时,导入表里命中该单就完整(同单的所有明细行都在导入数据里)。 if (trim((string) ($filters['order_no'] ?? '')) !== '') { return $this->orderNumberExists($filters); } $from = trim((string) ($filters['from'] ?? '')); $to = trim((string) ($filters['to'] ?? '')); // 不限日期时无法保证覆盖范围,回退镜像,避免把导入数据当成全量。 if ($from === '' || $to === '') { return false; } $period = self::fullMonthPeriod($from, $to); if ($period !== null && $this->periodHasAllSelectedShops($period, $filters)) { return true; } // 兼容旧数据:此前导入没有 source_period,只能按订单时间范围判断覆盖。 $range = $this->legacyFilteredRange($filters) ->selectRaw('MIN(order_time) as min_time, MAX(order_time) as max_time') ->first(); if (! $range || $range->min_time === null) { return false; } // 只有导入范围完整覆盖所选日期时才使用导入数据,避免跨月时数据不全。 return substr((string) $range->min_time, 0, 10) <= $from && substr((string) $range->max_time, 0, 10) >= $to; } /** * @param array $filters * @return array{rows:list>,has_more:bool} */ public function paginate(array $filters, int $page, int $limit): array { $page = max($page, 1); $limit = min(max($limit, 1), 100); if (trim((string) ($filters['order_no'] ?? '')) !== '') { return $this->paginateByOrderNumbers($filters, $page, $limit); } if (count(self::batchValues((string) ($filters['sku_id'] ?? ''))) > 1) { return $this->paginateBySkuValues($filters, $page, $limit); } $records = $this->filtered($filters) ->orderByDesc('order_time') ->orderByDesc('id') ->forPage($page, $limit + 1) ->get(); $rows = []; foreach ($records as $record) { $payload = json_decode((string) $record->payload, true); $rows[] = is_array($payload) ? $payload : []; } return [ 'rows' => array_slice($rows, 0, $limit), 'has_more' => count($rows) > $limit, ]; } /** @return list */ public function shopNames(): array { if (! $this->available()) { return []; } return Cache::remember( 'order-data.after-sales.imported-shop-options', now()->addHours(24), static fn (): array => DB::table(self::TABLE) ->whereNotNull('source_period') ->whereNotNull('shop_name') ->where('shop_name', '<>', '') ->distinct() ->orderBy('shop_name') ->pluck('shop_name') ->map(static fn (mixed $name): string => trim((string) $name)) ->filter() ->values() ->all(), ); } /** * 商品成本补拆:按线上订单号批量读取导入表中已经拆好的组件行。 * * 只返回拆分所需的商品、平台规格、数量和金额;单价仍由商品成本的 * 月加权平均价 / 线上代发价 / 普通商品资料成本价链路计算。 * * @param list $onlineOrderNos * @return list */ public function componentRowsForOrders(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->available()) { return []; } $rows = []; foreach (array_chunk($onlineOrderNos, 500) as $chunk) { foreach (DB::table(self::TABLE) ->whereIn('online_order_no', $chunk) ->orderBy('id') ->get([ 'shop_name', 'online_order_no', 'internal_order_no', 'product_code', 'product_quantity', 'product_amount', 'payload', ]) as $record) { $payload = json_decode((string) ($record->payload ?? ''), true); $payload = is_array($payload) ? $payload : []; $productCode = mb_strtoupper(trim((string) ( $record->product_code ?? ($payload['product_code'] ?? '') ))); if ($productCode === '') { continue; } $rows[] = [ '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 ?? '')), 'product_code' => $productCode, 'shop_product_code' => trim((string) ($payload['shop_product_code'] ?? '')), 'shop_style_code' => trim((string) ($payload['shop_style_code'] ?? '')), 'product_quantity' => (float) ($record->product_quantity ?? 0), 'product_amount' => (float) ($record->product_amount ?? 0), ]; } } return $rows; } /** * 导入一行(命令内部使用,批量写入)。 * * @param array $payload */ public static function toRecord( array $payload, ?string $importedAt = null, ?string $sourcePeriod = null, ): array { // 以整行内容做键:同一单同一商品可能出现多行,只有完全相同的一行才合并。 ksort($payload); $rowKey = sha1((string) json_encode( ['source_period' => $sourcePeriod, 'payload' => $payload], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, )); $now = $importedAt ?? now()->toDateTimeString(); return [ 'row_key' => $rowKey, 'shop_name' => mb_substr((string) ($payload['shop_name'] ?? ''), 0, 191), 'shop_code' => mb_substr((string) ($payload['shop_code'] ?? ''), 0, 64), 'online_order_no' => mb_substr((string) ($payload['online_order_no'] ?? ''), 0, 64), 'internal_order_no' => mb_substr((string) ($payload['internal_order_no'] ?? ''), 0, 64), 'product_code' => mb_substr((string) ($payload['product_code'] ?? ''), 0, 191), 'style_code' => mb_substr((string) ($payload['style_code'] ?? ''), 0, 191), 'shop_product_code' => mb_substr((string) ($payload['shop_product_code'] ?? ''), 0, 191), 'shop_style_code' => mb_substr((string) ($payload['shop_style_code'] ?? ''), 0, 191), 'order_time' => self::dateTime($payload['order_time'] ?? null), 'source_period' => $sourcePeriod, 'product_quantity' => (float) ($payload['product_quantity'] ?? 0), 'product_amount' => (float) ($payload['product_amount'] ?? 0), 'product_cost' => (float) ($payload['product_cost'] ?? 0), 'cost_source' => mb_substr((string) ($payload['cost_source'] ?? ''), 0, 32), 'combine_type' => mb_substr((string) ($payload['combine_type'] ?? ''), 0, 32), 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'imported_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]; } /** @param array $filters */ private function filtered(array $filters): Builder { $query = DB::table(self::TABLE); $from = trim((string) ($filters['from'] ?? '')); $to = trim((string) ($filters['to'] ?? '')); $sku = trim((string) ($filters['sku_id'] ?? '')); $period = self::fullMonthPeriod($from, $to); if ($period !== null) { $query->where(function (Builder $range) use ($period, $from, $to): void { $range->where('source_period', $period) ->orWhere(function (Builder $legacy) use ($from, $to): void { $legacy->whereNull('source_period') ->where('order_time', '>=', $from . ' 00:00:00') ->where('order_time', '<=', $to . ' 23:59:59'); }); }); } elseif ($from !== '' && $to !== '') { $query->where(function (Builder $range) use ($from, $to): void { $range->whereBetween('source_period', [substr($from, 0, 7), substr($to, 0, 7)]) ->orWhere(function (Builder $legacy) use ($from, $to): void { $legacy->whereNull('source_period') ->where('order_time', '>=', $from . ' 00:00:00') ->where('order_time', '<=', $to . ' 23:59:59'); }); }); } elseif ($from !== '') { $query->where('order_time', '>=', $from . ' 00:00:00'); } elseif ($to !== '') { $query->where('order_time', '<=', $to . ' 23:59:59'); } $this->applyShopFilters($query, $filters); $skuValues = self::batchValues($sku); if ($skuValues !== []) { $query->where(function (Builder $codes) use ($skuValues): void { foreach ($skuValues as $value) { $contains = self::contains($value); $codes->orWhere(function (Builder $group) use ($contains): void { $group->whereRaw("product_code LIKE ? ESCAPE '!'", [$contains]) ->orWhereRaw("style_code LIKE ? ESCAPE '!'", [$contains]) ->orWhereRaw("shop_product_code LIKE ? ESCAPE '!'", [$contains]) ->orWhereRaw("shop_style_code LIKE ? ESCAPE '!'", [$contains]); }); } }); } return $query; } /** * 单号查询拆成线上/内部两个独立索引查询,避免 OR 让数据库扫描整个月份。 * * @param array $filters * @return array{rows:list>,has_more:bool} */ private function paginateByOrderNumbers(array $filters, int $page, int $limit): array { $values = self::batchValues((string) ($filters['order_no'] ?? '')); if ($values === []) { return ['rows' => [], 'has_more' => false]; } $baseFilters = array_replace($filters, ['order_no' => '']); $cap = max($limit * 20, 1000); $ids = []; foreach (['online_order_no', 'internal_order_no'] as $field) { $fieldValues = self::orderValuesForField($field, $values); if ($fieldValues === []) { continue; } $query = $this->orderNumberBranch($baseFilters, $field, $fieldValues) ->select('id') ->limit($cap); foreach ($query->pluck('id') as $id) { $ids[(int) $id] = true; } } if ($ids === []) { return ['rows' => [], 'has_more' => false]; } $records = DB::table(self::TABLE) ->whereIn('id', array_keys($ids)) ->orderByDesc('order_time') ->orderByDesc('id') ->forPage($page, $limit + 1) ->get(); $rows = []; foreach ($records as $record) { $payload = json_decode((string) $record->payload, true); $rows[] = is_array($payload) ? $payload : []; } return [ 'rows' => array_slice($rows, 0, $limit), 'has_more' => count($rows) > $limit, ]; } /** * 批量商品编码按四列稳定索引取候选行,避免四列 contains 扫描整个月份。 * * @param array $filters * @return array{rows:list>,has_more:bool} */ private function paginateBySkuValues(array $filters, int $page, int $limit): array { $values = self::batchValues((string) ($filters['sku_id'] ?? '')); if (count($values) < 2) { return ['rows' => [], 'has_more' => false]; } $baseFilters = array_replace($filters, ['sku_id' => '']); $needed = $page * $limit + 1; $ids = []; foreach (['product_code', 'style_code', 'shop_product_code', 'shop_style_code'] as $field) { $query = $this->filtered($baseFilters) ->whereIn($field, $values) ->orderByDesc('order_time') ->orderByDesc('id') ->limit($needed); foreach ($query->pluck('id') as $id) { $ids[(int) $id] = true; } } if ($ids === []) { return ['rows' => [], 'has_more' => false]; } $records = DB::table(self::TABLE) ->whereIn('id', array_keys($ids)) ->orderByDesc('order_time') ->orderByDesc('id') ->forPage($page, $limit + 1) ->get(); $rows = []; foreach ($records as $record) { $payload = json_decode((string) $record->payload, true); $rows[] = is_array($payload) ? $payload : []; } return [ 'rows' => array_slice($rows, 0, $limit), 'has_more' => count($rows) > $limit, ]; } /** @param array $filters */ private function orderNumberExists(array $filters): bool { $values = self::batchValues((string) ($filters['order_no'] ?? '')); if ($values === []) { return false; } $baseFilters = array_replace($filters, ['order_no' => '']); foreach (['online_order_no', 'internal_order_no'] as $field) { $fieldValues = self::orderValuesForField($field, $values); if ($fieldValues !== [] && $this->orderNumberBranch($baseFilters, $field, $fieldValues)->limit(1)->exists()) { return true; } } return false; } /** * @param array $filters * @param list $values */ private function orderNumberBranch(array $filters, string $field, array $values): Builder { $query = $this->filtered($filters); if (count($values) === 1) { return $query->where($field, 'like', self::likePrefix($values[0])); } return $query->whereIn($field, $values); } /** @param array $filters */ private function legacyFilteredRange(array $filters): Builder { $from = trim((string) ($filters['from'] ?? '')); $to = trim((string) ($filters['to'] ?? '')); $query = DB::table(self::TABLE)->whereNull('source_period'); if ($from !== '') { $query->where('order_time', '>=', $from . ' 00:00:00'); } if ($to !== '') { $query->where('order_time', '<=', $to . ' 23:59:59'); } $this->applyShopFilters($query, $filters); return $query; } /** @param array $filters */ private function periodHasAllSelectedShops(string $period, array $filters): bool { $query = DB::table(self::TABLE)->where('source_period', $period); $this->applyShopFilters($query, $filters); $shops = self::selectedShopNames($filters); if ($shops === []) { return $query->exists(); } $covered = $query->distinct()->pluck('shop_name') ->map(static fn (mixed $name): string => trim((string) $name)) ->all(); $exact = ! empty($filters['shop_names_exact']); foreach ($shops as $shop) { $found = false; foreach ($covered as $candidate) { if ($exact ? $candidate === $shop : mb_stripos($candidate, $shop) !== false) { $found = true; break; } } if (! $found) { return false; } } return true; } /** @param array $filters */ private function applyShopFilters(Builder $query, array $filters): void { $shops = self::selectedShopNames($filters); if ($shops === []) { return; } if (! empty($filters['shop_names_exact'])) { $query->whereIn('shop_name', $shops); return; } $query->where(function (Builder $shop) use ($shops): void { foreach ($shops as $name) { $shop->orWhereRaw("shop_name LIKE ? ESCAPE '!'", [self::contains($name)]); } }); } /** @return list */ private static function selectedShopNames(array $filters): array { $values = (array) ($filters['shop_names'] ?? []); if ($values === [] && trim((string) ($filters['shop_name'] ?? '')) !== '') { $values = [$filters['shop_name']]; } $names = []; foreach ($values as $value) { $name = trim((string) $value); if ($name !== '') { $names[$name] = true; } if (count($names) >= 50) { break; } } return array_keys($names); } private static function fullMonthPeriod(string $from, string $to): ?string { if (preg_match('/^(\d{4}-\d{2})-01$/', $from, $matches) !== 1) { return null; } $period = $matches[1]; return $to === date('Y-m-t', strtotime($period . '-01')) ? $period : null; } private static function contains(string $value): string { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; } private static function likePrefix(string $value): string { return str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; } /** @param list $values @return list */ private static function orderValuesForField(string $field, array $values): array { if ($field !== 'internal_order_no') { return $values; } return array_values(array_filter( $values, static fn (string $value): bool => mb_strlen($value) <= self::INTERNAL_ORDER_MAX_PREFIX_LENGTH, )); } /** @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); } private static function dateTime(mixed $value): ?string { $value = trim((string) $value); if ($value === '') { return null; } $timestamp = strtotime($value); return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp); } }