connection = $mirror->connection(); } public function available(): bool { try { $schema = Schema::connection($this->connection->getName()); $dataset = $this->datasets->get(self::DATASET); return $schema->hasTable('business_mirror_table_states') && $schema->hasTable($dataset->targetTable()); } catch (Throwable) { return false; } } public function ready(): bool { try { return $this->mirror->ready($this->datasets->get(self::DATASET)); } catch (Throwable) { return false; } } /** @return array|null */ public function state(): ?array { try { return $this->mirror->state($this->datasets->get(self::DATASET)); } catch (Throwable) { return null; } } /** @return array{code:string,last_succeeded_at:?string,lag_minutes:?int} */ public function syncStatus(?array $state = null): array { $state ??= $this->state(); if (! is_array($state)) { return ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null]; } $lastSucceeded = $state['last_succeeded_at'] ?? null; $lagMinutes = null; if (is_string($lastSucceeded) && $lastSucceeded !== '') { try { $lagMinutes = (int) ceil(max(0, now()->getTimestamp() - Carbon::parse($lastSucceeded)->getTimestamp()) / 60); } catch (Throwable) { $lagMinutes = null; } } $status = (string) ($state['status'] ?? 'pending'); if (($state['initial_completed_at'] ?? null) === null) { $code = $status === 'failed' ? 'initial_failed' : ($status === 'running' ? 'initializing' : 'pending'); } elseif ($status === 'failed') { $code = 'failed'; } elseif ($status === 'running') { $code = 'syncing'; } elseif ($lagMinutes === null || $lagMinutes > max((int) config('upstream_mirrors.courier_information.stale_after_minutes', 60), 1)) { $code = 'stale'; } else { $code = 'fresh'; } return ['code' => $code, 'last_succeeded_at' => $lastSucceeded, 'lag_minutes' => $lagMinutes]; } /** * @param array $filters * @return array{data:list>,total:int,summary:array,state:array|null,sync:array} */ public function paginate(array $filters, int $page, int $perPage): array { $dataset = $this->datasets->get(self::DATASET); if (! $this->mirror->ready($dataset)) { throw new RuntimeException('courier_freight_reconciliation_mirror_not_ready'); } $page = max($page, 1); $perPage = min(max($perPage, 1), 100); $query = $this->filteredQuery($dataset->targetTable(), $filters); $total = (clone $query)->count(); $summary = (clone $query)->selectRaw( 'COUNT(*) AS aggregate_count, ' . 'COALESCE(SUM(payable_amount), 0) AS payable_amount, ' . 'COALESCE(SUM(actual_payment), 0) AS actual_payment, ' . 'COALESCE(SUM(confirmed_amount), 0) AS confirmed_amount, ' . 'COALESCE(SUM(reconciliation_difference), 0) AS reconciliation_difference' )->first(); $rows = (clone $query)->select(self::COLUMNS) ->orderByDesc('Id') ->forPage($page, $perPage) ->get() ->map(fn (object $row): array => $this->mapRow($row)) ->all(); $state = $this->mirror->state($dataset); return [ 'data' => $rows, 'total' => $total, 'summary' => [ 'count' => (int) ($summary->aggregate_count ?? 0), 'payable_amount' => (float) ($summary->payable_amount ?? 0), 'actual_payment' => (float) ($summary->actual_payment ?? 0), 'confirmed_amount' => (float) ($summary->confirmed_amount ?? 0), 'reconciliation_difference' => (float) ($summary->reconciliation_difference ?? 0), ], 'state' => $state, 'sync' => $this->syncStatus($state), ]; } private function filteredQuery(string $table, array $filters): Builder { $query = $this->connection->table($table) ->where(static function (Builder $query): void { $query->whereNull('is_delete')->orWhere('is_delete', '=', 0); }); $this->containsFilter($query, 'waybill', $filters['express_no'] ?? ''); $this->containsFilter($query, 'expressname', $filters['express_name'] ?? ''); if (($filters['month'] ?? '') !== '') { $query->where('month', '=', $filters['month']); } if (($filters['status'] ?? '') !== '') { $query->where('status', '=', $filters['status']); } if (($filters['modified_from'] ?? '') !== '') { $query->where('modify_time', '>=', $filters['modified_from'] . ' 00:00:00'); } if (($filters['modified_to'] ?? '') !== '') { $query->where('modify_time', '<', date('Y-m-d 00:00:00', strtotime($filters['modified_to'] . ' +1 day'))); } return $query; } private function containsFilter(Builder $query, string $column, mixed $value): void { $value = trim((string) $value); if ($value === '') { return; } $escaped = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value); $query->whereRaw($column . " LIKE ? ESCAPE '!'", ['%' . $escaped . '%']); } /** @return array */ private function mapRow(object $row): array { $mapped = []; foreach (self::COLUMNS as $column) { $mapped[$column] = $row->{$column} ?? null; } foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $amount) { $mapped[$amount] = $mapped[$amount] === null ? null : (float) $mapped[$amount]; } return $mapped; } }