Build functions/ jobs for data_pool and transformation

This commit is contained in:
2025-11-17 15:27:21 +01:00
parent 46820e9515
commit 976af0f2e6
9 changed files with 1417 additions and 26 deletions
@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class RebuildFromDataPoolCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:rebuild-from-data-pool-command';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
//
}
}
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\TransformDataPoolToProduction;
use Illuminate\Console\Command;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\info;
use function Laravel\Prompts\spin;
use function Laravel\Prompts\table;
use function Laravel\Prompts\warning;
class TransformDataPoolCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'backend:transform-data-pool
{--batch-size=100 : Number of records to process per batch}
{--queue : Dispatch the job to the queue instead of running synchronously}
{--stats : Show statistics only, do not perform transformation}';
/**
* The console command description.
*/
protected $description = 'Transform backend_data_pool into companies and transactions tables';
/**
* Execute the console command.
*/
public function handle(): int
{
// Show stats only
if ($this->option('stats')) {
return $this->showStats();
}
info('Preparing for DATA POOL TRANSFORMATION');
warning('This will create/update companies and transactions from the data pool.');
if (! $this->option('no-interaction') && ! confirm('Do you want to continue?', true)) {
info('Transformation cancelled.');
return self::SUCCESS;
}
$batchSize = (int) $this->option('batch-size');
// Show current stats before transformation
$this->showStatsBefore();
// Create the job
$job = new TransformDataPoolToProduction(batchSize: $batchSize);
// Execute the job
if ($this->option('queue')) {
info('Dispatching job to queue...');
dispatch($job);
info('Job dispatched successfully!');
} else {
info('Running transformation synchronously...');
spin(
fn () => $job->handle(app(\App\Services\KycRiskCalculator::class)),
'Transforming data...'
);
info('Transformation completed successfully!');
}
// Show stats after transformation
$this->showStatsAfter();
return self::SUCCESS;
}
/**
* Show statistics only
*/
private function showStats(): int
{
$job = new TransformDataPoolToProduction();
$stats = $job->getStats();
info('Data Pool Transformation Statistics');
table(
['Metric', 'Value'],
[
['Data Pool Transactions', number_format($stats['data_pool_transactions'])],
['Companies in Database', number_format($stats['companies_count'])],
['Transactions in Database', number_format($stats['transactions_count'])],
['Migrated Transactions', number_format($stats['migrated_transactions'])],
]
);
return self::SUCCESS;
}
/**
* Show stats before transformation
*/
private function showStatsBefore(): void
{
$job = new TransformDataPoolToProduction();
$stats = $job->getStats();
$this->newLine();
info('Stats BEFORE transformation:');
table(
['Metric', 'Value'],
[
['Data Pool Transactions', number_format($stats['data_pool_transactions'])],
['Companies in Database', number_format($stats['companies_count'])],
['Migrated Transactions', number_format($stats['migrated_transactions'])],
]
);
$this->newLine();
}
/**
* Show stats after transformation
*/
private function showStatsAfter(): void
{
$job = new TransformDataPoolToProduction();
$stats = $job->getStats();
$this->newLine();
info('Stats AFTER transformation:');
table(
['Metric', 'Value'],
[
['Data Pool Transactions', number_format($stats['data_pool_transactions'])],
['Companies in Database', number_format($stats['companies_count'])],
['Migrated Transactions', number_format($stats['migrated_transactions'])],
]
);
$this->newLine();
}
}
+375
View File
@@ -0,0 +1,375 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Company;
use App\Models\Transaction;
use App\Services\KycRiskCalculator;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TransformDataPoolToProduction implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 3600; // 1 hour timeout
public int $tries = 3;
/**
* Create a new job instance.
*/
public function __construct(
public ?int $batchSize = 100,
) {
}
/**
* Execute the job.
*/
public function handle(KycRiskCalculator $kycCalculator): void
{
$startTime = now();
Log::info('TransformDataPoolToProduction: Starting transformation', [
'batch_size' => $this->batchSize,
]);
try {
DB::transaction(function () use ($kycCalculator) {
$this->transformData($kycCalculator);
});
$duration = now()->diffInSeconds($startTime);
Log::info('TransformDataPoolToProduction: Transformation completed successfully', [
'duration_seconds' => $duration,
]);
} catch (\Exception $e) {
Log::error('TransformDataPoolToProduction: Transformation failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
/**
* Transform data from backend_data_pool to companies and transactions
*/
private function transformData(KycRiskCalculator $kycCalculator): void
{
// Get all unique transaction IDs from data pool
$transactionIds = DB::table('backend_data_pool')
->select('transaction_id')
->distinct()
->pluck('transaction_id');
Log::info('TransformDataPoolToProduction: Found transactions to process', [
'count' => $transactionIds->count(),
]);
$processedCompanies = 0;
$processedTransactions = 0;
foreach ($transactionIds as $transactionId) {
// Get all outputs for this transaction
$outputs = DB::table('backend_data_pool')
->where('transaction_id', $transactionId)
->get();
if ($outputs->isEmpty()) {
continue;
}
$firstOutput = $outputs->first();
// 1. Create or update Company
$company = $this->createOrUpdateCompany($firstOutput, $outputs, $kycCalculator);
if ($company->wasRecentlyCreated) {
$processedCompanies++;
}
// 2. Create or update Transaction
$transaction = $this->createOrUpdateTransaction($company, $firstOutput, $outputs);
if ($transaction->wasRecentlyCreated) {
$processedTransactions++;
}
}
Log::info('TransformDataPoolToProduction: Processing completed', [
'new_companies' => $processedCompanies,
'new_transactions' => $processedTransactions,
]);
}
/**
* Create or update a Company from data pool outputs
*/
private function createOrUpdateCompany($firstOutput, $outputs, KycRiskCalculator $kycCalculator): Company
{
$corporateEntity = $firstOutput->corporate_entity;
// Calculate KYC Risk Level
$kycRiskLevel = $kycCalculator->calculateKycRiskLevel(collect([$outputs])->first());
// Extract company data from outputs
$companyData = [
'country' => $this->extractCountry($firstOutput, $outputs),
'kyc_risk_level' => $kycRiskLevel,
'sector' => $this->extractField($outputs, 'corporate_sector', 255),
'headquarters' => $this->extractField($outputs, 'corporate_HQ', 255),
'summary' => $this->extractField($outputs, 'corporate_summary', 5000), // text field, longer OK
'legal_name' => $this->extractField($outputs, 'corporate_name', 255),
];
return Company::updateOrCreate(
['name' => $corporateEntity],
$companyData
);
}
/**
* Create or update a Transaction from data pool outputs
*/
private function createOrUpdateTransaction(Company $company, $firstOutput, $outputs): Transaction
{
$reference = 'MIGRATED-' . $firstOutput->transaction_id;
// Map KYC Risk Level to Transaction Status
$status = match ($company->kyc_risk_level) {
'critical' => Transaction::STATUS_TRUE_POSITIVE,
'high' => Transaction::STATUS_FALSE_POSITIVE,
'low' => Transaction::STATUS_CLEARED,
default => Transaction::STATUS_FALSE_POSITIVE, // Default to high risk
};
// Core transaction data
$transactionData = [
'company_id' => $company->id,
'amount' => $firstOutput->tx_amount,
'currency' => $firstOutput->tx_currency ?? 'EUR',
'counterparty' => $firstOutput->corporate_counterparty,
'counterparty_country' => $this->extractCountryCode($firstOutput->tx_country_incoming),
'channel' => $firstOutput->source_file,
'executed_at' => $this->parseDate($firstOutput->tx_date),
'risk_score' => $this->extractRiskScore($outputs),
'status' => $status,
'requires_review' => true,
'flagged_reason' => $firstOutput->tx_purpose,
];
// Map all output_keys to their respective columns
foreach ($outputs as $output) {
$columnName = $output->output_key;
// Skip if column doesn't exist in transactions table
if (! $this->columnExists($columnName)) {
continue;
}
// Decode JSON content, store NULL if empty or invalid
$content = json_decode($output->content, true);
$transactionData[$columnName] = $content ?: null;
}
return Transaction::updateOrCreate(
['reference' => $reference],
$transactionData
);
}
/**
* Extract country code from data
*/
private function extractCountry($firstOutput, $outputs): string
{
// Try to get from tx_country_incoming
if (! empty($firstOutput->tx_country_incoming)) {
$country = $this->extractCountryCode($firstOutput->tx_country_incoming);
if ($country) {
return $country;
}
}
// Try to extract from corporate_HQ
$hq = $this->extractField($outputs, 'corporate_HQ');
if ($hq && is_array($hq) && isset($hq['answer'])) {
// Simple pattern matching for country codes
if (preg_match('/\b([A-Z]{2})\b/', $hq['answer'], $matches)) {
return $matches[1];
}
}
return 'DE'; // Default
}
/**
* Extract country code (convert to ISO 2-letter if needed)
*/
private function extractCountryCode(?string $country): ?string
{
if (empty($country)) {
return null;
}
// If already 2 letters, return uppercase
if (strlen($country) === 2) {
return strtoupper($country);
}
// Simple country name mapping (extend as needed)
$countryMap = [
'Germany' => 'DE',
'Deutschland' => 'DE',
'United States' => 'US',
'USA' => 'US',
'United Kingdom' => 'GB',
'UK' => 'GB',
'France' => 'FR',
'Spain' => 'ES',
'Italy' => 'IT',
'Netherlands' => 'NL',
'Belgium' => 'BE',
'Austria' => 'AT',
'Switzerland' => 'CH',
'Poland' => 'PL',
'Czech Republic' => 'CZ',
'Denmark' => 'DK',
'Sweden' => 'SE',
'Norway' => 'NO',
'Finland' => 'FI',
];
return $countryMap[$country] ?? null;
}
/**
* Extract risk score from tranx_score output
*/
private function extractRiskScore($outputs): int
{
$scoreOutput = $outputs->firstWhere('output_key', 'tranx_score');
if (! $scoreOutput) {
return 128; // Default: Medium risk (255/2)
}
$scoreData = json_decode($scoreOutput->content, true);
if (is_array($scoreData) && isset($scoreData['score'])) {
// Assume score is 0-100, convert to 0-255
$score = (int) $scoreData['score'];
return (int) round(($score / 100) * 255);
}
return 128;
}
/**
* Extract a field from outputs
*/
private function extractField($outputs, string $outputKey, int $maxLength = 255): mixed
{
$output = $outputs->firstWhere('output_key', $outputKey);
if (! $output) {
return null;
}
$data = json_decode($output->content, true);
// Extract 'answer' field if it exists
if (is_array($data) && isset($data['answer'])) {
$answer = $data['answer'];
// If answer is an object/array, convert to string
if (is_array($answer)) {
$answer = json_encode($answer);
}
// Truncate to max length if needed
return $this->truncateString((string) $answer, $maxLength);
}
// If data is an object/array, convert to JSON string
if (is_array($data)) {
return $this->truncateString(json_encode($data), $maxLength);
}
return $this->truncateString((string) $data, $maxLength);
}
/**
* Truncate string to max length
*/
private function truncateString(string $text, int $maxLength): string
{
if (strlen($text) <= $maxLength) {
return $text;
}
return substr($text, 0, $maxLength - 3) . '...';
}
/**
* Parse date string to Carbon instance
*/
private function parseDate(?string $date): ?Carbon
{
if (empty($date)) {
return null;
}
try {
return Carbon::parse($date);
} catch (\Exception $e) {
Log::warning('Failed to parse date', ['date' => $date]);
return null;
}
}
/**
* Check if column exists in transactions table
*/
private function columnExists(string $columnName): bool
{
static $columns = null;
if ($columns === null) {
$columns = DB::getSchemaBuilder()->getColumnListing('transactions');
}
return in_array($columnName, $columns);
}
/**
* Get statistics about the transformation
*/
public function getStats(): array
{
return [
'data_pool_transactions' => DB::table('backend_data_pool')
->select('transaction_id')
->distinct()
->count(),
'companies_count' => Company::count(),
'transactions_count' => Transaction::count(),
'migrated_transactions' => Transaction::where('reference', 'like', 'MIGRATED-%')->count(),
];
}
}
+59 -25
View File
@@ -17,33 +17,66 @@ class Transaction extends Model
public const STATUS_CLEARED = 'cleared';
/**
* Disable mass assignment protection to allow JSONB columns
*
* @var array<int, string>
*/
protected $fillable = [
'company_id',
'reference',
'amount',
'currency',
'counterparty',
'counterparty_country',
'channel',
'executed_at',
'risk_score',
'status',
'requires_review',
'flagged_by',
'flagged_reason',
'signals',
];
protected $guarded = [];
/**
* @var array<string, string>
* Cached JSONB column casts to avoid repeated schema queries
*/
protected $casts = [
'executed_at' => 'datetime',
'requires_review' => 'boolean',
'signals' => 'array',
];
private static ?array $jsonbCasts = null;
/**
* Get the attributes that should be cast.
*
* Dynamically casts all JSONB columns (102 output_keys) as arrays
* Uses static caching to avoid repeated database schema queries
*
* @return array<string, string>
*/
protected function casts(): array
{
// Return cached casts if available
if (self::$jsonbCasts !== null) {
return self::$jsonbCasts;
}
$standardCasts = [
'executed_at' => 'datetime',
'requires_review' => 'boolean',
'signals' => 'array',
];
// Standard non-JSONB columns
$standardColumns = [
'id', 'company_id', 'reference', 'amount', 'currency',
'counterparty', 'counterparty_country', 'channel', 'executed_at',
'risk_score', 'status', 'requires_review', 'flagged_by',
'flagged_reason', 'signals', 'created_at', 'updated_at',
];
try {
// Get all columns from the database (only once per request)
$allColumns = \Illuminate\Support\Facades\Schema::getColumnListing('transactions');
// All remaining columns are JSONB columns that should be cast as arrays
$jsonbColumns = array_diff($allColumns, $standardColumns);
foreach ($jsonbColumns as $column) {
$standardCasts[$column] = 'array';
}
} catch (\Exception $e) {
// If schema query fails (e.g., during migrations), just use standard casts
\Illuminate\Support\Facades\Log::warning('Failed to load JSONB column casts: ' . $e->getMessage());
}
// Cache for subsequent calls
self::$jsonbCasts = $standardCasts;
return self::$jsonbCasts;
}
public function company(): BelongsTo
{
@@ -53,9 +86,10 @@ class Transaction extends Model
public function statusLabel(): string
{
return match ($this->status) {
self::STATUS_TRUE_POSITIVE => __('Bestätigter Treffer'),
self::STATUS_FALSE_POSITIVE => __('Fehlalarm'),
default => __('Freigegeben'),
self::STATUS_TRUE_POSITIVE => __('Kritisches Risiko'),
self::STATUS_FALSE_POSITIVE => __('Hohes Risiko'),
self::STATUS_CLEARED => __('Geringes Risiko'),
default => __('Unbekanntes Risiko'),
};
}
}
+366
View File
@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Collection;
class KycRiskCalculator
{
/**
* KYC Risk Level Schwellenwerte (3 Levels)
*/
private const RISK_THRESHOLDS = [
'low' => 40, // 0-40 = Geringes Risiko
'high' => 70, // 41-70 = Hohes Risiko
'critical' => 100, // 71-100 = Kritisches Risiko
];
/**
* Gewichtung der verschiedenen Risk-Faktoren
*/
private const RISK_WEIGHTS = [
'transaction_score' => 0.40, // 40% Gewichtung
'sanctions' => 0.25, // 25% Gewichtung
'country_risk' => 0.15, // 15% Gewichtung
'pep_adverse' => 0.10, // 10% Gewichtung
'corruption' => 0.10, // 10% Gewichtung
];
/**
* Berechne KYC Risk Level basierend auf Transaction Outputs
*
* @param Collection $outputs Collection von backend_data_pool records für eine Transaction
* @return string 'low', 'high', or 'critical'
*/
public function calculateKycRiskLevel(Collection $outputs): string
{
// Wenn keine Outputs vorhanden: Default = high (vorsichtig)
if ($outputs->isEmpty()) {
return 'high';
}
// Konvertiere Collection zu Key-Value Array für einfacheren Zugriff
$outputsArray = $outputs->pluck('content', 'output_key')->toArray();
$scores = [
'transaction_score' => $this->calculateTransactionScore($outputsArray),
'sanctions' => $this->calculateSanctionsScore($outputsArray),
'country_risk' => $this->calculateCountryRiskScore($outputsArray),
'pep_adverse' => $this->calculatePepAdverseScore($outputsArray),
'corruption' => $this->calculateCorruptionScore($outputsArray),
];
$weightedScore = $this->calculateWeightedScore($scores);
return $this->determineRiskLevel($weightedScore);
}
/**
* Berechne Transaction Score (0-100)
*/
private function calculateTransactionScore(array $outputs): int
{
if (! isset($outputs['tranx_score'])) {
return 60; // Default: Hohes Risiko wenn keine Daten
}
$scoreData = json_decode($outputs['tranx_score'], true);
// Format: {"score": 75}
if (is_array($scoreData) && isset($scoreData['score'])) {
return min(100, max(0, (int) $scoreData['score']));
}
// Format: {"risk_level": "high"}
if (is_array($scoreData) && isset($scoreData['risk_level'])) {
return $this->mapRiskLevelToScore($scoreData['risk_level']);
}
// Falls nur numerischer Wert
if (is_numeric($scoreData)) {
return min(100, max(0, (int) $scoreData));
}
return 60; // Default
}
/**
* Berechne Sanctions Score (0-100)
*/
private function calculateSanctionsScore(array $outputs): int
{
$sanctionFlags = [
'corporate_eusanctions',
'corporate_ofacsanctions',
'corporate_uksanctions',
'sanctions_circumvention',
];
$score = 0;
$foundSanctions = 0;
foreach ($sanctionFlags as $flag) {
if (! isset($outputs[$flag])) {
continue;
}
$data = json_decode($outputs[$flag], true);
if ($this->hasSanctionsMatch($data)) {
$foundSanctions++;
if (is_array($data) && isset($data['severity'])) {
$score += $this->mapSeverityToScore($data['severity']);
} else {
$score += 70; // Default: Hohes Risiko
}
}
}
if ($foundSanctions === 0) {
return 0;
}
$avgScore = $score / $foundSanctions;
// Mehrere Sanctions → Kritisches Risiko
if ($foundSanctions > 1) {
return min(100, (int) round($avgScore * 1.3));
}
return (int) round($avgScore);
}
/**
* Berechne Country Risk Score (0-100)
*/
private function calculateCountryRiskScore(array $outputs): int
{
if (! isset($outputs['country_risk'])) {
return 40; // Default: Geringes Risiko
}
$countryRiskData = json_decode($outputs['country_risk'], true);
if (is_array($countryRiskData) && isset($countryRiskData['score'])) {
return min(100, max(0, (int) $countryRiskData['score']));
}
if (is_array($countryRiskData) && isset($countryRiskData['level'])) {
return $this->mapRiskLevelToScore($countryRiskData['level']);
}
if (is_bool($countryRiskData)) {
return $countryRiskData ? 80 : 20;
}
return 40;
}
/**
* Berechne PEP & Adverse Media Score (0-100)
*/
private function calculatePepAdverseScore(array $outputs): int
{
$score = 0;
$count = 0;
$pepFields = [
'corporate_pepexposure' => 75,
'corporate_pep' => 80,
'corporate_adverse' => 70,
'corporate_AMLexposure' => 85,
];
foreach ($pepFields as $field => $defaultScore) {
if (! isset($outputs[$field])) {
continue;
}
$data = json_decode($outputs[$field], true);
if ($this->hasPositiveMatch($data)) {
$score += $this->extractScoreFromData($data, $defaultScore);
$count++;
}
}
return $count > 0 ? (int) round($score / $count) : 0;
}
/**
* Berechne Corruption Score (0-100)
*/
private function calculateCorruptionScore(array $outputs): int
{
$corruptionFields = [
'corruption_sector',
'corruption_country',
'corruption_relationship',
'corporate_corruptionexposure',
];
$totalScore = 0;
$count = 0;
foreach ($corruptionFields as $field) {
if (! isset($outputs[$field])) {
continue;
}
$data = json_decode($outputs[$field], true);
if ($this->hasPositiveMatch($data)) {
$totalScore += $this->extractScoreFromData($data, 70);
$count++;
}
}
return $count > 0 ? (int) round($totalScore / $count) : 0;
}
/**
* Berechne gewichteten Gesamt-Score
*/
private function calculateWeightedScore(array $scores): float
{
$totalScore = 0.0;
foreach ($scores as $category => $score) {
$weight = self::RISK_WEIGHTS[$category] ?? 0;
$totalScore += $score * $weight;
}
return $totalScore;
}
/**
* Bestimme Risk Level basierend auf Score (3 Levels)
*/
private function determineRiskLevel(float $score): string
{
if ($score <= self::RISK_THRESHOLDS['low']) {
return 'low'; // Geringes Risiko
}
if ($score <= self::RISK_THRESHOLDS['high']) {
return 'high'; // Hohes Risiko
}
return 'critical'; // Kritisches Risiko
}
/**
* Hilfsfunktionen
*/
private function mapRiskLevelToScore(string $level): int
{
return match (strtolower($level)) {
'low', 'green', 'gering', 'geringes risiko' => 25,
'high', 'yellow', 'orange', 'hoch', 'hohes risiko' => 60,
'critical', 'red', 'kritisch', 'kritisches risiko' => 85,
default => 60,
};
}
private function mapSeverityToScore(string $severity): int
{
return match (strtolower($severity)) {
'minor', 'low', 'gering' => 40,
'moderate', 'high', 'hoch' => 65,
'critical', 'severe', 'kritisch' => 90,
default => 65,
};
}
private function hasSanctionsMatch($data): bool
{
if (! is_array($data)) {
return false;
}
return (isset($data['matches']) && ! empty($data['matches']))
|| (isset($data['found']) && $data['found'] === true)
|| (isset($data['sanctioned']) && $data['sanctioned'] === true);
}
private function hasPositiveMatch($data): bool
{
if (is_array($data)) {
return (isset($data['match']) && $data['match'] === true)
|| (isset($data['found']) && $data['found'] === true)
|| (isset($data['matches']) && ! empty($data['matches']))
|| (isset($data['exposure']) && $data['exposure'] === true);
}
if (is_bool($data)) {
return $data;
}
return false;
}
private function extractScoreFromData($data, int $default): int
{
if (is_array($data) && isset($data['score'])) {
return min(100, max(0, (int) $data['score']));
}
if (is_array($data) && isset($data['risk_score'])) {
return min(100, max(0, (int) $data['risk_score']));
}
return $default;
}
/**
* Berechne aggregierten Risk Level für eine Company basierend auf allen Transaktionen
*/
public function calculateCompanyRiskLevel(Collection $transactions): string
{
if ($transactions->isEmpty()) {
return 'high'; // Default: Hohes Risiko wenn keine Daten
}
$riskScores = [
'low' => 0,
'high' => 0,
'critical' => 0,
];
// Zähle Risk Levels aller Transaktionen
foreach ($transactions as $transactionOutputs) {
$riskLevel = $this->calculateKycRiskLevel($transactionOutputs);
$riskScores[$riskLevel]++;
}
$totalTransactions = $transactions->count();
// Worst-Case-Prinzip:
// 1. Wenn EINE Transaktion kritisches Risiko hat → Company ist kritisch
if ($riskScores['critical'] > 0) {
return 'critical';
}
// 2. Wenn >30% der Transaktionen hohes Risiko haben → Company ist kritisch
$highRiskPercentage = $riskScores['high'] / $totalTransactions;
if ($highRiskPercentage > 0.3) {
return 'critical';
}
// 3. Wenn >10% hohes Risiko → Company ist hohes Risiko
if ($highRiskPercentage > 0.1) {
return 'high';
}
// 4. Wenn >80% geringes Risiko → Company ist geringes Risiko
if ($riskScores['low'] / $totalTransactions > 0.8) {
return 'low';
}
// 5. Default: Hohes Risiko (vorsichtig)
return 'high';
}
}