connection = $mirror->connection(); } public function ready(): bool { $schema = Schema::connection($this->connection->getName()); if (! $schema->hasTable(self::TARGET_TABLE) || ! $schema->hasTable('business_mirror_table_states')) { return false; } $state = $this->connection->table('business_mirror_table_states') ->where('dataset_key', self::DATASET_KEY)->first(); return $state !== null && $state->initial_completed_at !== null; } /** @return array{data:list>,total:int,status:string,last_synced_at:?string,summary:array} */ public function paginate(array $filters, int $page, int $perPage): array { if (! $this->ready()) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $page = max($page, 1); $perPage = min(max($perPage, 1), 100); $state = $this->connection->table('business_mirror_table_states')->where('dataset_key', self::DATASET_KEY)->first(); // The synchronized dataset normally contains one configured status, so // count, totals, and paging can stay in SQL instead of loading all rows. $onlyStatus = strtoupper(trim((string) config('upstream_mirrors.ding_online_daifa_price.only_status', ''))); $statusFilter = strtoupper(trim((string) ($filters['status'] ?? ''))); $statusMismatch = $statusFilter !== '' && $onlyStatus !== '' && $statusFilter !== $onlyStatus; $total = $statusMismatch ? 0 : (int) $this->filteredQuery($filters)->count(); $summary = [ 'count' => 0, 'matched_count' => 0, 'daifa_amount' => 0.0, 'paid_amount' => 0.0, 'price_diff' => 0.0, ]; $pageRows = []; if (! $statusMismatch) { $aggregate = $this->filteredQuery($filters) ->selectRaw( 'COUNT(*) AS row_count, ' . "SUM(CASE WHEN product_id IS NOT NULL AND product_id <> '' AND product_name IS NOT NULL AND product_name <> '' THEN 1 ELSE 0 END) AS matched_count, " . 'COALESCE(SUM(daifa_amount), 0) AS daifa_amount, ' . 'COALESCE(SUM(paid_amount), 0) AS paid_amount, ' . 'COALESCE(SUM(price_diff), 0) AS price_diff' ) ->first(); $summary = [ 'count' => (int) ($aggregate->row_count ?? 0), 'matched_count' => (int) ($aggregate->matched_count ?? 0), 'daifa_amount' => round((float) ($aggregate->daifa_amount ?? 0), 2), 'paid_amount' => round((float) ($aggregate->paid_amount ?? 0), 2), 'price_diff' => round((float) ($aggregate->price_diff ?? 0), 2), ]; $pageRows = $this->filteredQuery($filters) ->orderByDesc('workflow_created_at') ->orderByDesc('process_instance_id') ->orderByDesc('detail_index') ->forPage($page, $perPage) ->get(); } $data = []; foreach ($pageRows as $row) { $workflow = $this->decode((string) $row->workflow_payload, []); $data[] = $this->presentRow($row, $workflow); } return [ 'data' => $data, 'total' => $total, 'status' => (string) ($state->status ?? 'unknown'), 'last_synced_at' => $state->last_succeeded_at ? (string) $state->last_succeeded_at : null, 'summary' => $summary, ]; } /** @return \Generator> */ public function exportRows(array $filters): \Generator { if (! $this->ready()) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $statusFilter = strtoupper(trim((string) ($filters['status'] ?? ''))); $rows = $this->filteredQuery($filters) ->orderByDesc('workflow_created_at') ->orderByDesc('process_instance_id') ->orderByDesc('detail_index') ->cursor(); foreach ($rows as $row) { $workflow = $this->decode((string) $row->workflow_payload, []); if (! $this->matchesStatus($workflow, $statusFilter)) { continue; } yield $this->presentRow($row, $workflow); } } private function filteredQuery(array $filters): Builder { $query = $this->connection->table(self::TARGET_TABLE); if (($filters['product_id'] ?? '') !== '') { $query->where('product_id', (string) $filters['product_id']); } if (($filters['shop_name'] ?? '') !== '') { $query->where('shop_name', (string) $filters['shop_name']); } if (($filters['supplier_name'] ?? '') !== '') { $query->whereRaw("supplier_name LIKE ? ESCAPE '!'", [$this->contains((string) $filters['supplier_name'])]); } if (($filters['material_no'] ?? '') !== '') { $query->where('material_no', (string) $filters['material_no']); } if (($filters['keyword'] ?? '') !== '') { $keyword = (string) $filters['keyword']; $query->where(function ($q) use ($keyword): void { $q->where('process_instance_id', 'like', '%' . $keyword . '%') ->orWhere('daifa_trade_no', 'like', '%' . $keyword . '%') ->orWhere('shop_trade_no', 'like', '%' . $keyword . '%'); }); } if (($filters['created_from'] ?? '') !== '') { $query->where('workflow_created_at', '>=', (string) $filters['created_from']); } if (($filters['created_to'] ?? '') !== '') { $query->where('workflow_created_at', '<', \Illuminate\Support\Carbon::parse((string) $filters['created_to'])->addDay()->startOfDay()->format('Y-m-d H:i:s')); } return $query; } private function matchesStatus(array $workflow, string $statusFilter): bool { return $statusFilter === '' || DingPayableWorkflowStatus::key($workflow) === $statusFilter; } /** @return array */ private function presentRow(object $row, array $workflow): array { $productId = (string) ($row->product_id ?? ''); // 商品名称已在同步阶段按 商品ID 关联商品目录并写入镜像宽表 product_name 列, // 页面直接读取,避免对 2500 万行的 ops_jst_sku_map 做临时 JOIN 导致超时。 $resolvedName = $this->cleanName($row->product_name ?? null); $product = $productId !== '' && $resolvedName !== null ? ['matched' => true, 'name' => $resolvedName, 'sku_id' => null, 'i_id' => null] : ['matched' => false, 'name' => null, 'sku_id' => null, 'i_id' => null]; return [ 'process_instance_id' => (string) $row->process_instance_id, 'detail_index' => (int) $row->detail_index, 'product_id' => $productId, 'daifa_trade_no' => (string) ($row->daifa_trade_no ?? ''), 'shop_trade_no' => (string) ($row->shop_trade_no ?? ''), 'shop_name' => (string) ($row->shop_name ?? ''), 'payee_name' => (string) ($row->payee_name ?? ''), 'tag' => ((string) ($row->payee_name ?? '')) === '宁波顾家望业家居有限公司' ? '刘萧代发' : '1688代发', 'supplier_name' => (string) ($row->supplier_name ?? ''), 'material_no' => (string) ($row->material_no ?? ''), 'ship_qty' => $row->ship_qty === null ? null : (int) $row->ship_qty, 'paid_amount' => $row->paid_amount === null ? null : (float) $row->paid_amount, 'daifa_amount' => $row->daifa_amount === null ? null : (float) $row->daifa_amount, 'price_diff' => $row->price_diff === null ? null : (float) $row->price_diff, 'workflow' => $workflow, 'scheme' => $this->decode((string) ($row->scheme_payload ?? '[]'), []), 'tasks' => $this->decode((string) ($row->task_payload ?? '[]'), []), 'status_key' => DingPayableWorkflowStatus::key($workflow), 'status_label' => DingPayableWorkflowStatus::label($workflow), 'matched_product' => $product, 'workflow_created_at' => (string) ($row->workflow_created_at ?? ''), ]; } private function contains(string $value): string { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; } private function cleanName(mixed $value): ?string { if ($value === null) { return null; } $name = trim((string) $value); return $name === '' ? null : $name; } private function decode(string $json, mixed $fallback): mixed { if ($json === '') { return $fallback; } $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : $fallback; } }