connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror') ); } public function scope(): string { $scope = trim((string) config( 'upstream_mirrors.ding_process_details.scope', self::DEFAULT_SCOPE, )); return $scope !== '' ? $scope : self::DEFAULT_SCOPE; } public function datasetKey(): string { return self::DATASET_PREFIX . $this->scope(); } public function ready(): bool { $schema = Schema::connection($this->connection->getName()); if (! $schema->hasTable('ding_process_detail_mirror') || ! $schema->hasTable('business_mirror_table_states')) { return false; } $state = $this->state(); return $state !== null && ($state['initial_completed_at'] ?? null) !== null; } /** @return array|null */ public function state(): ?array { if (! Schema::connection($this->connection->getName())->hasTable('business_mirror_table_states')) { return null; } $row = $this->connection->table('business_mirror_table_states') ->where('dataset_key', $this->datasetKey()) ->first(); return $row === null ? null : (array) $row; } /** * @param array $filters * @return array{ * data:list, * total:int, * summary:array{rows:int,approvals:int,amount_total:float,bank_amount_total:float}, * status:string, * last_synced_at:?string, * sync_stale:bool, * sync_stale_after_minutes:int, * scope:string * } */ public function paginate(array $filters, int $page, int $perPage): array { if (! $this->ready()) { throw new RuntimeException('ding_process_detail_mirror_not_ready'); } $page = max($page, 1); $perPage = min(max($perPage, 1), 100); $query = $this->filteredQuery($filters); $total = (clone $query)->count(); $bankAmountTotal = $this->structuredPaymentsEnabled() ? (float) ($this->connection->table('ding_process_payment_mirror') ->where('source_scope', $this->scope()) ->whereIn( 'business_id', (clone $query)->select('ding_process_detail_mirror.business_id'), ) ->sum('amount') ?? 0) : (float) ((clone $query)->sum('ding_process_detail_mirror.bank_amount') ?? 0); $summary = [ 'rows' => $total, 'approvals' => (clone $query)->distinct()->count('ding_process_detail_mirror.business_id'), 'amount_total' => (float) ((clone $query)->sum('ding_process_detail_mirror.amount') ?? 0), 'bank_amount_total' => $bankAmountTotal, ]; $data = $query ->orderByDesc('ding_process_detail_mirror.business_month') ->orderByDesc('ding_process_detail_mirror.business_id') ->orderBy('ding_process_detail_mirror.line_no') ->forPage($page, $perPage) ->get() ->all(); $state = $this->state(); $staleAfterMinutes = max( 15, (int) config('upstream_mirrors.ding_process_details.sync_interval_minutes', 5) * 3, ); return [ 'data' => $data, 'total' => $total, 'summary' => $summary, 'status' => (string) ($state['status'] ?? 'unknown'), 'last_synced_at' => isset($state['last_succeeded_at']) ? (string) $state['last_succeeded_at'] : null, 'sync_stale' => $this->stateIsStale($state, $staleAfterMinutes), 'sync_stale_after_minutes' => $staleAfterMinutes, 'scope' => $this->scope(), ]; } /** @return array{document_types:list,statuses:list,payment_states:list} */ public function filterOptions(): array { if (! Schema::connection($this->connection->getName())->hasTable('ding_process_detail_mirror')) { return ['document_types' => [], 'statuses' => [], 'payment_states' => []]; } $base = $this->connection->table('ding_process_detail_mirror') ->where('source_scope', $this->scope()); return [ 'document_types' => (clone $base) ->whereNotNull('document_type') ->where('document_type', '!=', '') ->distinct() ->orderBy('document_type') ->pluck('document_type') ->map(static fn (mixed $value): string => (string) $value) ->all(), 'statuses' => (clone $base) ->whereNotNull('status') ->where('status', '!=', '') ->distinct() ->orderBy('status') ->pluck('status') ->map(static fn (mixed $value): string => (string) $value) ->all(), 'payment_states' => $this->structuredPaymentsEnabled() ? self::PAYMENT_STATES : [self::LEGACY_PAYMENT_STATE], ]; } /** @param array $filters */ public function exportCount(array $filters): int { if (! Schema::connection($this->connection->getName())->hasTable('ding_process_detail_mirror')) { return 0; } return (int) $this->filteredQuery($filters)->count(); } /** * @param array $filters * @return \Generator> */ public function exportRows(array $filters): \Generator { $query = $this->filteredQuery($filters) ->orderByDesc('business_month') ->orderByDesc('business_id') ->orderBy('line_no'); foreach ($query->cursor() as $row) { yield (array) $row; } } /** @param array $filters */ private function filteredQuery(array $filters): Builder { $query = $this->connection->table('ding_process_detail_mirror') ->select('ding_process_detail_mirror.*'); if ($this->structuredPaymentsEnabled()) { $detailTotals = $this->connection->table('ding_process_detail_mirror') ->select('business_id') ->selectRaw('SUM(COALESCE(amount, 0)) as approval_amount') ->where('source_scope', $this->scope()) ->groupBy('business_id'); $paymentTotals = $this->connection->table('ding_process_payment_mirror') ->select('business_id') ->selectRaw('SUM(COALESCE(amount, 0)) as payment_amount') ->where('source_scope', $this->scope()) ->groupBy('business_id'); $query ->leftJoinSub($detailTotals, 'detail_totals', function ($join): void { $join->on( 'detail_totals.business_id', '=', 'ding_process_detail_mirror.business_id', ); }) ->leftJoinSub($paymentTotals, 'payment_totals', function ($join): void { $join->on( 'payment_totals.business_id', '=', 'ding_process_detail_mirror.business_id', ); }) ->selectRaw( 'COALESCE(payment_totals.payment_amount, 0) as payment_fact_amount, ' . $this->paymentStateExpression() . ' as payment_state', ); } else { $query ->selectRaw( 'NULL as payment_fact_amount, ? as payment_state', [self::LEGACY_PAYMENT_STATE], ); } return $this->applyFilters($query, $filters); } /** @param array $filters */ private function applyFilters(Builder $query, array $filters): Builder { $query->where('ding_process_detail_mirror.source_scope', $this->scope()); $paidDate = trim((string) ($filters['paid_date'] ?? '')); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $paidDate) === 1) { $query->where('ding_process_detail_mirror.paid_at', 'like', $paidDate . '%'); } foreach ([ 'document_type' => 'document_type', 'status' => 'status', ] as $filter => $column) { $value = trim((string) ($filters[$filter] ?? '')); if ($value !== '') { $query->where('ding_process_detail_mirror.' . $column, $value); } } foreach ([ 'business_id' => 'business_id', 'originator_name' => 'originator_name', 'keyword' => 'purpose', ] as $filter => $column) { $value = mb_substr(trim((string) ($filters[$filter] ?? '')), 0, 100); if ($value !== '') { $query->where('ding_process_detail_mirror.' . $column, 'like', '%' . $value . '%'); } } $paymentState = trim((string) ($filters['payment_state'] ?? '')); if ($paymentState !== '') { $allowed = $this->structuredPaymentsEnabled() ? self::PAYMENT_STATES : [self::LEGACY_PAYMENT_STATE]; if (in_array($paymentState, $allowed, true)) { if ($paymentState !== self::LEGACY_PAYMENT_STATE) { $query->whereRaw( '(' . $this->paymentStateExpression() . ') = ?', [$paymentState], ); } } } return $query; } private function paymentStateExpression(): string { $sourceState = $this->paymentSourceStateColumnAvailable() ? 'ding_process_detail_mirror.payment_source_state' : "'missing'"; return "CASE WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005 AND payment_totals.payment_amount + 0.01 < COALESCE(detail_totals.approval_amount, 0) THEN '部分付款' WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005 THEN '已付款' WHEN {$sourceState} = 'not_applicable' THEN '不适用' WHEN {$sourceState} = 'present' THEN '未付款' ELSE '付款明细待同步' END"; } private function paymentSourceStateColumnAvailable(): bool { return $this->paymentSourceStateColumn ??= Schema::connection( $this->connection->getName(), )->hasColumn('ding_process_detail_mirror', 'payment_source_state'); } /** @param array|null $state */ private function stateIsStale(?array $state, int $staleAfterMinutes): bool { if (($state['status'] ?? '') !== 'succeeded' || empty($state['last_succeeded_at'])) { return true; } return Carbon::parse((string) $state['last_succeeded_at']) ->lt(now()->subMinutes($staleAfterMinutes)); } private function structuredPaymentsEnabled(): bool { if (! Schema::connection($this->connection->getName())->hasTable('ding_process_payment_mirror')) { return false; } $state = $this->state(); $cursor = $state['cursor'] ?? null; if (is_string($cursor)) { $cursor = json_decode($cursor, true); } return is_array($cursor) && ($cursor['source'] ?? '') === 'structured'; } }