'待付款', '未确认' => '待付款', '出纳状态待同步' => '待付款', '出纳未开始' => '待付款', '出纳待打款' => '待付款', '出纳退回' => '待付款', '出纳已打款' => '已付款', '流程终止' => '不涉及付款', ]; public const LEGACY_STATUS_MAP = [ '已终止' => '已撤销', '已撤回' => '已撤销', ]; private readonly ConnectionInterface $connection; private ?bool $paymentSourceStateColumn = null; private ?bool $cashierStatusColumn = null; public function __construct(DatabaseManager $database) { $this->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 static function normalizePaymentStateFilter(mixed $value): string { $paymentState = trim((string) $value); $paymentState = self::LEGACY_PAYMENT_STATE_MAP[$paymentState] ?? $paymentState; return in_array($paymentState, self::PAYMENT_STATES, true) ? $paymentState : ''; } public static function normalizeStatusFilter(mixed $value): string { $status = trim((string) $value); return self::LEGACY_STATUS_MAP[$status] ?? $status; } 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), 200); $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(), ]; } /** * @param array $filters * @return array{document_types:list,statuses:list,payment_states:list} */ public function filterOptions(array $filters = []): 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->paymentStatesAvailableFor($filters), ]; } /** @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, ' . 'COALESCE(detail_totals.approval_amount, 0) ' . '- COALESCE(payment_totals.payment_amount, 0) as payment_difference, ' . $this->paymentStateExpression() . ' as payment_state', ); } else { $query ->selectRaw( 'NULL as payment_fact_amount, NULL as payment_difference, ' . $this->paymentStateExpression() . ' as 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()); $paidStart = $this->filterDate($filters['paid_start'] ?? ''); $paidEnd = $this->filterDate($filters['paid_end'] ?? ''); if ($paidStart === '' && $paidEnd === '') { $paidStart = $this->filterDate($filters['paid_date'] ?? ''); $paidEnd = $paidStart; } if ($paidStart !== '' && $paidEnd !== '' && $paidStart > $paidEnd) { [$paidStart, $paidEnd] = [$paidEnd, $paidStart]; } if ($paidStart !== '') { $query->where('ding_process_detail_mirror.paid_at', '>=', $paidStart); } if ($paidEnd !== '') { $query->where( 'ding_process_detail_mirror.paid_at', '<', Carbon::createFromFormat('Y-m-d', $paidEnd)->addDay()->toDateString(), ); } foreach ([ 'document_type' => 'document_type', 'status' => 'status', ] as $filter => $column) { $value = trim((string) ($filters[$filter] ?? '')); if ($filter === 'status') { $value = self::normalizeStatusFilter($value); } 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 = self::normalizePaymentStateFilter($filters['payment_state'] ?? ''); if ($paymentState !== '') { $query->whereRaw( '(' . $this->paymentStateExpression() . ') = ?', [$paymentState], ); } return $query; } private function paymentStateExpression(): string { $sourceState = $this->paymentSourceStateColumnAvailable() ? 'ding_process_detail_mirror.payment_source_state' : "'missing'"; $cashierState = $this->cashierStatusColumnAvailable() ? 'ding_process_detail_mirror.cashier_status' : "'pending_sync'"; $workflowStatus = 'ding_process_detail_mirror.status'; $paymentFacts = $this->structuredPaymentsEnabled() ? "EXISTS ( SELECT 1 FROM ding_process_payment_mirror AS payment_check WHERE payment_check.business_id = ding_process_detail_mirror.business_id AND payment_check.source_scope = ding_process_detail_mirror.source_scope )" : '0 = 1'; return "CASE WHEN {$sourceState} = 'not_applicable' OR {$cashierState} = 'terminated' OR (({$cashierState} IS NULL OR {$cashierState} = '') AND {$workflowStatus} IN ('已撤销', '已终止')) THEN '不涉及付款' WHEN {$cashierState} = 'completed' THEN '已付款' WHEN {$workflowStatus} = '已完成' AND {$sourceState} <> 'not_applicable' THEN '已付款' WHEN {$cashierState} IN ('pending', 'returned', 'not_started') THEN '待付款' WHEN {$workflowStatus} = '审批中' AND ({$paymentFacts}) THEN '已付款' ELSE '待付款' END"; } private function filterDate(mixed $value): string { $date = trim((string) $value); if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $parts) !== 1 || ! checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) { return ''; } return $date; } /** * @param array $filters * @return list */ private function paymentStatesAvailableFor(array $filters): array { $optionFilters = $filters; $optionFilters['payment_state'] = ''; $available = $this->applyFilters( $this->connection->table('ding_process_detail_mirror'), $optionFilters, ) ->selectRaw($this->paymentStateExpression() . ' as payment_state') ->groupByRaw($this->paymentStateExpression()) ->pluck('payment_state') ->map(static fn (mixed $value): string => (string) $value) ->all(); return array_values(array_filter( self::PAYMENT_STATES, static fn (string $state): bool => in_array($state, $available, true), )); } private function paymentSourceStateColumnAvailable(): bool { return $this->paymentSourceStateColumn ??= Schema::connection( $this->connection->getName(), )->hasColumn('ding_process_detail_mirror', 'payment_source_state'); } private function cashierStatusColumnAvailable(): bool { return $this->cashierStatusColumn ??= Schema::connection( $this->connection->getName(), )->hasColumn('ding_process_detail_mirror', 'cashier_status'); } /** @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'; } }