>|null */ private ?array $sourceStatusCache = null; public function __construct(GenericBusinessMirrorRepository $mirror) { $this->connection = $mirror->connection(); } public function ready(): bool { return collect($this->sourceStatuses())->contains( static fn (array $source): bool => (bool) ($source['ready'] ?? false), ); } /** @return array{data:list>,total:int,status:string,last_synced_at:?string,summary:array,source_statuses: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); $total = (int) $this->filteredQuery($filters)->count(); $summary = [ 'count' => 0, 'matched_count' => 0, 'matchable_count' => 0, 'purchase_count' => 0, 'daifa_amount' => 0.0, 'paid_amount' => 0.0, 'price_diff' => 0.0, ]; $pageRows = []; if ($total > 0) { $aggregate = $this->filteredQuery($filters) ->selectRaw( 'COUNT(*) AS row_count, ' . "SUM(CASE WHEN source_type = 'purchase' THEN 1 ELSE 0 END) AS purchase_count, " . "SUM(CASE WHEN source_type = 'ding' THEN 1 ELSE 0 END) AS matchable_count, " . "SUM(CASE WHEN source_type = 'ding' AND 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(CASE WHEN source_type = 'ding' THEN daifa_amount ELSE 0 END), 0) AS daifa_amount, " . "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN paid_amount ELSE 0 END), 0) AS paid_amount, " . "COALESCE(SUM(CASE WHEN source_type = 'ding' THEN price_diff ELSE 0 END), 0) AS price_diff" ) ->first(); $summary = [ 'count' => (int) ($aggregate->row_count ?? 0), 'matched_count' => (int) ($aggregate->matched_count ?? 0), 'matchable_count' => (int) ($aggregate->matchable_count ?? 0), 'purchase_count' => (int) ($aggregate->purchase_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) { $data[] = $this->presentRow($row); } $sources = $this->sourceStatuses(); $readySources = array_filter($sources, static fn (array $source): bool => (bool) ($source['ready'] ?? false)); $synced = array_values(array_filter(array_column($readySources, 'last_synced_at'))); return [ 'data' => $data, 'total' => $total, 'status' => count($readySources) > 1 ? 'available' : (string) (reset($readySources)['status'] ?? 'unknown'), 'last_synced_at' => $synced === [] ? null : max($synced), 'summary' => $summary, 'source_statuses' => $sources, ]; } /** @return \Generator> */ public function exportRows(array $filters): \Generator { if (! $this->ready()) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $rows = $this->filteredQuery($filters) ->orderByDesc('workflow_created_at') ->orderByDesc('process_instance_id') ->orderByDesc('detail_index') ->cursor(); foreach ($rows as $row) { yield $this->presentRow($row); } } private function filteredQuery(array $filters): Builder { $query = $this->combinedQuery(); 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 . '%') ->orWhere('source_order_no', 'like', '%' . $keyword . '%'); }); } if (($filters['status'] ?? '') !== '') { $query->where('status_key', strtoupper((string) $filters['status'])); } if (($filters['created_from'] ?? '') !== '') { $query->where('workflow_created_at', '>=', (string) $filters['created_from']); } if (($filters['created_to'] ?? '') !== '') { $query->where( 'workflow_created_at', '<', Carbon::parse((string) $filters['created_to'])->addDay()->startOfDay()->format('Y-m-d H:i:s'), ); } return $query; } private function combinedQuery(): Builder { $statuses = $this->sourceStatuses(); $queries = []; if ((bool) ($statuses['ding']['ready'] ?? false)) { $queries[] = $this->dingQuery(); } if ((bool) ($statuses['purchase']['ready'] ?? false)) { $queries[] = $this->purchaseQuery(); } if ($queries === []) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $union = array_shift($queries); foreach ($queries as $query) { $union->unionAll($query); } return $this->connection->query()->fromSub($union, 'online_daifa_rows'); } private function dingQuery(): Builder { $status = strtoupper(trim((string) config( 'upstream_mirrors.ding_online_daifa_price.only_status', 'COMPLETED', ))) ?: 'COMPLETED'; return $this->connection->table(self::TARGET_TABLE . ' as d')->select([ DB::raw("'ding' AS source_type"), 'd.process_instance_id', 'd.detail_index', DB::raw('NULL AS purchase_order_id'), DB::raw('NULL AS source_order_no'), 'd.product_id', 'd.product_name', 'd.daifa_trade_no', 'd.shop_trade_no', 'd.shop_name', 'd.payee_name', DB::raw("CASE WHEN d.payee_name = '宁波顾家望业家居有限公司' THEN '刘萧代发' ELSE '1688代发' END AS tag"), 'd.supplier_name', 'd.material_no', 'd.ship_qty', 'd.paid_amount', 'd.daifa_amount', 'd.price_diff', 'd.workflow_payload', 'd.scheme_payload', 'd.task_payload', DB::raw("'" . $status . "' AS status_key"), 'd.workflow_created_at', ]); } private function purchaseQuery(): Builder { return $this->connection->table('product_purchase_price_mirror_rows as p') ->leftJoin('ops_jst_orders as o', 'o.o_id', '=', 'p.source_order_no') ->whereIn('p.document_status', ['Confirmed', 'Finished']) ->select([ DB::raw("'purchase' AS source_type"), 'p.purchase_order_id as process_instance_id', 'p.item_id as detail_index', 'p.purchase_order_id', 'p.source_order_no', DB::raw("'' AS product_id"), DB::raw('NULL AS product_name'), DB::raw("'' AS daifa_trade_no"), 'o.so_id as shop_trade_no', 'o.shop_name', DB::raw("'' AS payee_name"), DB::raw("'采购单管理' AS tag"), 'p.supplier_name', 'p.sku_id as material_no', 'p.quantity as ship_qty', DB::raw('NULL AS paid_amount'), 'p.unit_price as daifa_amount', DB::raw('NULL AS price_diff'), DB::raw('NULL AS workflow_payload'), DB::raw('NULL AS scheme_payload'), DB::raw('NULL AS task_payload'), DB::raw('UPPER(p.document_status) AS status_key'), DB::raw('COALESCE(p.document_created_at, p.purchase_updated_at) AS workflow_created_at'), ]); } /** @return array> */ private function sourceStatuses(): array { if ($this->sourceStatusCache !== null) { return $this->sourceStatusCache; } $schema = Schema::connection($this->connection->getName()); $dingState = null; if ($schema->hasTable(self::TARGET_TABLE) && $schema->hasTable('business_mirror_table_states')) { $dingState = $this->connection->table('business_mirror_table_states') ->where('dataset_key', self::DATASET_KEY) ->first(); } $dingReady = $dingState !== null && $dingState->initial_completed_at !== null; $purchaseState = null; $orderState = null; $requiredPurchaseColumns = [ 'source_order_no', 'supplier_name', 'quantity', 'document_created_at', ]; $purchaseSchemaReady = $schema->hasTable('product_purchase_price_mirror_rows') && $schema->hasTable('product_purchase_price_sync_states') && $schema->hasTable('ops_jst_orders') && $schema->hasColumns('product_purchase_price_mirror_rows', $requiredPurchaseColumns); if ($purchaseSchemaReady) { $purchaseState = $this->connection->table('product_purchase_price_sync_states') ->where('dataset_key', ProductPurchasePriceMirrorRepository::DATASET_KEY) ->first(); $orderState = $schema->hasTable('business_mirror_table_states') ? $this->connection->table('business_mirror_table_states') ->where('dataset_key', self::ORDER_DATASET_KEY) ->first() : null; } $purchaseReady = $purchaseState !== null && $purchaseState->initial_completed_at !== null && (int) ($purchaseState->projection_version ?? 1) >= ProductPurchasePriceMirrorRepository::CURRENT_PROJECTION_VERSION && $orderState !== null && $orderState->initial_completed_at !== null; return $this->sourceStatusCache = [ 'ding' => [ 'label' => '钉钉线上代发审批', 'ready' => $dingReady, 'status' => $dingState === null ? 'pending' : (string) $dingState->status, 'last_synced_at' => $dingState?->last_succeeded_at === null ? null : (string) $dingState->last_succeeded_at, ], 'purchase' => [ 'label' => '聚水潭采购单管理', 'ready' => $purchaseReady, 'status' => $purchaseReady ? (string) $purchaseState->status : ($purchaseSchemaReady ? 'requires_full_sync' : 'pending'), 'last_synced_at' => $purchaseState?->last_succeeded_at === null ? null : (string) $purchaseState->last_succeeded_at, ], ]; } /** @return array */ private function presentRow(object $row): array { $sourceType = (string) ($row->source_type ?? 'ding'); $purchase = $sourceType === 'purchase'; $workflow = $purchase ? [ 'businessId' => (string) ($row->purchase_order_id ?? ''), 'title' => '采购单管理', 'status' => (string) ($row->status_key ?? ''), ] : $this->decode((string) ($row->workflow_payload ?? ''), []); $productId = (string) ($row->product_id ?? ''); $resolvedName = $this->cleanName($row->product_name ?? null); $product = ! $purchase && $productId !== '' && $resolvedName !== null ? ['matched' => true, 'name' => $resolvedName, 'sku_id' => null, 'i_id' => null] : ['matched' => false, 'name' => null, 'sku_id' => null, 'i_id' => null]; $statusKey = $purchase ? strtoupper((string) ($row->status_key ?? '')) : DingPayableWorkflowStatus::key($workflow); return [ 'source_type' => $sourceType, 'process_instance_id' => (string) $row->process_instance_id, 'detail_index' => (int) $row->detail_index, 'purchase_order_id' => $row->purchase_order_id === null ? null : (int) $row->purchase_order_id, 'source_order_no' => $row->source_order_no === null ? null : (string) $row->source_order_no, '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->tag ?? ''), 'supplier_name' => (string) ($row->supplier_name ?? ''), 'material_no' => (string) ($row->material_no ?? ''), 'ship_qty' => $row->ship_qty === null ? null : (float) $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' => $purchase ? [] : $this->decode((string) ($row->scheme_payload ?? ''), []), 'tasks' => $purchase ? [] : $this->decode((string) ($row->task_payload ?? ''), []), 'status_key' => $statusKey, 'status_label' => $purchase ? match ($statusKey) { 'CONFIRMED' => '已确认', 'FINISHED' => '已完成', default => $statusKey, } : DingPayableWorkflowStatus::label($workflow), 'matched_product' => $product, 'order_matched' => ! $purchase || ((string) ($row->shop_trade_no ?? '') !== '' && (string) ($row->shop_name ?? '') !== ''), '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; } }