checkTable('accounting_accounts', $businessId, 'chart_of_accounts'); $checks[] = $this->checkGlActivity($businessId); $checks[] = $this->checkPostingIntegrity($businessId); $checks[] = $this->checkUnlinkedPaymentAccounts($businessId); $checks[] = $this->checkFailedPostings($businessId); $checks[] = $this->checkClosedPeriods($businessId); $checks[] = $this->checkStaleExchangeRates($businessId); $checks[] = $this->checkEntityTagging($businessId); $checks[] = $this->checkOperationalMapping($businessId); $score = 0; foreach ($checks as $check) { $score += $check['score']; } return [ 'score' => min(100, $score), 'status' => $score >= 80 ? 'healthy' : ($score >= 50 ? 'watch' : 'critical'), 'checks' => $checks, ]; } protected function checkTable(string $table, int $businessId, string $label): array { if (! Schema::hasTable($table)) { return ['key' => $label, 'ok' => false, 'score' => 0, 'message' => "Missing table: {$table}"]; } $count = DB::table($table)->where('business_id', $businessId)->count(); return [ 'key' => $label, 'ok' => $count > 0, 'score' => $count > 0 ? 15 : 5, 'message' => $count > 0 ? "{$count} records" : 'No records yet', ]; } protected function checkGlActivity(int $businessId): array { if (! Schema::hasTable('accounting_accounts_transactions')) { return ['key' => 'gl_activity', 'ok' => false, 'score' => 0, 'message' => 'GL transactions table missing']; } $count = DB::table('accounting_accounts_transactions as aat') ->join('accounting_accounts as aa', 'aa.id', '=', 'aat.accounting_account_id') ->where('aa.business_id', $businessId) ->count(); return [ 'key' => 'gl_activity', 'ok' => $count > 0, 'score' => $count > 0 ? 20 : 5, 'message' => "{$count} GL lines", ]; } protected function checkPostingIntegrity(int $businessId): array { if (! Schema::hasTable('accounting_posting_logs')) { return ['key' => 'posting_integrity', 'ok' => true, 'score' => 10, 'message' => 'Posting log not migrated yet']; } $duplicates = DB::table('accounting_posting_logs') ->where('business_id', $businessId) ->where('status', 'posted') ->select('posting_key', DB::raw('COUNT(*) as c')) ->groupBy('posting_key') ->having('c', '>', 1) ->count(); return [ 'key' => 'posting_integrity', 'ok' => $duplicates === 0, 'score' => $duplicates === 0 ? 20 : 0, 'message' => $duplicates === 0 ? 'No duplicate postings' : "{$duplicates} duplicate posting keys", ]; } protected function checkUnlinkedPaymentAccounts(int $businessId): array { if (! Schema::hasColumn('accounts', 'accounting_account_id')) { return ['key' => 'payment_gl_link', 'ok' => true, 'score' => 10, 'message' => 'Link column not migrated yet']; } $unlinked = DB::table('accounts') ->where('business_id', $businessId) ->whereNull('accounting_account_id') ->count(); return [ 'key' => 'payment_gl_link', 'ok' => $unlinked === 0, 'score' => $unlinked === 0 ? 15 : 5, 'message' => $unlinked === 0 ? 'All payment accounts linked' : "{$unlinked} unlinked payment accounts", ]; } protected function checkFailedPostings(int $businessId): array { if (! Schema::hasTable('accounting_posting_logs')) { return ['key' => 'failed_postings', 'ok' => true, 'score' => 10, 'message' => 'N/A']; } $failed = DB::table('accounting_posting_logs') ->where('business_id', $businessId) ->where('status', 'failed') ->count(); return [ 'key' => 'failed_postings', 'ok' => $failed === 0, 'score' => $failed === 0 ? 10 : 0, 'message' => $failed === 0 ? 'No failed postings' : "{$failed} failed postings", ]; } protected function checkClosedPeriods(int $businessId): array { if (! Schema::hasTable('accounting_fiscal_periods')) { return ['key' => 'fiscal_periods', 'ok' => true, 'score' => 10, 'message' => 'Fiscal periods not migrated yet']; } $count = DB::table('accounting_fiscal_periods')->where('business_id', $businessId)->count(); return [ 'key' => 'fiscal_periods', 'ok' => $count > 0, 'score' => $count > 0 ? 10 : 5, 'message' => "{$count} fiscal periods configured", ]; } protected function checkStaleExchangeRates(int $businessId): array { if (! Schema::hasTable('accounting_exchange_rates') || ! class_exists(ExchangeRateService::class)) { return ['key' => 'fx_rates', 'ok' => true, 'score' => 10, 'message' => 'FX table not available']; } $summary = app(ExchangeRateService::class)->staleRateSummary($businessId, 2); return [ 'key' => 'fx_rates', 'ok' => ($summary['total_issues'] ?? 0) === 0, 'score' => ($summary['total_issues'] ?? 0) === 0 ? 10 : 0, 'message' => ($summary['total_issues'] ?? 0) === 0 ? 'Exchange rates are current' : ($summary['total_issues'].' stale/missing FX pair(s)'), ]; } protected function checkEntityTagging(int $businessId): array { if (! class_exists(HoldingEntityResolutionService::class)) { return ['key' => 'entity_tagging', 'ok' => true, 'score' => 5, 'message' => 'N/A']; } $tagging = app(HoldingEntityResolutionService::class)->assessTagging($businessId); if (($tagging['entities'] ?? 0) === 0) { return ['key' => 'entity_tagging', 'ok' => true, 'score' => 5, 'message' => 'No holding entities']; } $ready = (bool) ($tagging['ready'] ?? false); return [ 'key' => 'entity_tagging', 'ok' => $ready, 'score' => $ready ? 10 : 0, 'message' => $ready ? ($tagging['tagged_pct'].'% GL lines tagged by entity') : ($tagging['untagged_lines'].' untagged GL line(s)'), ]; } protected function checkOperationalMapping(int $businessId): array { if (! class_exists(OperationalTransactionPostingService::class)) { return ['key' => 'operational_mapping', 'ok' => true, 'score' => 5, 'message' => 'N/A']; } $gaps = app(OperationalTransactionPostingService::class)->assessGaps($businessId); $total = (int) ($gaps['total_unmapped'] ?? 0); return [ 'key' => 'operational_mapping', 'ok' => $total === 0, 'score' => $total === 0 ? 10 : max(0, 10 - min(10, $total)), 'message' => $total === 0 ? 'Operational transactions mapped ('.($gaps['unified_posting'] ? 'unified' : 'legacy').')' : "{$total} unmapped operational transaction(s) in last 90 days", ]; } }