connectionName()); return $schema->hasTable(self::TABLE) && $schema->hasColumns(self::TABLE, [ 'shop_name', 'online_order_no', 'internal_order_no', 'ship_time', 'product_cost', 'matched_freight_fee', 'matched_package_fee', 'matched_bill_fee', ]); } catch (\Throwable) { return false; } } /** * @return array{ * ready:bool,last_synced_at:?string,total:int,shops:list, * detail_ready:bool,detail_total:int,detail_last_synced_at:?string * } */ public function status(): array { try { $query = $this->connection()->table(self::TABLE); $detailReady = $this->detailReady(); $detailQuery = $detailReady ? $this->connection()->table(self::DETAIL_TABLE) : null; return [ 'ready' => $this->ready(), 'last_synced_at' => (clone $query)->max('synced_at'), 'total' => (int) (clone $query)->count(), 'shops' => (clone $query)->whereNotNull('shop_name') ->where('shop_name', '<>', '') ->distinct() ->orderBy('shop_name') ->pluck('shop_name') ->map(static fn (mixed $shop): string => trim((string) $shop)) ->filter() ->values() ->all(), 'detail_ready' => $detailReady, 'detail_total' => $detailQuery === null ? 0 : (int) (clone $detailQuery)->count(), 'detail_last_synced_at' => $detailQuery?->max('synced_at'), ]; } catch (\Throwable) { return [ 'ready' => false, 'last_synced_at' => null, 'total' => 0, 'shops' => [], 'detail_ready' => false, 'detail_total' => 0, 'detail_last_synced_at' => null, ]; } } /** * @param array $filters * @return array{rows:list>,total:int,has_more:bool} */ public function paginate(array $filters, int $page, int $limit): array { $query = $this->filtered($this->connection()->table(self::TABLE), $filters); $total = (int) (clone $query)->count(); $rows = $query ->select([ 'shop_name', 'online_order_no', 'internal_order_no', 'order_key', 'ship_time', 'product_cost', 'matched_package_fee', 'matched_freight_fee', 'matched_bill_fee', 'source_payload', 'synced_at', ]) ->orderByDesc('ship_time') ->orderByDesc('order_key') ->forPage(max($page, 1), min(max($limit, 1), 100)) ->get() ->map(function (object $row): array { $anomalyTypes = self::anomalyTypes($row); return [ '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 ?? '')), 'ship_time' => $row->ship_time, 'product_cost' => $row->product_cost === null ? null : (float) $row->product_cost, 'matched_package_fee' => $row->matched_package_fee === null ? null : (float) $row->matched_package_fee, 'matched_freight_fee' => $row->matched_freight_fee === null ? null : (float) $row->matched_freight_fee, 'matched_bill_fee' => $row->matched_bill_fee === null ? null : (float) $row->matched_bill_fee, 'synced_at' => $row->synced_at, 'order_source_payload' => $this->decodeJson($row->source_payload), 'anomaly_types' => $anomalyTypes, 'anomaly_type' => implode('、', $anomalyTypes), ]; }) ->all(); $this->hydrateDetails($rows); return [ 'rows' => $rows, 'total' => $total, 'has_more' => $page * min(max($limit, 1), 100) < $total, ]; } /** * @param array $filters * @return list */ public function references(array $filters, bool $missingDetails = false, int $limit = 0): array { $query = $this->filtered($this->connection()->table(self::TABLE), $filters); $query->select([ self::TABLE . '.shop_name', self::TABLE . '.online_order_no', self::TABLE . '.internal_order_no', self::TABLE . '.order_key', ]); if ($missingDetails && $this->detailReady()) { $query->leftJoin( self::DETAIL_TABLE, self::DETAIL_TABLE . '.order_key', '=', self::TABLE . '.order_key', )->whereNull(self::DETAIL_TABLE . '.order_key'); } $query->orderByDesc(self::TABLE . '.ship_time') ->orderByDesc(self::TABLE . '.order_key'); if ($limit > 0) { $query->limit($limit); } return $query->get()->map(static function (object $row): array { $online = trim((string) ($row->online_order_no ?? '')); $internal = trim((string) ($row->internal_order_no ?? '')); return [ 'order_key' => trim((string) ($row->order_key ?? '')), 'shop_name' => trim((string) ($row->shop_name ?? '')), 'online_order_no' => $online === '' ? null : $online, 'internal_order_no' => $internal === '' ? null : $internal, 'order_no' => $online !== '' ? $online : $internal, ]; })->filter(static fn (array $row): bool => $row['order_no'] !== '')->values()->all(); } private function filtered(\Illuminate\Database\Query\Builder $query, array $filters): \Illuminate\Database\Query\Builder { $query->where(function ($anomaly) { $anomaly->where(function ($cost) { $cost->whereNull('product_cost')->orWhere('product_cost', 0); })->orWhere(function ($freight) { $freight->whereNull('matched_freight_fee')->orWhere('matched_freight_fee', 0); }); }); $from = trim((string) ($filters['from'] ?? '')); $to = trim((string) ($filters['to'] ?? '')); if ($from !== '') { $query->where('ship_time', '>=', $from . ' 00:00:00'); } if ($to !== '') { $query->where('ship_time', '<=', $to . ' 23:59:59'); } $shops = array_values(array_unique(array_filter(array_map( static fn (mixed $value): string => trim((string) $value), (array) ($filters['shop_names'] ?? []), )))); if ($shops !== []) { $query->whereIn('shop_name', array_slice($shops, 0, 50)); } $orders = $this->batchValues((string) ($filters['order_no'] ?? '')); if ($orders !== []) { $query->where(function ($order) use ($orders): void { $order->whereIn('online_order_no', $orders) ->orWhereIn('internal_order_no', $orders); }); } return $query; } /** @return list */ private static function anomalyTypes(object $row): array { $types = []; if (self::isZeroOrMissing($row->product_cost)) { $types[] = '成本异常'; } if (self::isZeroOrMissing($row->matched_freight_fee)) { $types[] = '运费异常'; } return $types; } private static function isZeroOrMissing(mixed $value): bool { return $value === null || (float) $value === 0.0; } /** @param list> $rows */ private function hydrateDetails(array &$rows): void { if ($rows === []) { return; } $snapshots = collect(); if ($this->detailReady()) { $keys = array_values(array_filter(array_map( static fn (array $row): string => trim((string) ($row['order_key'] ?? '')), $rows, ))); if ($keys !== []) { $snapshots = $this->connection()->table(self::DETAIL_TABLE) ->whereIn('order_key', array_slice(array_unique($keys), 0, 100)) ->get() ->keyBy('order_key'); } } foreach ($rows as &$row) { $row['order_fields'] = ShengsuanOrderProfitDisplayMapper::fields( (array) ($row['order_source_payload'] ?? []), ); $snapshot = $snapshots->get($row['order_key']); $row['detail_synced_at'] = $snapshot->synced_at ?? null; $row['detail_item_count'] = (int) ($snapshot->item_count ?? 0); $row['detail_after_item_count'] = (int) ($snapshot->after_item_count ?? 0); $row['detail_missing_cost_item_count'] = (int) ($snapshot->missing_cost_item_count ?? 0); $row['missing_cost_barcodes'] = $this->decodeJson($snapshot->missing_cost_barcodes ?? null); $row['detail_order_headers'] = $this->decodeJson($snapshot->order_headers ?? null); $row['detail_order_items'] = $this->decodeJson($snapshot->order_items ?? null); $row['detail_after_headers'] = $this->decodeJson($snapshot->after_headers ?? null); $row['detail_after_items'] = $this->decodeJson($snapshot->after_items ?? null); $types = array_values(array_filter((array) ($row['anomaly_types'] ?? []))); if (($row['missing_cost_barcodes'] ?? []) !== [] && ! in_array('成本异常', $types, true)) { array_unshift($types, '成本异常'); } $row['anomaly_types'] = $types; $row['anomaly_type'] = implode('、', $types); } } private function detailReady(): bool { try { return Schema::connection($this->connectionName())->hasTable(self::DETAIL_TABLE); } catch (\Throwable) { return false; } } /** @return array */ private function decodeJson(mixed $value): array { if (! is_string($value) || trim($value) === '') { return []; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : []; } /** @return list */ private 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 function connection(): ConnectionInterface { return DB::connection($this->connectionName()); } private function connectionName(): string { return (string) config('upstream_mirrors.connection', 'business_mirror'); } }