Build functions/ jobs for data_pool and transformation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user