fix: added upload formular, reorganized some files and put them into a new folder misc
This commit is contained in:
@@ -20,13 +20,21 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_CONNECTION=pgsql
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
# Secondary PostgreSQL Connection (for backend schema)
|
||||
DB_CONNECTION2=pgsql
|
||||
DB_HOST2=127.0.0.1
|
||||
DB_PORT2=5433
|
||||
DB_DATABASE2=risk_ingest_db
|
||||
DB_USERNAME2=risk_ingest_user
|
||||
DB_PASSWORD2=S0prast3r1a
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Backend;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $connection = 'backend';
|
||||
|
||||
protected $table = 'transactions';
|
||||
|
||||
public const UPDATED_AT = 'last_modified_at';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'corporate_entity',
|
||||
'corporate_counterparty',
|
||||
'tx_date',
|
||||
'tx_amount',
|
||||
'tx_currency',
|
||||
'tx_purpose',
|
||||
'tx_country_outgoing',
|
||||
'tx_country_incoming',
|
||||
'source_file',
|
||||
'raw_payload',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tx_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* KI-generierte Outputs für diese Transaktion
|
||||
*/
|
||||
public function outputs(): HasMany
|
||||
{
|
||||
return $this->hasMany(TransactionOutput::class, 'transaction_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hole spezifischen Output-Typ
|
||||
*/
|
||||
public function getOutput(string $key): ?array
|
||||
{
|
||||
return $this->outputs()
|
||||
->where('output_key', $key)
|
||||
->first()
|
||||
?->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hole alle Outputs als Key-Value Array
|
||||
*/
|
||||
public function getOutputsArray(): array
|
||||
{
|
||||
return $this->outputs()
|
||||
->get()
|
||||
->pluck('content', 'output_key')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Company Info aus Outputs
|
||||
*/
|
||||
public function getCompanyInfo(): ?array
|
||||
{
|
||||
return $this->getOutput('company_info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk Assessment aus Outputs
|
||||
*/
|
||||
public function getRiskAssessment(): ?array
|
||||
{
|
||||
return $this->getOutput('risk_assessment');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanctions Check aus Outputs
|
||||
*/
|
||||
public function getSanctionsCheck(): ?array
|
||||
{
|
||||
return $this->getOutput('sanctions');
|
||||
}
|
||||
|
||||
/**
|
||||
* PEP Check aus Outputs
|
||||
*/
|
||||
public function getPepCheck(): ?array
|
||||
{
|
||||
return $this->getOutput('pep');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Hat diese Transaction einen bestimmten Output?
|
||||
*/
|
||||
public function hasOutput(string $key): bool
|
||||
{
|
||||
return $this->outputs()->where('output_key', $key)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist KI-Verarbeitung abgeschlossen?
|
||||
*/
|
||||
public function isProcessed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benötigt Review?
|
||||
*/
|
||||
public function requiresReview(): bool
|
||||
{
|
||||
$risk = $this->getRiskAssessment();
|
||||
|
||||
if (!$risk) {
|
||||
return true; // Keine Risk-Assessment → Review
|
||||
}
|
||||
|
||||
return ($risk['score'] ?? 0) >= 70 || ($risk['requires_review'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display-friendly company name
|
||||
*/
|
||||
public function getCompanyName(): string
|
||||
{
|
||||
$companyInfo = $this->getCompanyInfo();
|
||||
|
||||
return $companyInfo['name'] ?? $this->corporate_entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk score (0-100)
|
||||
*/
|
||||
public function getRiskScore(): int
|
||||
{
|
||||
$risk = $this->getRiskAssessment();
|
||||
|
||||
return $risk['score'] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get risk level (low, medium, high)
|
||||
*/
|
||||
public function getRiskLevel(): string
|
||||
{
|
||||
$risk = $this->getRiskAssessment();
|
||||
|
||||
return $risk['level'] ?? 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Backend;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TransactionOutput extends Model
|
||||
{
|
||||
protected $connection = 'backend';
|
||||
|
||||
protected $table = 'transaction_outputs';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'transaction_id',
|
||||
'prompt_id',
|
||||
'output_key',
|
||||
'content',
|
||||
'run_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'content' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Transaction zu der dieser Output gehört
|
||||
*/
|
||||
public function transaction(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Transaction::class, 'transaction_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Output-Key Konstanten für Type Safety
|
||||
*/
|
||||
public const KEY_COMPANY_INFO = 'company_info';
|
||||
|
||||
public const KEY_RISK_ASSESSMENT = 'risk_assessment';
|
||||
|
||||
public const KEY_SANCTIONS = 'sanctions';
|
||||
|
||||
public const KEY_PEP = 'pep';
|
||||
|
||||
public const KEY_REGISTRY = 'registry';
|
||||
|
||||
public const KEY_GLEIF = 'gleif';
|
||||
|
||||
public const KEY_INSOLVENCY = 'insolvency';
|
||||
|
||||
public const KEY_BUNDESANZEIGER = 'bundesanzeiger';
|
||||
|
||||
public const KEY_RSS = 'rss';
|
||||
|
||||
public const KEY_EU_SANCTIONS = 'eu_sanctions';
|
||||
|
||||
public const KEY_HANDELSREGISTER = 'handelsregister';
|
||||
|
||||
public const KEY_GENESIS = 'genesis';
|
||||
|
||||
public const KEY_GOVDATA = 'govdata';
|
||||
|
||||
/**
|
||||
* Alle verfügbaren Output-Keys
|
||||
*/
|
||||
public static function availableKeys(): array
|
||||
{
|
||||
return [
|
||||
self::KEY_COMPANY_INFO,
|
||||
self::KEY_RISK_ASSESSMENT,
|
||||
self::KEY_SANCTIONS,
|
||||
self::KEY_PEP,
|
||||
self::KEY_REGISTRY,
|
||||
self::KEY_GLEIF,
|
||||
self::KEY_INSOLVENCY,
|
||||
self::KEY_BUNDESANZEIGER,
|
||||
self::KEY_RSS,
|
||||
self::KEY_EU_SANCTIONS,
|
||||
self::KEY_HANDELSREGISTER,
|
||||
self::KEY_GENESIS,
|
||||
self::KEY_GOVDATA,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable label for output key
|
||||
*/
|
||||
public static function getKeyLabel(string $key): string
|
||||
{
|
||||
return match ($key) {
|
||||
self::KEY_COMPANY_INFO => 'Company Information',
|
||||
self::KEY_RISK_ASSESSMENT => 'Risk Assessment',
|
||||
self::KEY_SANCTIONS => 'Sanctions Check',
|
||||
self::KEY_PEP => 'PEP Check',
|
||||
self::KEY_REGISTRY => 'Registry Data',
|
||||
self::KEY_GLEIF => 'GLEIF Data',
|
||||
self::KEY_INSOLVENCY => 'Insolvency Check',
|
||||
self::KEY_BUNDESANZEIGER => 'Bundesanzeiger',
|
||||
self::KEY_RSS => 'RSS Alerts',
|
||||
self::KEY_EU_SANCTIONS => 'EU Sanctions',
|
||||
self::KEY_HANDELSREGISTER => 'Handelsregister',
|
||||
self::KEY_GENESIS => 'Genesis Data',
|
||||
self::KEY_GOVDATA => 'GovData',
|
||||
default => ucfirst(str_replace('_', ' ', $key)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Backend\Transaction;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TransactionRepository
|
||||
{
|
||||
/**
|
||||
* Alle Transaktionen mit Outputs
|
||||
*/
|
||||
public function all(): Collection
|
||||
{
|
||||
return Transaction::with('outputs')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finde Transaction by ID mit Outputs
|
||||
*/
|
||||
public function find(int $id): ?Transaction
|
||||
{
|
||||
return Transaction::with('outputs')->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaktionen die Review benötigen
|
||||
*/
|
||||
public function requiresReview(): Collection
|
||||
{
|
||||
return Transaction::with('outputs')
|
||||
->where('status', Transaction::STATUS_COMPLETED)
|
||||
->get()
|
||||
->filter(fn ($t) => $t->requiresReview());
|
||||
}
|
||||
|
||||
/**
|
||||
* High-Risk Transaktionen
|
||||
*/
|
||||
public function highRisk(int $threshold = 70): Collection
|
||||
{
|
||||
return $this->all()
|
||||
->filter(function ($transaction) use ($threshold) {
|
||||
$risk = $transaction->getRiskAssessment();
|
||||
|
||||
return ($risk['score'] ?? 0) >= $threshold;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaktionen nach Firma
|
||||
*/
|
||||
public function byCompany(string $companyName): Collection
|
||||
{
|
||||
return Transaction::with('outputs')
|
||||
->where('corporate_entity', 'ILIKE', "%{$companyName}%")
|
||||
->orderBy('tx_date', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaktionen nach Status
|
||||
*/
|
||||
public function byStatus(string $status): Collection
|
||||
{
|
||||
return Transaction::with('outputs')
|
||||
->where('status', $status)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending Transaktionen (warten auf KI-Verarbeitung)
|
||||
*/
|
||||
public function pending(): Collection
|
||||
{
|
||||
return $this->byStatus(Transaction::STATUS_PENDING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processing Transaktionen (werden gerade verarbeitet)
|
||||
*/
|
||||
public function processing(): Collection
|
||||
{
|
||||
return $this->byStatus(Transaction::STATUS_PROCESSING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed Transaktionen
|
||||
*/
|
||||
public function completed(): Collection
|
||||
{
|
||||
return $this->byStatus(Transaction::STATUS_COMPLETED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Failed Transaktionen
|
||||
*/
|
||||
public function failed(): Collection
|
||||
{
|
||||
return $this->byStatus(Transaction::STATUS_FAILED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken
|
||||
*/
|
||||
public function stats(): array
|
||||
{
|
||||
$total = Transaction::count();
|
||||
$pending = Transaction::where('status', Transaction::STATUS_PENDING)->count();
|
||||
$processing = Transaction::where('status', Transaction::STATUS_PROCESSING)->count();
|
||||
$completed = Transaction::where('status', Transaction::STATUS_COMPLETED)->count();
|
||||
$failed = Transaction::where('status', Transaction::STATUS_FAILED)->count();
|
||||
|
||||
// Review Statistics
|
||||
$needsReview = $this->completed()
|
||||
->filter(fn ($t) => $t->requiresReview())
|
||||
->count();
|
||||
|
||||
// Risk Statistics
|
||||
$highRisk = $this->completed()
|
||||
->filter(fn ($t) => $t->getRiskScore() >= 70)
|
||||
->count();
|
||||
|
||||
$mediumRisk = $this->completed()
|
||||
->filter(fn ($t) => $t->getRiskScore() >= 40 && $t->getRiskScore() < 70)
|
||||
->count();
|
||||
|
||||
$lowRisk = $this->completed()
|
||||
->filter(fn ($t) => $t->getRiskScore() < 40)
|
||||
->count();
|
||||
|
||||
return compact(
|
||||
'total',
|
||||
'pending',
|
||||
'processing',
|
||||
'completed',
|
||||
'failed',
|
||||
'needsReview',
|
||||
'highRisk',
|
||||
'mediumRisk',
|
||||
'lowRisk'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginierte Transaktionen
|
||||
*/
|
||||
public function paginated(int $perPage = 20)
|
||||
{
|
||||
return Transaction::with('outputs')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suche in Transaktionen
|
||||
*/
|
||||
public function search(string $query): Collection
|
||||
{
|
||||
return Transaction::with('outputs')
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('corporate_entity', 'ILIKE', "%{$query}%")
|
||||
->orWhere('corporate_counterparty', 'ILIKE', "%{$query}%")
|
||||
->orWhere('tx_purpose', 'ILIKE', "%{$query}%");
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaktionen nach Datum-Range
|
||||
*/
|
||||
public function byDateRange(string $startDate, string $endDate): Collection
|
||||
{
|
||||
return Transaction::with('outputs')
|
||||
->whereBetween('tx_date', [$startDate, $endDate])
|
||||
->orderBy('tx_date', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle unique Firmen
|
||||
*/
|
||||
public function getAllCompanies(): Collection
|
||||
{
|
||||
return DB::connection('backend')
|
||||
->table('transactions')
|
||||
->select('corporate_entity')
|
||||
->distinct()
|
||||
->orderBy('corporate_entity')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-Daten
|
||||
*/
|
||||
public function dashboardData(): array
|
||||
{
|
||||
$stats = $this->stats();
|
||||
|
||||
$recentTransactions = Transaction::with('outputs')
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$recentHighRisk = $this->completed()
|
||||
->filter(fn ($t) => $t->getRiskScore() >= 70)
|
||||
->take(5);
|
||||
|
||||
$topCompanies = DB::connection('backend')
|
||||
->table('transactions')
|
||||
->select('corporate_entity', DB::raw('COUNT(*) as count'))
|
||||
->groupBy('corporate_entity')
|
||||
->orderByDesc('count')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'stats' => $stats,
|
||||
'recent_transactions' => $recentTransactions,
|
||||
'recent_high_risk' => $recentHighRisk,
|
||||
'top_companies' => $topCompanies,
|
||||
];
|
||||
}
|
||||
}
|
||||
+30
-1
@@ -16,7 +16,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
'default' => env('DB_CONNECTION', 'pgsql_second'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -82,6 +82,35 @@ return [
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST2', '127.0.0.1'),
|
||||
'port' => env('DB_PORT2', '5433'),
|
||||
'database' => env('DB_DATABASE2', 'risk_ingest_db'),
|
||||
'username' => env('DB_USERNAME2', 'risk_ingest_user'),
|
||||
'password' => env('DB_PASSWORD2', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => 'prefer',
|
||||
],
|
||||
|
||||
'backend' => [
|
||||
'driver' => env('DB_CONNECTION2'),
|
||||
'host' => env('DB_HOST2', '127.0.0.1'),
|
||||
'port' => env('DB_PORT2', '5433'),
|
||||
'database' => env('DB_DATABASE2', 'laravel'),
|
||||
'username' => env('DB_USERNAME2', 'root'),
|
||||
'password' => env('DB_PASSWORD2', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'backend', // Backend Schema (gleiche DB wie pgsql_second, anderes Schema)
|
||||
'sslmode' => 'prefer',
|
||||
],
|
||||
|
||||
'pgsql_second' => [
|
||||
'driver' => env('DB_CONNECTION2'),
|
||||
'host' => env('DB_HOST2', '127.0.0.1'),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
# Implementation Summary: Backend Schema Integration
|
||||
|
||||
## ✅ Was wurde implementiert
|
||||
|
||||
### 1. Database Configuration
|
||||
**Datei**: [config/database.php](config/database.php)
|
||||
|
||||
Zwei Verbindungen hinzugefügt:
|
||||
- `pgsql` → public Schema (Default)
|
||||
- `backend` → backend Schema (search_path: 'backend')
|
||||
|
||||
```php
|
||||
'default' => env('DB_CONNECTION', 'pgsql'), // Geändert von sqlite zu pgsql
|
||||
|
||||
'pgsql' => [
|
||||
'search_path' => 'public',
|
||||
],
|
||||
|
||||
'backend' => [
|
||||
'search_path' => 'backend', // Zugriff auf backend Schema
|
||||
],
|
||||
```
|
||||
|
||||
### 2. Backend Models
|
||||
|
||||
#### Backend\Transaction Model
|
||||
**Datei**: [app/Models/Backend/Transaction.php](app/Models/Backend/Transaction.php)
|
||||
|
||||
**Eigenschaften**:
|
||||
- Connection: `backend`
|
||||
- Table: `transactions`
|
||||
- 14 Felder (Rohdaten aus CSV Upload)
|
||||
|
||||
**Relationships**:
|
||||
- `outputs()` - HasMany zu TransactionOutput
|
||||
|
||||
**Helper Methods**:
|
||||
```php
|
||||
// Outputs abrufen
|
||||
getOutput(string $key): ?array
|
||||
getOutputsArray(): array
|
||||
getCompanyInfo(): ?array
|
||||
getRiskAssessment(): ?array
|
||||
getSanctionsCheck(): ?array
|
||||
getPepCheck(): ?array
|
||||
|
||||
// Status & Review
|
||||
hasOutput(string $key): bool
|
||||
isProcessed(): bool
|
||||
requiresReview(): bool
|
||||
|
||||
// Display Helpers
|
||||
getCompanyName(): string
|
||||
getRiskScore(): int
|
||||
getRiskLevel(): string
|
||||
```
|
||||
|
||||
#### Backend\TransactionOutput Model
|
||||
**Datei**: [app/Models/Backend/TransactionOutput.php](app/Models/Backend/TransactionOutput.php)
|
||||
|
||||
**Eigenschaften**:
|
||||
- Connection: `backend`
|
||||
- Table: `transaction_outputs`
|
||||
- 5 Felder (KI-generierte Outputs)
|
||||
|
||||
**Relationships**:
|
||||
- `transaction()` - BelongsTo Transaction
|
||||
|
||||
**Konstanten** (Output-Keys):
|
||||
```php
|
||||
KEY_COMPANY_INFO = 'company_info'
|
||||
KEY_RISK_ASSESSMENT = 'risk_assessment'
|
||||
KEY_SANCTIONS = 'sanctions'
|
||||
KEY_PEP = 'pep'
|
||||
KEY_REGISTRY = 'registry'
|
||||
KEY_GLEIF = 'gleif'
|
||||
KEY_INSOLVENCY = 'insolvency'
|
||||
KEY_BUNDESANZEIGER = 'bundesanzeiger'
|
||||
KEY_RSS = 'rss'
|
||||
KEY_EU_SANCTIONS = 'eu_sanctions'
|
||||
KEY_HANDELSREGISTER = 'handelsregister'
|
||||
KEY_GENESIS = 'genesis'
|
||||
KEY_GOVDATA = 'govdata'
|
||||
```
|
||||
|
||||
**Helper Methods**:
|
||||
```php
|
||||
availableKeys(): array
|
||||
getKeyLabel(string $key): string
|
||||
```
|
||||
|
||||
### 3. Repository Layer
|
||||
|
||||
**Datei**: [app/Repositories/TransactionRepository.php](app/Repositories/TransactionRepository.php)
|
||||
|
||||
**Methoden**:
|
||||
```php
|
||||
// Basic CRUD
|
||||
all(): Collection
|
||||
find(int $id): ?Transaction
|
||||
paginated(int $perPage = 20)
|
||||
|
||||
// Filtering
|
||||
requiresReview(): Collection
|
||||
highRisk(int $threshold = 70): Collection
|
||||
byCompany(string $companyName): Collection
|
||||
byStatus(string $status): Collection
|
||||
byDateRange(string $startDate, string $endDate): Collection
|
||||
search(string $query): Collection
|
||||
|
||||
// Status-specific
|
||||
pending(): Collection
|
||||
processing(): Collection
|
||||
completed(): Collection
|
||||
failed(): Collection
|
||||
|
||||
// Statistics
|
||||
stats(): array
|
||||
dashboardData(): array
|
||||
getAllCompanies(): Collection
|
||||
```
|
||||
|
||||
### 4. Tests
|
||||
|
||||
**Datei**: [tests/Feature/BackendModelsTest.php](tests/Feature/BackendModelsTest.php)
|
||||
|
||||
11 Tests geschrieben (3 laufen ohne DB-Verbindung):
|
||||
- ✅ Connection Tests
|
||||
- ✅ Model Relationship Tests
|
||||
- ✅ Helper Method Tests
|
||||
- ✅ Constant Tests
|
||||
|
||||
---
|
||||
|
||||
## 📋 Nächste Schritte: Component-Migration
|
||||
|
||||
### Betroffene Components
|
||||
|
||||
#### 1. transaction-review.blade.php
|
||||
**Aktuell**: Verwendet `App\Models\Transaction` und `App\Models\Company`
|
||||
|
||||
**Änderungen**:
|
||||
```php
|
||||
// Alt
|
||||
use App\Models\Transaction;
|
||||
use App\Models\Company;
|
||||
|
||||
// Neu
|
||||
use App\Models\Backend\Transaction;
|
||||
use App\Repositories\TransactionRepository;
|
||||
|
||||
// Repository verwenden statt direkte Model-Queries
|
||||
public function __construct(
|
||||
private TransactionRepository $transactions
|
||||
) {}
|
||||
|
||||
$transactions = $this->transactions->all();
|
||||
```
|
||||
|
||||
**Mapping**:
|
||||
| Alt (public.transactions) | Neu (backend) |
|
||||
|---------------------------|---------------|
|
||||
| `$transaction->company->name` | `$transaction->getCompanyName()` |
|
||||
| `$transaction->company->legal_name` | `$transaction->getCompanyInfo()['legal_name']` |
|
||||
| `$transaction->company->sector` | `$transaction->getCompanyInfo()['sector']` |
|
||||
| `$transaction->risk_score` | `$transaction->getRiskScore()` |
|
||||
| `$transaction->requires_review` | `$transaction->requiresReview()` |
|
||||
| `$transaction->amount` | `$transaction->tx_amount` |
|
||||
| `$transaction->counterparty` | `$transaction->corporate_counterparty` |
|
||||
| `$transaction->executed_at` | `$transaction->tx_date` (⚠️ ist text) |
|
||||
|
||||
#### 2. companies/transactions.blade.php
|
||||
**Aktuell**: Verwendet `App\Models\Company` Parameter
|
||||
|
||||
**Änderungen**:
|
||||
- Company-Daten kommen jetzt aus `transaction_outputs`
|
||||
- Grouping nach `corporate_entity` statt `company_id`
|
||||
|
||||
**Neuer Ansatz**:
|
||||
```php
|
||||
// Alle Transaktionen für eine Firma
|
||||
$companyName = 'Siemens AG'; // Aus Route oder Parameter
|
||||
$transactions = $this->transactions->byCompany($companyName);
|
||||
|
||||
// Company-Info aus erster Transaction
|
||||
$companyInfo = $transactions->first()?->getCompanyInfo();
|
||||
```
|
||||
|
||||
#### 3. company-search.blade.php
|
||||
**Änderungen**:
|
||||
- Suche jetzt in `backend.transactions.corporate_entity`
|
||||
- Keine separate Company-Tabelle mehr
|
||||
|
||||
```php
|
||||
// Repository Method
|
||||
public function getAllCompanies(): Collection
|
||||
{
|
||||
return DB::connection('backend')
|
||||
->table('transactions')
|
||||
->select('corporate_entity as name')
|
||||
->distinct()
|
||||
->orderBy('corporate_entity')
|
||||
->get();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Migrationsplan (Step-by-Step)
|
||||
|
||||
### Phase 1: Testen ohne Breaking Changes (empfohlen)
|
||||
|
||||
**Option A: Feature Flag**
|
||||
```php
|
||||
// config/features.php
|
||||
'use_backend_schema' => env('FEATURE_USE_BACKEND_SCHEMA', false),
|
||||
|
||||
// In Components
|
||||
if (config('features.use_backend_schema')) {
|
||||
$transactions = app(TransactionRepository::class)->all();
|
||||
} else {
|
||||
$transactions = Transaction::all(); // Alt
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Neue Routes** (empfohlen für parallele Tests)
|
||||
```php
|
||||
// routes/web.php
|
||||
Route::get('/beta/transactions', ...); // Nutzt Backend-Schema
|
||||
Route::get('/transactions', ...); // Alte Implementation
|
||||
```
|
||||
|
||||
### Phase 2: Direkte Migration (schneller, aber riskanter)
|
||||
|
||||
1. **Alle `use App\Models\Transaction` ersetzen**:
|
||||
```bash
|
||||
find resources/views/livewire -type f -name "*.php" -exec sed -i '' 's/use App\\Models\\Transaction/use App\\Models\\Backend\\Transaction/g' {} +
|
||||
```
|
||||
|
||||
2. **Alle `use App\Models\Company` entfernen**:
|
||||
```bash
|
||||
find resources/views/livewire -type f -name "*.php" -exec sed -i '' 's/use App\\Models\\Company;//g' {} +
|
||||
```
|
||||
|
||||
3. **Component für Component anpassen**:
|
||||
- transaction-review.blade.php
|
||||
- companies/transactions.blade.php
|
||||
- company-search.blade.php
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Daten-Mapping Referenz
|
||||
|
||||
### Transaction Fields
|
||||
|
||||
| public.transactions | backend.transactions | Typ-Unterschied |
|
||||
|---------------------|----------------------|-----------------|
|
||||
| id | id | bigint → integer |
|
||||
| company_id | - (via corporate_entity) | Relationship entfällt |
|
||||
| reference | - | Neu: auto-generated |
|
||||
| amount | tx_amount | numeric → double |
|
||||
| currency | tx_currency | ✓ |
|
||||
| counterparty | corporate_counterparty | ✓ |
|
||||
| counterparty_country | tx_country_incoming | ✓ |
|
||||
| channel | - | Nicht in backend |
|
||||
| executed_at | tx_date | **timestamp → text!** |
|
||||
| risk_score | - (in outputs) | Via getRiskScore() |
|
||||
| status | status | ✓ |
|
||||
| requires_review | - (computed) | Via requiresReview() |
|
||||
| flagged_by | - | Nicht in backend |
|
||||
| flagged_reason | tx_purpose | Ähnlich |
|
||||
| signals | - | Nicht in backend |
|
||||
|
||||
### Company Fields (jetzt in transaction_outputs)
|
||||
|
||||
| public.companies | transaction_outputs (key='company_info') |
|
||||
|------------------|-------------------------------------------|
|
||||
| name | content['name'] |
|
||||
| legal_name | content['legal_name'] |
|
||||
| ticker | content['ticker'] |
|
||||
| sector | content['sector'] |
|
||||
| country | content['country'] |
|
||||
| headquarters | content['headquarters'] |
|
||||
| kyc_risk_level | content['kyc_risk_level'] |
|
||||
| summary | content['summary'] |
|
||||
|
||||
### Enrichment Fields (in transaction_outputs)
|
||||
|
||||
Alle 35 Enrichment-Felder aus `public.transactions` sind jetzt separate Outputs:
|
||||
|
||||
```php
|
||||
// Alt
|
||||
$transaction->sanctions_data // JSON
|
||||
|
||||
// Neu
|
||||
$transaction->getOutput('sanctions') // Array
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Breaking Changes
|
||||
|
||||
### 1. Timestamps sind Text
|
||||
```php
|
||||
// Alt
|
||||
$transaction->executed_at->format('d.m.Y')
|
||||
|
||||
// Neu (ACHTUNG: tx_date ist Text!)
|
||||
$transaction->tx_date // Already string, no format()
|
||||
```
|
||||
|
||||
**Empfehlung**: Migration hinzufügen um `tx_date` von `text` zu `timestamp` zu ändern.
|
||||
|
||||
### 2. Company Relationship entfällt
|
||||
```php
|
||||
// Alt
|
||||
$transaction->company->name
|
||||
$transaction->company()->where(...)
|
||||
|
||||
// Neu
|
||||
$transaction->getCompanyName()
|
||||
$transaction->getCompanyInfo()['name']
|
||||
// Kein Relationship mehr verfügbar
|
||||
```
|
||||
|
||||
### 3. Feld-Namen ändern sich
|
||||
```php
|
||||
// Alt → Neu
|
||||
amount → tx_amount
|
||||
counterparty → corporate_counterparty
|
||||
executed_at → tx_date
|
||||
```
|
||||
|
||||
**Empfehlung**: Accessor in Model für Backward-Compatibility:
|
||||
|
||||
```php
|
||||
// In Backend\Transaction Model
|
||||
protected $appends = ['amount', 'counterparty', 'executed_at'];
|
||||
|
||||
public function getAmountAttribute(): float
|
||||
{
|
||||
return $this->tx_amount;
|
||||
}
|
||||
|
||||
public function getCounterpartyAttribute(): string
|
||||
{
|
||||
return $this->corporate_counterparty;
|
||||
}
|
||||
|
||||
public function getExecutedAtAttribute(): string
|
||||
{
|
||||
return $this->tx_date;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Empfohlenes Vorgehen
|
||||
|
||||
### Option 1: Schrittweise mit Feature Flags (Sicher, 2-3 Wochen)
|
||||
1. ✅ Backend Models & Repository erstellt
|
||||
2. ⬜ Feature Flag System einrichten
|
||||
3. ⬜ Parallele Routes `/beta/*` erstellen
|
||||
4. ⬜ Einen Component nach dem anderen migrieren
|
||||
5. ⬜ Testen mit echten Nutzern (10%)
|
||||
6. ⬜ Gradual Rollout
|
||||
7. ⬜ Alte Components entfernen
|
||||
|
||||
### Option 2: Direkte Migration (Schnell, 3-5 Tage)
|
||||
1. ✅ Backend Models & Repository erstellt
|
||||
2. ⬜ Alle Components in einem PR umstellen
|
||||
3. ⬜ Accessor für Backward-Compatibility hinzufügen
|
||||
4. ⬜ Intensives Testing
|
||||
5. ⬜ Deploy mit Rollback-Plan
|
||||
|
||||
### Option 3: Hybrid (Empfohlen, 1 Woche)
|
||||
1. ✅ Backend Models & Repository erstellt
|
||||
2. ⬜ Accessor für Backward-Compatibility in Backend Models
|
||||
3. ⬜ `transaction-review` Component migrieren (wichtigster)
|
||||
4. ⬜ 1-2 Tage Testing
|
||||
5. ⬜ Restliche Components migrieren
|
||||
6. ⬜ Deploy
|
||||
|
||||
---
|
||||
|
||||
## 📝 Checkliste für Component-Migration
|
||||
|
||||
Für jeden Component:
|
||||
|
||||
- [ ] Import `App\Models\Transaction` → `App\Models\Backend\Transaction`
|
||||
- [ ] Import `App\Models\Company` entfernen
|
||||
- [ ] Repository injecten statt direkte Model-Queries
|
||||
- [ ] `company->` zu `getCompanyInfo()` ändern
|
||||
- [ ] Field-Namen anpassen (`amount` → `tx_amount`, etc.)
|
||||
- [ ] `executed_at` zu `tx_date` ändern
|
||||
- [ ] `->format()` Calls bei `tx_date` entfernen (ist schon Text)
|
||||
- [ ] Tests schreiben/anpassen
|
||||
- [ ] Manuell testen
|
||||
- [ ] PR erstellen
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Los geht's!
|
||||
|
||||
Möchten Sie:
|
||||
|
||||
**A)** Dass ich jetzt `transaction-review.blade.php` auf Backend-Schema umstelle?
|
||||
|
||||
**B)** Erst Accessors für Backward-Compatibility hinzufügen?
|
||||
|
||||
**C)** Ein Feature-Flag-System einrichten?
|
||||
|
||||
**D)** Etwas anderes?
|
||||
|
||||
Was ist Ihr bevorzugter Ansatz?
|
||||
@@ -0,0 +1,965 @@
|
||||
# Inkrementelle Migrations-Strategie: Parallele Schema-Integration
|
||||
|
||||
## Konzept: Strangler Fig Pattern
|
||||
|
||||
Diese Strategie nutzt das **Strangler Fig Pattern** - das Backend-Schema wird parallel integriert und schrittweise übernimmt die Logik, während das alte System weiterläuft. Kein Big Bang, keine Breaking Changes.
|
||||
|
||||
```
|
||||
Phase 1: Beide Systeme parallel
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Public Schema │ │ Backend Schema │
|
||||
│ (Aktiv) │ │ (Read-Only) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
|
||||
Phase 2: Dual-Write Pattern
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Public Schema │────▶│ Backend Schema │
|
||||
│ (Primary) │ │ (Secondary) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
|
||||
Phase 3: Umstellung
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Public Schema │◀────│ Backend Schema │
|
||||
│ (Read-Only) │ │ (Primary) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
|
||||
Phase 4: Deprecation
|
||||
┌─────────────────┐
|
||||
│ Backend Schema │
|
||||
│ (Einzige Quelle)│
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Dual-Connection Setup (1-2 Tage)
|
||||
|
||||
### 1.1 Database-Konfiguration erweitern
|
||||
|
||||
**config/database.php**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default' => env('DB_CONNECTION', 'pgsql'),
|
||||
|
||||
'connections' => [
|
||||
// Bestehende Public-Schema Verbindung
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
'username' => env('DB_USERNAME', 'forge'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => 'utf8',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => 'prefer',
|
||||
],
|
||||
|
||||
// Neue Backend-Schema Verbindung
|
||||
'backend' => [
|
||||
'driver' => 'pgsql',
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'forge'), // Gleiche DB
|
||||
'username' => env('DB_USERNAME', 'forge'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => 'utf8',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'backend', // Unterschiedliches Schema!
|
||||
'sslmode' => 'prefer',
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
**.env**:
|
||||
```env
|
||||
# Keine Änderung nötig - beide Connections nutzen gleiche Credentials
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=your_database
|
||||
DB_USERNAME=your_user
|
||||
DB_PASSWORD=your_password
|
||||
```
|
||||
|
||||
### 1.2 Backend Models erstellen
|
||||
|
||||
**app/Models/Backend/Company.php**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Models\Backend;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Company extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $connection = 'backend';
|
||||
protected $table = 'companies';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'legal_name',
|
||||
'ticker',
|
||||
'sector',
|
||||
'country',
|
||||
'headquarters',
|
||||
'kyc_risk_level',
|
||||
'summary',
|
||||
];
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync zu public.companies (während Migration)
|
||||
*/
|
||||
public function syncToPublic(): \App\Models\Company
|
||||
{
|
||||
return \App\Models\Company::updateOrCreate(
|
||||
['id' => $this->id],
|
||||
$this->only($this->fillable)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**app/Models/Backend/Transaction.php**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Models\Backend;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $connection = 'backend';
|
||||
protected $table = 'transactions';
|
||||
|
||||
public const UPDATED_AT = 'last_modified_at';
|
||||
|
||||
protected $fillable = [
|
||||
'corporate_entity',
|
||||
'corporate_counterparty',
|
||||
'tx_date',
|
||||
'tx_amount',
|
||||
'tx_currency',
|
||||
'tx_purpose',
|
||||
'tx_country_outgoing',
|
||||
'tx_country_incoming',
|
||||
'source_file',
|
||||
'raw_payload',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tx_amount' => 'decimal:2',
|
||||
// tx_date ist als TEXT gespeichert - später migrieren zu timestamp
|
||||
];
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiere zu public.transactions Format
|
||||
*/
|
||||
public function toPublicFormat(): array
|
||||
{
|
||||
return [
|
||||
'company_id' => $this->company_id,
|
||||
'reference' => $this->id, // oder generiere unique reference
|
||||
'amount' => $this->tx_amount,
|
||||
'currency' => $this->tx_currency,
|
||||
'counterparty' => $this->corporate_counterparty,
|
||||
'counterparty_country' => $this->tx_country_incoming,
|
||||
'executed_at' => $this->tx_date,
|
||||
'status' => $this->status,
|
||||
'requires_review' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Test der Dual-Connection
|
||||
|
||||
```php
|
||||
<?php
|
||||
// tests/Feature/DualConnectionTest.php
|
||||
|
||||
use App\Models\Backend\Company as BackendCompany;
|
||||
use App\Models\Company as PublicCompany;
|
||||
|
||||
test('can access both schemas', function () {
|
||||
// Public Schema
|
||||
$publicCount = PublicCompany::count();
|
||||
expect($publicCount)->toBeGreaterThan(0);
|
||||
|
||||
// Backend Schema
|
||||
$backendCount = BackendCompany::count();
|
||||
expect($backendCount)->toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('connections are isolated', function () {
|
||||
$public = PublicCompany::first();
|
||||
$backend = BackendCompany::first();
|
||||
|
||||
// Verschiedene Connections
|
||||
expect($public->getConnectionName())->toBe('pgsql');
|
||||
expect($backend->getConnectionName())->toBe('backend');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Abstraction Layer (2-3 Tage)
|
||||
|
||||
### 2.1 Repository Pattern mit Feature Flags
|
||||
|
||||
**app/Repositories/CompanyRepository.php**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Backend\Company as BackendCompany;
|
||||
use App\Models\Company as PublicCompany;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class CompanyRepository
|
||||
{
|
||||
public function __construct(
|
||||
private bool $useBackendSchema = false
|
||||
) {
|
||||
// Feature Flag aus Config
|
||||
$this->useBackendSchema = config('features.use_backend_schema', false);
|
||||
}
|
||||
|
||||
public function all(): Collection
|
||||
{
|
||||
return $this->useBackendSchema
|
||||
? BackendCompany::all()
|
||||
: PublicCompany::all();
|
||||
}
|
||||
|
||||
public function find(int $id): PublicCompany|BackendCompany|null
|
||||
{
|
||||
return $this->useBackendSchema
|
||||
? BackendCompany::find($id)
|
||||
: PublicCompany::find($id);
|
||||
}
|
||||
|
||||
public function create(array $data): PublicCompany|BackendCompany
|
||||
{
|
||||
if ($this->useBackendSchema) {
|
||||
$company = BackendCompany::create($data);
|
||||
|
||||
// Dual-Write: Sync zu Public während Übergangsphase
|
||||
if (config('features.dual_write', true)) {
|
||||
$company->syncToPublic();
|
||||
}
|
||||
|
||||
return $company;
|
||||
}
|
||||
|
||||
return PublicCompany::create($data);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$company = $this->find($id);
|
||||
|
||||
if (!$company) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $company->update($data);
|
||||
|
||||
// Dual-Write
|
||||
if ($this->useBackendSchema && config('features.dual_write', true)) {
|
||||
$company->syncToPublic();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$company = $this->find($id);
|
||||
|
||||
if (!$company) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dual-Delete
|
||||
if ($this->useBackendSchema && config('features.dual_write', true)) {
|
||||
PublicCompany::destroy($id);
|
||||
}
|
||||
|
||||
return $company->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get model class
|
||||
*/
|
||||
public function getModelClass(): string
|
||||
{
|
||||
return $this->useBackendSchema
|
||||
? BackendCompany::class
|
||||
: PublicCompany::class;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**app/Repositories/TransactionRepository.php**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Backend\Transaction as BackendTransaction;
|
||||
use App\Models\Transaction as PublicTransaction;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class TransactionRepository
|
||||
{
|
||||
public function __construct(
|
||||
private bool $useBackendSchema = false
|
||||
) {
|
||||
$this->useBackendSchema = config('features.use_backend_schema', false);
|
||||
}
|
||||
|
||||
public function forCompany(int $companyId): Collection
|
||||
{
|
||||
return $this->useBackendSchema
|
||||
? BackendTransaction::where('company_id', $companyId)->get()
|
||||
: PublicTransaction::where('company_id', $companyId)->get();
|
||||
}
|
||||
|
||||
public function requiresReview(): Collection
|
||||
{
|
||||
if ($this->useBackendSchema) {
|
||||
// Backend hat kein requires_review Feld - nutze status
|
||||
return BackendTransaction::where('status', 'pending')->get();
|
||||
}
|
||||
|
||||
return PublicTransaction::where('requires_review', true)->get();
|
||||
}
|
||||
|
||||
public function highRisk(int $threshold = 70): Collection
|
||||
{
|
||||
if ($this->useBackendSchema) {
|
||||
// Backend hat keinen risk_score - Alternative Logik
|
||||
return BackendTransaction::where('status', 'flagged')->get();
|
||||
}
|
||||
|
||||
return PublicTransaction::where('risk_score', '>=', $threshold)->get();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Feature Flag Konfiguration
|
||||
|
||||
**config/features.php**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Backend Schema Migration Flags
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Hauptschalter: Nutze Backend-Schema statt Public
|
||||
'use_backend_schema' => env('FEATURE_USE_BACKEND_SCHEMA', false),
|
||||
|
||||
// Dual-Write: Schreibe in beide Schemas während Migration
|
||||
'dual_write' => env('FEATURE_DUAL_WRITE', true),
|
||||
|
||||
// Read-Verification: Vergleiche Reads aus beiden Schemas (Logging)
|
||||
'verify_reads' => env('FEATURE_VERIFY_READS', false),
|
||||
|
||||
// Schrittweise Migration pro Bereich
|
||||
'backend_schema_areas' => [
|
||||
'companies' => env('FEATURE_BACKEND_COMPANIES', false),
|
||||
'transactions' => env('FEATURE_BACKEND_TRANSACTIONS', false),
|
||||
'reports' => env('FEATURE_BACKEND_REPORTS', false),
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
**.env** (für schrittweise Aktivierung):
|
||||
```env
|
||||
# Phase 1: Beide Schemas verfügbar, aber Public aktiv
|
||||
FEATURE_USE_BACKEND_SCHEMA=false
|
||||
FEATURE_DUAL_WRITE=false
|
||||
|
||||
# Phase 2: Dual-Write aktivieren
|
||||
# FEATURE_USE_BACKEND_SCHEMA=false
|
||||
# FEATURE_DUAL_WRITE=true
|
||||
|
||||
# Phase 3: Backend als Primary, Public als Fallback
|
||||
# FEATURE_USE_BACKEND_SCHEMA=true
|
||||
# FEATURE_DUAL_WRITE=true
|
||||
|
||||
# Phase 4: Nur Backend
|
||||
# FEATURE_USE_BACKEND_SCHEMA=true
|
||||
# FEATURE_DUAL_WRITE=false
|
||||
```
|
||||
|
||||
### 2.3 Service Provider für Dependency Injection
|
||||
|
||||
**app/Providers/RepositoryServiceProvider.php**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Repositories\CompanyRepository;
|
||||
use App\Repositories\TransactionRepository;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class RepositoryServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(CompanyRepository::class, function ($app) {
|
||||
return new CompanyRepository(
|
||||
useBackendSchema: config('features.use_backend_schema', false)
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->singleton(TransactionRepository::class, function ($app) {
|
||||
return new TransactionRepository(
|
||||
useBackendSchema: config('features.use_backend_schema', false)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**bootstrap/providers.php**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\RepositoryServiceProvider::class, // Neu hinzufügen
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Controller Migration (3-5 Tage)
|
||||
|
||||
### 3.1 Controller auf Repository umstellen
|
||||
|
||||
**Vorher** (app/Http/Controllers/CompanyController.php):
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Company;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$companies = Company::with('transactions')->get();
|
||||
|
||||
return view('companies.index', compact('companies'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string',
|
||||
'country' => 'required|string|size:2',
|
||||
// ...
|
||||
]);
|
||||
|
||||
$company = Company::create($validated);
|
||||
|
||||
return redirect()->route('companies.show', $company);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nachher**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Repositories\CompanyRepository;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private CompanyRepository $companies
|
||||
) {}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$companies = $this->companies->all();
|
||||
|
||||
return view('companies.index', compact('companies'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string',
|
||||
'country' => 'required|string|size:2',
|
||||
// ...
|
||||
]);
|
||||
|
||||
$company = $this->companies->create($validated);
|
||||
|
||||
return redirect()->route('companies.show', $company);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Volt/Livewire Components anpassen
|
||||
|
||||
**Vorher** (resources/views/pages/companies/index.blade.php):
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
|
||||
$companies = Company::query()
|
||||
->with('transactions')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
@foreach($companies as $company)
|
||||
<flux:card>{{ $company->name }}</flux:card>
|
||||
@endforeach
|
||||
</div>
|
||||
```
|
||||
|
||||
**Nachher**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Repositories\CompanyRepository;
|
||||
|
||||
$companyRepo = app(CompanyRepository::class);
|
||||
$companies = $companyRepo->all();
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
@foreach($companies as $company)
|
||||
<flux:card>{{ $company->name }}</flux:card>
|
||||
@endforeach
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Monitoring & Verification (Parallel zu Phase 3)
|
||||
|
||||
### 4.1 Dual-Read Verification Middleware
|
||||
|
||||
**app/Http/Middleware/VerifyDualSchemaReads.php**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class VerifyDualSchemaReads
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
if (!config('features.verify_reads')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Capture queries von beiden Schemas
|
||||
$publicQueries = [];
|
||||
$backendQueries = [];
|
||||
|
||||
\DB::connection('pgsql')->listen(function ($query) use (&$publicQueries) {
|
||||
$publicQueries[] = $query->sql;
|
||||
});
|
||||
|
||||
\DB::connection('backend')->listen(function ($query) use (&$backendQueries) {
|
||||
$backendQueries[] = $query->sql;
|
||||
});
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
// Log für Analyse
|
||||
if (!empty($publicQueries) || !empty($backendQueries)) {
|
||||
Log::channel('migration')->info('Dual Schema Access', [
|
||||
'route' => $request->path(),
|
||||
'public_queries' => count($publicQueries),
|
||||
'backend_queries' => count($backendQueries),
|
||||
]);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Health Check Command
|
||||
|
||||
**app/Console/Commands/VerifySchemaConsistency.php**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Backend\Company as BackendCompany;
|
||||
use App\Models\Company as PublicCompany;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class VerifySchemaConsistency extends Command
|
||||
{
|
||||
protected $signature = 'schema:verify-consistency';
|
||||
protected $description = 'Verify data consistency between public and backend schemas';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Checking schema consistency...');
|
||||
|
||||
// Company Count
|
||||
$publicCount = PublicCompany::count();
|
||||
$backendCount = BackendCompany::count();
|
||||
|
||||
$this->table(
|
||||
['Schema', 'Companies', 'Status'],
|
||||
[
|
||||
['Public', $publicCount, '✓'],
|
||||
['Backend', $backendCount, $backendCount === $publicCount ? '✓' : '⚠'],
|
||||
]
|
||||
);
|
||||
|
||||
if ($backendCount !== $publicCount) {
|
||||
$this->warn("Company count mismatch: Public={$publicCount}, Backend={$backendCount}");
|
||||
}
|
||||
|
||||
// Sample Data Verification
|
||||
$sampleSize = min(10, $publicCount);
|
||||
$publicSample = PublicCompany::take($sampleSize)->get();
|
||||
$mismatches = 0;
|
||||
|
||||
foreach ($publicSample as $publicCompany) {
|
||||
$backendCompany = BackendCompany::find($publicCompany->id);
|
||||
|
||||
if (!$backendCompany) {
|
||||
$this->warn("Company {$publicCompany->id} missing in backend");
|
||||
$mismatches++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($publicCompany->name !== $backendCompany->name) {
|
||||
$this->warn("Company {$publicCompany->id} name mismatch");
|
||||
$mismatches++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($mismatches === 0) {
|
||||
$this->info('✓ All consistency checks passed!');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error("✗ Found {$mismatches} inconsistencies");
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Graduelle Umstellung (2-4 Wochen)
|
||||
|
||||
### Woche 1: Read-Only Access
|
||||
```env
|
||||
FEATURE_USE_BACKEND_SCHEMA=false
|
||||
FEATURE_DUAL_WRITE=false
|
||||
FEATURE_VERIFY_READS=true
|
||||
```
|
||||
|
||||
- ✅ Backend-Schema ist verfügbar
|
||||
- ✅ Monitoring läuft
|
||||
- ✅ Keine Produktions-Daten betroffen
|
||||
|
||||
### Woche 2: Dual-Write aktivieren
|
||||
```env
|
||||
FEATURE_USE_BACKEND_SCHEMA=false # Lesen: Public
|
||||
FEATURE_DUAL_WRITE=true # Schreiben: Beide
|
||||
FEATURE_VERIFY_READS=true
|
||||
```
|
||||
|
||||
- ✅ Neue Daten gehen in beide Schemas
|
||||
- ✅ Public bleibt Primary
|
||||
- ⚠️ Monitor auf Sync-Errors
|
||||
|
||||
### Woche 3: Backend als Primary (Canary)
|
||||
```env
|
||||
# Nur für 10% Traffic oder spezifische Routes
|
||||
FEATURE_BACKEND_COMPANIES=true # Companies von Backend lesen
|
||||
FEATURE_BACKEND_TRANSACTIONS=false # Transactions noch von Public
|
||||
FEATURE_DUAL_WRITE=true
|
||||
```
|
||||
|
||||
- ✅ Schrittweise Umstellung pro Feature
|
||||
- ✅ A/B Testing möglich
|
||||
- ✅ Rollback jederzeit möglich
|
||||
|
||||
### Woche 4: Full Switchover
|
||||
```env
|
||||
FEATURE_USE_BACKEND_SCHEMA=true # Lesen: Backend
|
||||
FEATURE_DUAL_WRITE=true # Schreiben: Beide (Sicherheit)
|
||||
```
|
||||
|
||||
- ✅ Backend ist Primary
|
||||
- ✅ Public als Safety Net
|
||||
|
||||
### Nach 2 Wochen stabiler Betrieb:
|
||||
```env
|
||||
FEATURE_USE_BACKEND_SCHEMA=true
|
||||
FEATURE_DUAL_WRITE=false # Public wird deprecated
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Testing-Strategie
|
||||
|
||||
### 6.1 Feature Tests mit Feature Flags
|
||||
|
||||
```php
|
||||
<?php
|
||||
// tests/Feature/CompanyControllerTest.php
|
||||
|
||||
use App\Models\Backend\Company as BackendCompany;
|
||||
use App\Models\Company as PublicCompany;
|
||||
|
||||
test('can create company with public schema', function () {
|
||||
config(['features.use_backend_schema' => false]);
|
||||
|
||||
$response = $this->post('/companies', [
|
||||
'name' => 'Test Corp',
|
||||
'country' => 'DE',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
expect(PublicCompany::where('name', 'Test Corp')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('can create company with backend schema', function () {
|
||||
config(['features.use_backend_schema' => true]);
|
||||
|
||||
$response = $this->post('/companies', [
|
||||
'name' => 'Test Corp Backend',
|
||||
'country' => 'DE',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
expect(BackendCompany::where('corporate_entity', 'Test Corp Backend')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('dual write creates in both schemas', function () {
|
||||
config([
|
||||
'features.use_backend_schema' => true,
|
||||
'features.dual_write' => true,
|
||||
]);
|
||||
|
||||
$response = $this->post('/companies', [
|
||||
'name' => 'Dual Write Test',
|
||||
'country' => 'DE',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
expect(BackendCompany::where('corporate_entity', 'Dual Write Test')->exists())->toBeTrue();
|
||||
expect(PublicCompany::where('name', 'Dual Write Test')->exists())->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
### 6.2 Performance Tests
|
||||
|
||||
```php
|
||||
<?php
|
||||
// tests/Performance/SchemaPerformanceTest.php
|
||||
|
||||
test('backend schema is not slower than public', function () {
|
||||
// Public Schema
|
||||
$start = microtime(true);
|
||||
config(['features.use_backend_schema' => false]);
|
||||
app(CompanyRepository::class)->all();
|
||||
$publicTime = microtime(true) - $start;
|
||||
|
||||
// Backend Schema
|
||||
$start = microtime(true);
|
||||
config(['features.use_backend_schema' => true]);
|
||||
app(CompanyRepository::class)->all();
|
||||
$backendTime = microtime(true) - $start;
|
||||
|
||||
// Backend sollte nicht mehr als 20% langsamer sein
|
||||
expect($backendTime)->toBeLessThan($publicTime * 1.2);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Cleanup & Optimization (Nach 4-6 Wochen)
|
||||
|
||||
### 7.1 Repository vereinfachen
|
||||
|
||||
Wenn Backend-Schema stabil läuft:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Backend\Company;
|
||||
|
||||
class CompanyRepository
|
||||
{
|
||||
// Feature Flags entfernen
|
||||
public function all()
|
||||
{
|
||||
return Company::all();
|
||||
}
|
||||
|
||||
// Dual-Write Code entfernen
|
||||
public function create(array $data)
|
||||
{
|
||||
return Company::create($data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Public Schema deprecaten
|
||||
|
||||
```sql
|
||||
-- Tabellen in _deprecated Schema verschieben
|
||||
CREATE SCHEMA IF NOT EXISTS _deprecated;
|
||||
|
||||
ALTER TABLE public.companies SET SCHEMA _deprecated;
|
||||
ALTER TABLE public.transactions SET SCHEMA _deprecated;
|
||||
|
||||
-- Optional: Views für Legacy-Support
|
||||
CREATE VIEW public.companies AS
|
||||
SELECT
|
||||
id,
|
||||
corporate_entity as name,
|
||||
NULL as legal_name,
|
||||
country,
|
||||
'medium' as kyc_risk_level,
|
||||
NULL as summary,
|
||||
created_at::timestamp,
|
||||
last_modified_at::timestamp as updated_at
|
||||
FROM backend.companies;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vorteile dieser Strategie
|
||||
|
||||
### ✅ Zero Downtime
|
||||
- Keine Breaking Changes
|
||||
- Rollback jederzeit möglich
|
||||
- Schrittweise Umstellung
|
||||
|
||||
### ✅ Sicherheit
|
||||
- Dual-Write als Safety Net
|
||||
- Continuous Verification
|
||||
- Feature Flags für granulare Kontrolle
|
||||
|
||||
### ✅ Flexibilität
|
||||
- A/B Testing möglich
|
||||
- Schrittweise Migration pro Feature
|
||||
- Team kann parallel arbeiten
|
||||
|
||||
### ✅ Lernkurve
|
||||
- Team lernt neues Schema schrittweise
|
||||
- Bugs können isoliert gefunden werden
|
||||
- Keine Hektik
|
||||
|
||||
---
|
||||
|
||||
## Zeitplan (Realistisch)
|
||||
|
||||
| Woche | Phase | Aufwand | Risiko |
|
||||
|-------|-------|---------|--------|
|
||||
| 1 | Setup & Backend Models | 2-3 Tage | Niedrig |
|
||||
| 2 | Repository Pattern | 2-3 Tage | Niedrig |
|
||||
| 3-4 | Controller Migration | 5-7 Tage | Mittel |
|
||||
| 5 | Read-Only Testing | 2-3 Tage | Niedrig |
|
||||
| 6 | Dual-Write Phase | 1 Woche | Mittel |
|
||||
| 7-8 | Gradual Switchover | 2 Wochen | Mittel |
|
||||
| 9-10 | Monitoring & Stabilisierung | 2 Wochen | Niedrig |
|
||||
| 11-12 | Cleanup | 1 Woche | Niedrig |
|
||||
|
||||
**Total**: 10-12 Wochen (inkl. Buffer)
|
||||
|
||||
---
|
||||
|
||||
## Nächste konkrete Schritte
|
||||
|
||||
### Schritt 1: Database Config (heute, 30 Min)
|
||||
```bash
|
||||
# config/database.php erweitern
|
||||
# .env bleibt unverändert
|
||||
php artisan config:clear
|
||||
php artisan tinker
|
||||
>>> DB::connection('backend')->select('SELECT 1')
|
||||
```
|
||||
|
||||
### Schritt 2: Backend Models (heute, 1-2 Std)
|
||||
```bash
|
||||
# Models erstellen
|
||||
mkdir -p app/Models/Backend
|
||||
# Company.php & Transaction.php erstellen
|
||||
```
|
||||
|
||||
### Schritt 3: Erster Test (heute, 30 Min)
|
||||
```bash
|
||||
php artisan test --filter=DualConnectionTest
|
||||
```
|
||||
|
||||
### Schritt 4: Feature Flags (morgen, 1-2 Std)
|
||||
```bash
|
||||
# config/features.php erstellen
|
||||
# Repository Pattern implementieren
|
||||
```
|
||||
|
||||
Möchten Sie, dass ich mit **Schritt 1-2 beginne** und die konkrete Implementierung starte?
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# MCP-Server Aktivierung und Tests
|
||||
|
||||
## Aktivierung der globalen MCP-Server
|
||||
|
||||
### Methode 1: @-Erwähnung im Chat
|
||||
Schreibe einfach den Server-Namen mit @ in deiner Nachricht:
|
||||
- `@sqlite`
|
||||
- `@postgresql`
|
||||
- `@bear`
|
||||
- `@MCP_DOCKER`
|
||||
- `@Ref`
|
||||
|
||||
### Methode 2: CLI Flag beim Start
|
||||
```bash
|
||||
claude --mcp-config ~/.claude.json
|
||||
```
|
||||
|
||||
### Methode 3: Beide Configs kombinieren
|
||||
```bash
|
||||
claude --mcp-config .mcp.json ~/.claude.json
|
||||
```
|
||||
|
||||
## Test-Befehle für jeden Server
|
||||
|
||||
### 1. SQLite Server
|
||||
**Datenbank:** `/Users/sebastianfrohlich/Downloads/company.db`
|
||||
|
||||
Nach Aktivierung mit `@sqlite`:
|
||||
```
|
||||
Bitte zeige mir alle Tabellen in der SQLite-Datenbank
|
||||
```
|
||||
|
||||
### 2. PostgreSQL Server
|
||||
**Verbindung:** localhost:5433, DB: risk_ingest_db
|
||||
|
||||
Nach Aktivierung mit `@postgresql`:
|
||||
```
|
||||
Bitte zeige mir das Schema der PostgreSQL-Datenbank risk_ingest_db
|
||||
```
|
||||
|
||||
### 3. Bear Notes Server
|
||||
**Pfad:** `/Users/sebastianfrohlich/Projekte/bear-notes-mcp`
|
||||
|
||||
Nach Aktivierung mit `@bear`:
|
||||
```
|
||||
Erstelle eine neue Bear-Notiz mit dem Titel "Test MCP Server"
|
||||
```
|
||||
|
||||
### 4. MCP_DOCKER Server
|
||||
**Command:** `docker mcp gateway run`
|
||||
|
||||
Nach Aktivierung mit `@MCP_DOCKER`:
|
||||
```
|
||||
Zeige mir die verfügbaren Docker-Container
|
||||
```
|
||||
|
||||
### 5. Ref.tools Server
|
||||
**URL:** https://api.ref.tools/mcp
|
||||
|
||||
Nach Aktivierung mit `@Ref`:
|
||||
```
|
||||
Nutze Ref.tools um [spezifische Aufgabe]
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Server-Status prüfen
|
||||
```bash
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
### MCP-Debug-Modus aktivieren
|
||||
```bash
|
||||
claude --mcp-debug
|
||||
```
|
||||
|
||||
### Server-Logs anzeigen
|
||||
Prüfe die Logs in:
|
||||
- `~/.claude/logs/`
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Globale Server aus `~/.claude.json` sind standardmäßig nicht in jeder Session geladen
|
||||
- Projekt-Server aus `.mcp.json` werden automatisch geladen
|
||||
- @-Erwähnung ist die einfachste Methode zur Ad-hoc-Aktivierung
|
||||
- Einige Server benötigen laufende Dienste (z.B. PostgreSQL muss auf Port 5433 laufen)
|
||||
@@ -0,0 +1,801 @@
|
||||
# Migrationsplan: Schema-Restrukturierung
|
||||
|
||||
## Zielsetzung
|
||||
|
||||
### Aktueller Zustand
|
||||
```
|
||||
public.companies (11 Spalten)
|
||||
↓ 1:N
|
||||
public.transactions (49 Spalten) - Angereicherte Produktionsdaten
|
||||
|
||||
backend.transactions (14 Spalten) - Rohdaten
|
||||
↓ N:M
|
||||
backend.transaction_outputs (5 Spalten) - AI-generierte Outputs
|
||||
```
|
||||
|
||||
### Ziel-Zustand
|
||||
```
|
||||
backend.transactions (14 Spalten) - Ersetzt public.companies
|
||||
↓ 1:N
|
||||
public.transactions (5 Spalten, ähnlich backend.transaction_outputs) - Vereinfacht
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Klärungsfragen (KRITISCH)
|
||||
|
||||
Bevor wir fortfahren, müssen folgende Fragen geklärt werden:
|
||||
|
||||
### 1. Companies → backend.transactions Mapping
|
||||
**Problem**: Die Strukturen sind sehr unterschiedlich
|
||||
|
||||
| public.companies | backend.transactions | Kompatibilität |
|
||||
|------------------|---------------------|----------------|
|
||||
| id (bigint) | id (integer) | ⚠️ Typ-Unterschied |
|
||||
| name | corporate_entity | ✅ Ähnlich |
|
||||
| legal_name | - | ❌ Fehlt |
|
||||
| ticker | - | ❌ Fehlt |
|
||||
| sector | - | ❌ Fehlt |
|
||||
| country | tx_country_outgoing? | ⚠️ Unklar |
|
||||
| headquarters | - | ❌ Fehlt |
|
||||
| kyc_risk_level | - | ❌ Fehlt |
|
||||
| summary | - | ❌ Fehlt |
|
||||
| - | corporate_counterparty | ❌ Neu |
|
||||
| - | tx_date, tx_amount, tx_currency | ❌ Neu |
|
||||
| - | tx_purpose | ❌ Neu |
|
||||
| - | source_file, raw_payload | ❌ Neu |
|
||||
| - | status | ❌ Neu |
|
||||
|
||||
**Frage**:
|
||||
- Soll `backend.transactions` erweitert werden, um Company-Felder aufzunehmen?
|
||||
- Oder sollen Companies als einzelne Zeilen ohne Transaktionsdaten gespeichert werden?
|
||||
- Wie wird `corporate_entity` zu `companies.name` gemappt?
|
||||
|
||||
### 2. public.transactions → transaction_outputs Mapping
|
||||
**Problem**: Drastischer Datenverlust bei Vereinfachung
|
||||
|
||||
| public.transactions (49 Felder) | backend.transaction_outputs (5 Felder) |
|
||||
|---------------------------------|----------------------------------------|
|
||||
| Alle Core-Felder (14) | ❌ Verloren |
|
||||
| Alle Enrichment-Felder (34) | ❌ Verloren |
|
||||
| - | transaction_id (Foreign Key) |
|
||||
| - | prompt_id (Welcher?) |
|
||||
| - | output_key (Welcher Typ?) |
|
||||
| - | content (Wie strukturiert?) |
|
||||
| - | run_id (Optional) |
|
||||
|
||||
**Frage**:
|
||||
- Welche Daten aus den 49 Feldern sollen in `content` serialisiert werden?
|
||||
- Welchen `output_key` verwenden wir? (z.B. "transaction_data", "risk_assessment"?)
|
||||
- Welchen `prompt_id` verwenden wir? (Muss in `backend.prompt_templates` existieren)
|
||||
- Was passiert mit den 11 Enrichment-Datenquellen?
|
||||
|
||||
### 3. Relationship & Foreign Keys
|
||||
**Problem**: Beziehungen ändern sich fundamental
|
||||
|
||||
**Aktuell**:
|
||||
```
|
||||
companies.id → transactions.company_id (1:N)
|
||||
```
|
||||
|
||||
**Ziel** (unklar):
|
||||
```
|
||||
backend.transactions.id → public.transactions.transaction_id (1:N)?
|
||||
```
|
||||
|
||||
**Frage**:
|
||||
- Bleibt die 1:N Beziehung erhalten?
|
||||
- Wie wird `transaction_id` in der neuen `public.transactions` gemappt?
|
||||
|
||||
---
|
||||
|
||||
## Vorgeschlagene Alternative: Erweiterte Migration
|
||||
|
||||
Ich schlage eine modifizierte Zielstruktur vor, die Datenverlust minimiert:
|
||||
|
||||
### Option A: Erweitere backend.transactions (Empfohlen)
|
||||
|
||||
```sql
|
||||
backend.companies (neue Tabelle)
|
||||
- id (integer)
|
||||
- name (text)
|
||||
- legal_name (text, nullable)
|
||||
- country (text)
|
||||
- kyc_risk_level (text)
|
||||
- ... weitere Company-Felder
|
||||
|
||||
backend.transactions (erweitert, bleibt)
|
||||
- id (integer)
|
||||
- company_id (integer FK → backend.companies)
|
||||
- corporate_counterparty (text)
|
||||
- tx_date (text → sollte timestamp werden)
|
||||
- tx_amount (double precision)
|
||||
- tx_currency (text)
|
||||
- ... bestehende Felder
|
||||
|
||||
public.transaction_enrichments (neue Tabelle)
|
||||
- id (bigint)
|
||||
- transaction_id (integer FK → backend.transactions)
|
||||
- enrichment_type (varchar) -- 'registry', 'sanctions', etc.
|
||||
- data (jsonb)
|
||||
- last_refreshed_at (timestamp)
|
||||
- created_at, updated_at
|
||||
```
|
||||
|
||||
**Vorteile**:
|
||||
- ✅ Kein Datenverlust
|
||||
- ✅ Klare Trennung: Companies, Transactions, Enrichments
|
||||
- ✅ Backend-Schema behält Rohdaten
|
||||
- ✅ Public-Schema behält angereicherte Daten
|
||||
- ✅ Laravel-Logik kann schrittweise migriert werden
|
||||
|
||||
### Option B: Vollständiger Umzug zu backend Schema
|
||||
|
||||
```sql
|
||||
backend.companies (neu)
|
||||
- Alle Felder von public.companies
|
||||
|
||||
backend.transactions (bleibt)
|
||||
- Bestehende Struktur
|
||||
|
||||
backend.transaction_enrichments (neu)
|
||||
- Alle Enrichment-Daten aus public.transactions
|
||||
|
||||
public.* (deprecated, später löschen)
|
||||
```
|
||||
|
||||
**Vorteile**:
|
||||
- ✅ Alles im backend Schema
|
||||
- ✅ Klare Schema-Trennung
|
||||
- ❌ Laravel-App muss komplett umgeschrieben werden
|
||||
- ❌ Größere Breaking Changes
|
||||
|
||||
---
|
||||
|
||||
## Migrationsplan (nach Klärung)
|
||||
|
||||
### Phase 1: Vorbereitung (1-2 Tage)
|
||||
|
||||
#### 1.1 Backup erstellen
|
||||
```bash
|
||||
# Vollständiges Backup
|
||||
pg_dump -h localhost -U username -d database_name > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# Schema-spezifische Backups
|
||||
pg_dump -h localhost -U username -d database_name -n public > backup_public_$(date +%Y%m%d).sql
|
||||
pg_dump -h localhost -U username -d database_name -n backend > backup_backend_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
#### 1.2 Datenanalyse
|
||||
```sql
|
||||
-- Anzahl Companies
|
||||
SELECT COUNT(*) FROM public.companies;
|
||||
|
||||
-- Anzahl Transactions
|
||||
SELECT COUNT(*) FROM public.transactions;
|
||||
|
||||
-- Datenintegrität prüfen
|
||||
SELECT
|
||||
COUNT(*) as total_transactions,
|
||||
COUNT(DISTINCT company_id) as unique_companies,
|
||||
COUNT(*) FILTER (WHERE company_id NOT IN (SELECT id FROM public.companies)) as orphaned_transactions
|
||||
FROM public.transactions;
|
||||
|
||||
-- Enrichment-Daten Analyse
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE registry_data IS NOT NULL) as has_registry,
|
||||
COUNT(*) FILTER (WHERE sanctions_data IS NOT NULL) as has_sanctions,
|
||||
COUNT(*) FILTER (WHERE pep_data IS NOT NULL) as has_pep
|
||||
FROM public.transactions;
|
||||
```
|
||||
|
||||
#### 1.3 Test-Umgebung aufsetzen
|
||||
```bash
|
||||
# Kopie der Datenbank für Tests
|
||||
createdb -T production_db test_migration_db
|
||||
```
|
||||
|
||||
### Phase 2: Schema-Erweiterung (2-3 Tage)
|
||||
|
||||
#### 2.1 backend.companies erstellen
|
||||
|
||||
**Laravel Migration**:
|
||||
```php
|
||||
<?php
|
||||
// database/migrations/2025_11_12_create_backend_companies_table.php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::connection('backend')->create('companies', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('legal_name')->nullable();
|
||||
$table->string('ticker')->nullable();
|
||||
$table->string('sector')->nullable();
|
||||
$table->string('country', 2)->default('DE');
|
||||
$table->string('headquarters')->nullable();
|
||||
$table->string('kyc_risk_level')->default('medium');
|
||||
$table->text('summary')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection('backend')->dropIfExists('companies');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 2.2 backend.transactions erweitern
|
||||
|
||||
**SQL Migration** (wenn Laravel Multi-Schema-Support limitiert ist):
|
||||
```sql
|
||||
-- Füge company_id zu backend.transactions hinzu
|
||||
ALTER TABLE backend.transactions
|
||||
ADD COLUMN company_id INTEGER REFERENCES backend.companies(id) ON DELETE CASCADE;
|
||||
|
||||
-- Index für Performance
|
||||
CREATE INDEX idx_backend_transactions_company_id ON backend.transactions(company_id);
|
||||
```
|
||||
|
||||
#### 2.3 public.transaction_enrichments erstellen
|
||||
|
||||
**Laravel Migration**:
|
||||
```php
|
||||
<?php
|
||||
// database/migrations/2025_11_12_create_transaction_enrichments_table.php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('transaction_enrichments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->integer('backend_transaction_id'); // FK zu backend.transactions
|
||||
$table->string('enrichment_type', 50); // 'registry', 'sanctions', etc.
|
||||
$table->jsonb('data');
|
||||
$table->timestamp('last_refreshed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['backend_transaction_id', 'enrichment_type']);
|
||||
$table->unique(['backend_transaction_id', 'enrichment_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('transaction_enrichments');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 3: Datenmigration (3-5 Tage)
|
||||
|
||||
#### 3.1 Companies migrieren
|
||||
|
||||
```php
|
||||
<?php
|
||||
// database/migrations/2025_11_12_migrate_companies_data.php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Kopiere Companies von public → backend
|
||||
DB::statement("
|
||||
INSERT INTO backend.companies (
|
||||
id, name, legal_name, ticker, sector,
|
||||
country, headquarters, kyc_risk_level, summary,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, name, legal_name, ticker, sector,
|
||||
country, headquarters, kyc_risk_level, summary,
|
||||
created_at, updated_at
|
||||
FROM public.companies
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
");
|
||||
|
||||
// Aktualisiere Sequence
|
||||
DB::statement("
|
||||
SELECT setval('backend.companies_id_seq',
|
||||
(SELECT MAX(id) FROM backend.companies)
|
||||
)
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement("TRUNCATE backend.companies CASCADE");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 3.2 Transaction Core-Daten migrieren
|
||||
|
||||
```php
|
||||
<?php
|
||||
// database/migrations/2025_11_12_migrate_transaction_core_data.php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Füge company_id zu bestehenden backend.transactions hinzu
|
||||
// (falls diese Daten schon existieren und verknüpft werden müssen)
|
||||
|
||||
// ODER: Migriere von public.transactions → backend.transactions
|
||||
DB::statement("
|
||||
INSERT INTO backend.transactions (
|
||||
corporate_entity,
|
||||
corporate_counterparty,
|
||||
tx_date,
|
||||
tx_amount,
|
||||
tx_currency,
|
||||
tx_purpose,
|
||||
tx_country_incoming,
|
||||
company_id,
|
||||
status,
|
||||
created_at,
|
||||
last_modified_at
|
||||
)
|
||||
SELECT
|
||||
c.name as corporate_entity,
|
||||
t.counterparty as corporate_counterparty,
|
||||
t.executed_at::text as tx_date,
|
||||
t.amount as tx_amount,
|
||||
t.currency as tx_currency,
|
||||
t.flagged_reason as tx_purpose,
|
||||
t.counterparty_country as tx_country_incoming,
|
||||
t.company_id,
|
||||
t.status,
|
||||
t.created_at::text,
|
||||
t.updated_at::text
|
||||
FROM public.transactions t
|
||||
JOIN public.companies c ON t.company_id = c.id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM backend.transactions bt
|
||||
WHERE bt.corporate_entity = c.name
|
||||
AND bt.tx_date = t.executed_at::text
|
||||
)
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Rollback nur für migrierte Daten
|
||||
DB::statement("
|
||||
DELETE FROM backend.transactions
|
||||
WHERE company_id IS NOT NULL
|
||||
");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 3.3 Enrichment-Daten migrieren
|
||||
|
||||
```php
|
||||
<?php
|
||||
// database/migrations/2025_11_12_migrate_enrichment_data.php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private array $enrichmentSources = [
|
||||
'registry' => ['registry_data', 'registry_last_refreshed_at'],
|
||||
'genesis' => ['genesis_context', 'genesis_last_refreshed_at'],
|
||||
'govdata' => ['govdata_data', 'govdata_last_refreshed_at'],
|
||||
'bundesanzeiger' => ['bundesanzeiger_data', 'bundesanzeiger_last_refreshed_at'],
|
||||
'insolvency' => ['insolvency_data', 'insolvency_last_refreshed_at'],
|
||||
'rss' => ['rss_alerts', 'rss_last_refreshed_at'],
|
||||
'sanctions' => ['sanctions_data', 'sanctions_last_refreshed_at'],
|
||||
'pep' => ['pep_data', 'pep_last_refreshed_at'],
|
||||
'gleif' => ['gleif_data', 'gleif_last_refreshed_at'],
|
||||
'eu_sanctions' => ['eu_sanctions_data', 'eu_sanctions_last_refreshed_at'],
|
||||
'handelsregister' => ['handelsregister_data', 'handelsregister_last_refreshed_at'],
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
foreach ($this->enrichmentSources as $type => [$dataField, $refreshField]) {
|
||||
DB::statement("
|
||||
INSERT INTO public.transaction_enrichments (
|
||||
backend_transaction_id,
|
||||
enrichment_type,
|
||||
data,
|
||||
last_refreshed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
bt.id as backend_transaction_id,
|
||||
'{$type}' as enrichment_type,
|
||||
t.{$dataField} as data,
|
||||
t.{$refreshField} as last_refreshed_at,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM public.transactions t
|
||||
JOIN backend.transactions bt ON (
|
||||
bt.tx_date = t.executed_at::text
|
||||
AND bt.tx_amount = t.amount
|
||||
)
|
||||
WHERE t.{$dataField} IS NOT NULL
|
||||
ON CONFLICT (backend_transaction_id, enrichment_type) DO NOTHING
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement("TRUNCATE public.transaction_enrichments");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 4: Model-Anpassung (2-3 Tage)
|
||||
|
||||
#### 4.1 Neue Models erstellen
|
||||
|
||||
**Backend\Company Model**:
|
||||
```php
|
||||
<?php
|
||||
// app/Models/Backend/Company.php
|
||||
|
||||
namespace App\Models\Backend;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Company extends Model
|
||||
{
|
||||
protected $connection = 'backend';
|
||||
protected $table = 'companies';
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'legal_name', 'ticker', 'sector',
|
||||
'country', 'headquarters', 'kyc_risk_level', 'summary'
|
||||
];
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Backend\Transaction Model**:
|
||||
```php
|
||||
<?php
|
||||
// app/Models/Backend/Transaction.php
|
||||
|
||||
namespace App\Models\Backend;
|
||||
|
||||
use App\Models\TransactionEnrichment;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
protected $connection = 'backend';
|
||||
protected $table = 'transactions';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'corporate_entity', 'corporate_counterparty',
|
||||
'tx_date', 'tx_amount', 'tx_currency', 'tx_purpose',
|
||||
'tx_country_outgoing', 'tx_country_incoming',
|
||||
'source_file', 'raw_payload', 'status'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tx_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
public function enrichments(): HasMany
|
||||
{
|
||||
return $this->hasMany(TransactionEnrichment::class, 'backend_transaction_id');
|
||||
}
|
||||
|
||||
public function getEnrichment(string $type): ?array
|
||||
{
|
||||
return $this->enrichments()
|
||||
->where('enrichment_type', $type)
|
||||
->first()
|
||||
?->data;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**TransactionEnrichment Model**:
|
||||
```php
|
||||
<?php
|
||||
// app/Models/TransactionEnrichment.php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Backend\Transaction;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TransactionEnrichment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'backend_transaction_id',
|
||||
'enrichment_type',
|
||||
'data',
|
||||
'last_refreshed_at'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'data' => 'array',
|
||||
'last_refreshed_at' => 'datetime'
|
||||
];
|
||||
|
||||
public function transaction(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Transaction::class, 'backend_transaction_id');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Code-Refactoring (5-7 Tage)
|
||||
|
||||
#### 5.1 Controller anpassen
|
||||
|
||||
**Vorher**:
|
||||
```php
|
||||
use App\Models\Transaction;
|
||||
|
||||
$transactions = Transaction::with('company')
|
||||
->where('risk_score', '>', 50)
|
||||
->get();
|
||||
```
|
||||
|
||||
**Nachher**:
|
||||
```php
|
||||
use App\Models\Backend\Transaction;
|
||||
|
||||
$transactions = Transaction::with(['company', 'enrichments'])
|
||||
->where('risk_score', '>', 50)
|
||||
->get();
|
||||
|
||||
// Enrichment-Daten abrufen
|
||||
foreach ($transactions as $transaction) {
|
||||
$sanctionsData = $transaction->getEnrichment('sanctions');
|
||||
$pepData = $transaction->getEnrichment('pep');
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.2 Compatibility Layer (Optional)
|
||||
|
||||
```php
|
||||
<?php
|
||||
// app/Models/Transaction.php (Legacy-Support)
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Backend\Transaction as BackendTransaction;
|
||||
|
||||
/**
|
||||
* @deprecated Use App\Models\Backend\Transaction instead
|
||||
*/
|
||||
class Transaction extends BackendTransaction
|
||||
{
|
||||
// Proxy zu neuem Model für Backward-Compatibility
|
||||
|
||||
public function __get($key)
|
||||
{
|
||||
// Prüfe ob Enrichment-Feld
|
||||
if (str_ends_with($key, '_data')) {
|
||||
$type = str_replace('_data', '', $key);
|
||||
return $this->getEnrichment($type);
|
||||
}
|
||||
|
||||
return parent::__get($key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 6: Testing (3-5 Tage)
|
||||
|
||||
#### 6.1 Unit Tests
|
||||
|
||||
```php
|
||||
<?php
|
||||
// tests/Unit/Models/Backend/TransactionTest.php
|
||||
|
||||
use App\Models\Backend\Transaction;
|
||||
use App\Models\TransactionEnrichment;
|
||||
|
||||
test('transaction has enrichments relationship', function () {
|
||||
$transaction = Transaction::factory()->create();
|
||||
|
||||
TransactionEnrichment::factory()->create([
|
||||
'backend_transaction_id' => $transaction->id,
|
||||
'enrichment_type' => 'sanctions',
|
||||
'data' => ['status' => 'clear']
|
||||
]);
|
||||
|
||||
expect($transaction->enrichments)->toHaveCount(1);
|
||||
expect($transaction->getEnrichment('sanctions'))->toBe(['status' => 'clear']);
|
||||
});
|
||||
```
|
||||
|
||||
#### 6.2 Integration Tests
|
||||
|
||||
```php
|
||||
<?php
|
||||
// tests/Feature/TransactionMigrationTest.php
|
||||
|
||||
test('data integrity after migration', function () {
|
||||
// Vergleiche Anzahl
|
||||
$oldCount = DB::table('public.transactions')->count();
|
||||
$newCount = DB::table('backend.transactions')->count();
|
||||
|
||||
expect($newCount)->toBeGreaterThanOrEqual($oldCount);
|
||||
|
||||
// Vergleiche Enrichment-Daten
|
||||
$oldEnrichments = DB::table('public.transactions')
|
||||
->whereNotNull('sanctions_data')
|
||||
->count();
|
||||
|
||||
$newEnrichments = DB::table('public.transaction_enrichments')
|
||||
->where('enrichment_type', 'sanctions')
|
||||
->count();
|
||||
|
||||
expect($newEnrichments)->toBe($oldEnrichments);
|
||||
});
|
||||
```
|
||||
|
||||
### Phase 7: Deployment (1-2 Tage)
|
||||
|
||||
#### 7.1 Deployment-Schritte
|
||||
|
||||
```bash
|
||||
# 1. Backup
|
||||
php artisan backup:database
|
||||
|
||||
# 2. Migrations ausführen (in Reihenfolge!)
|
||||
php artisan migrate --path=database/migrations/2025_11_12_create_backend_companies_table.php
|
||||
php artisan migrate --path=database/migrations/2025_11_12_create_transaction_enrichments_table.php
|
||||
php artisan migrate --path=database/migrations/2025_11_12_migrate_companies_data.php
|
||||
php artisan migrate --path=database/migrations/2025_11_12_migrate_transaction_core_data.php
|
||||
php artisan migrate --path=database/migrations/2025_11_12_migrate_enrichment_data.php
|
||||
|
||||
# 3. Verification
|
||||
php artisan tinker
|
||||
>>> DB::table('backend.companies')->count()
|
||||
>>> DB::table('backend.transactions')->count()
|
||||
>>> DB::table('public.transaction_enrichments')->count()
|
||||
|
||||
# 4. Clear caches
|
||||
php artisan cache:clear
|
||||
php artisan config:clear
|
||||
php artisan route:clear
|
||||
php artisan view:clear
|
||||
|
||||
# 5. Run tests
|
||||
php artisan test --filter=TransactionMigration
|
||||
```
|
||||
|
||||
#### 7.2 Rollback-Plan
|
||||
|
||||
```bash
|
||||
# Falls etwas schief geht
|
||||
php artisan migrate:rollback --step=5
|
||||
|
||||
# Restore from backup
|
||||
psql -U username -d database_name < backup_20251112_120000.sql
|
||||
```
|
||||
|
||||
### Phase 8: Cleanup (nach 2-4 Wochen Monitoring)
|
||||
|
||||
```sql
|
||||
-- Wenn alles stabil läuft, alte Tabellen entfernen
|
||||
DROP TABLE public.transactions CASCADE;
|
||||
DROP TABLE public.companies CASCADE;
|
||||
|
||||
-- Views für Backward-Compatibility (optional)
|
||||
CREATE VIEW public.companies AS
|
||||
SELECT * FROM backend.companies;
|
||||
|
||||
CREATE VIEW public.transactions AS
|
||||
SELECT
|
||||
bt.id,
|
||||
bt.company_id,
|
||||
bt.corporate_counterparty as counterparty,
|
||||
bt.tx_amount as amount,
|
||||
bt.tx_currency as currency,
|
||||
bt.tx_date::timestamp as executed_at,
|
||||
bt.status,
|
||||
bt.created_at::timestamp,
|
||||
bt.last_modified_at::timestamp as updated_at
|
||||
FROM backend.transactions bt;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zeitplan
|
||||
|
||||
| Phase | Dauer | Abhängigkeiten |
|
||||
|-------|-------|----------------|
|
||||
| 1. Vorbereitung | 1-2 Tage | - |
|
||||
| 2. Schema-Erweiterung | 2-3 Tage | Phase 1 |
|
||||
| 3. Datenmigration | 3-5 Tage | Phase 2 |
|
||||
| 4. Model-Anpassung | 2-3 Tage | Phase 3 |
|
||||
| 5. Code-Refactoring | 5-7 Tage | Phase 4 |
|
||||
| 6. Testing | 3-5 Tage | Phase 5 |
|
||||
| 7. Deployment | 1-2 Tage | Phase 6 |
|
||||
| 8. Cleanup | Nach 2-4 Wochen | Phase 7 |
|
||||
|
||||
**Gesamtdauer**: 17-27 Arbeitstage (3-5 Wochen)
|
||||
|
||||
---
|
||||
|
||||
## Risiken & Mitigation
|
||||
|
||||
### Risiko 1: Datenverlust
|
||||
**Mitigation**:
|
||||
- Vollständige Backups vor jedem Schritt
|
||||
- Test-Migration in Staging-Umgebung
|
||||
- Datenvalidierung nach jeder Phase
|
||||
|
||||
### Risiko 2: Downtime
|
||||
**Mitigation**:
|
||||
- Migrations während Wartungsfenster
|
||||
- Blue-Green Deployment
|
||||
- Read-Replica für Zero-Downtime
|
||||
|
||||
### Risiko 3: Performance-Probleme
|
||||
**Mitigation**:
|
||||
- Indizes auf Foreign Keys
|
||||
- Batch-Processing für große Datasets
|
||||
- Query-Optimierung mit EXPLAIN ANALYZE
|
||||
|
||||
### Risiko 4: Code-Inkompatibilität
|
||||
**Mitigation**:
|
||||
- Compatibility Layer
|
||||
- Schrittweises Refactoring
|
||||
- Feature-Flags für graduelle Umstellung
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ✅ **Klärung der Fragen oben**
|
||||
2. ⬜ Detaillierte Datenanalyse durchführen
|
||||
3. ⬜ Test-Umgebung aufsetzen
|
||||
4. ⬜ Erste Migration in Staging testen
|
||||
5. ⬜ Review & Approval vom Team
|
||||
6. ⬜ Production-Migration planen
|
||||
|
||||
---
|
||||
|
||||
*Erstellt am: 2025-11-12*
|
||||
*Status: ENTWURF - Wartet auf Klärung der kritischen Fragen*
|
||||
@@ -0,0 +1,332 @@
|
||||
# Detaillierte Beschreibung der Datenbank-Tabellen
|
||||
|
||||
## **Backend Schema**
|
||||
|
||||
### **1. backend.transactions**
|
||||
|
||||
**Zweck**: Rohdaten-Tabelle für eingehende Transaktionen aus verschiedenen Quellen (vermutlich CSV/Excel-Uploads oder API-Imports)
|
||||
|
||||
**Struktur**: 14 Spalten
|
||||
|
||||
#### Identifikation
|
||||
- **id** (integer, NOT NULL, AUTO_INCREMENT)
|
||||
- Primärschlüssel
|
||||
- Sequenz: `backend.transactions_id_seq`
|
||||
|
||||
#### Transaktions-Stammdaten
|
||||
- **corporate_entity** (text, NOT NULL)
|
||||
- Name der durchführenden Firma/Entität
|
||||
- Kein Foreign Key - als Textfeld gespeichert
|
||||
|
||||
- **corporate_counterparty** (text, NOT NULL)
|
||||
- Name der Gegenpartei/Empfänger
|
||||
- Freitext, keine Normalisierung
|
||||
|
||||
- **tx_date** (text, NOT NULL)
|
||||
- Transaktionsdatum
|
||||
- ⚠️ Als Text gespeichert (nicht als DATE/TIMESTAMP)
|
||||
- Wahrscheinlich verschiedene Formate möglich
|
||||
|
||||
- **tx_amount** (double precision, NOT NULL)
|
||||
- Transaktionsbetrag
|
||||
- Fließkommazahl für Währungsbeträge
|
||||
|
||||
- **tx_currency** (text, nullable)
|
||||
- Währungscode (z.B. EUR, USD)
|
||||
- Optional
|
||||
|
||||
- **tx_purpose** (text, nullable)
|
||||
- Verwendungszweck/Beschreibung der Transaktion
|
||||
- Freitextfeld
|
||||
|
||||
#### Geografische Informationen
|
||||
- **tx_country_outgoing** (text, nullable)
|
||||
- Herkunftsland der Zahlung
|
||||
|
||||
- **tx_country_incoming** (text, nullable)
|
||||
- Zielland der Zahlung
|
||||
|
||||
#### Metadaten & Verarbeitung
|
||||
- **source_file** (text, nullable)
|
||||
- Name/Pfad der Quelldatei
|
||||
- Für Nachverfolgbarkeit der Datenherkunft
|
||||
|
||||
- **raw_payload** (text, nullable)
|
||||
- Rohdaten im Originalformat
|
||||
- Ermöglicht Reprocessing bei Bedarf
|
||||
|
||||
- **status** (text, NOT NULL)
|
||||
- Verarbeitungsstatus (z.B. "pending", "processed", "error")
|
||||
|
||||
- **created_at** (text, NOT NULL)
|
||||
- Erstellungszeitpunkt
|
||||
- ⚠️ Als Text gespeichert (nicht als TIMESTAMP)
|
||||
|
||||
- **last_modified_at** (text, NOT NULL)
|
||||
- Letzte Änderung
|
||||
- ⚠️ Als Text gespeichert (nicht als TIMESTAMP)
|
||||
|
||||
**Charakteristik**: ETL-/Staging-Tabelle mit lockerer Typisierung für maximale Flexibilität beim Import
|
||||
|
||||
---
|
||||
|
||||
### **2. backend.transaction_outputs**
|
||||
|
||||
**Zweck**: Speichert generierte Outputs/Ergebnisse aus Prompt-Verarbeitung für Transaktionen (vermutlich KI/LLM-generierte Analysen)
|
||||
|
||||
**Struktur**: 5 Spalten
|
||||
|
||||
#### Primärschlüssel (zusammengesetzt)
|
||||
- **transaction_id** (integer, NOT NULL)
|
||||
- Foreign Key zu `backend.transactions.id`
|
||||
- Referenziert die analysierte Transaktion
|
||||
|
||||
- **prompt_id** (integer, NOT NULL)
|
||||
- Foreign Key zu `backend.prompt_templates` (vermutlich)
|
||||
- Identifiziert welcher Prompt verwendet wurde
|
||||
|
||||
- **output_key** (text, NOT NULL)
|
||||
- Schlüssel für den Output-Typ
|
||||
- Beispiele: "risk_assessment", "compliance_check", "summary", "recommendations"
|
||||
|
||||
#### Output-Daten
|
||||
- **content** (text, NOT NULL)
|
||||
- Der generierte Inhalt/Ergebnis
|
||||
- Kann strukturierter Text, JSON oder Markdown sein
|
||||
|
||||
#### Verknüpfung
|
||||
- **run_id** (integer, nullable)
|
||||
- Foreign Key zu `backend.prompt_runs` (vermutlich)
|
||||
- Gruppiert Outputs aus demselben Batch/Durchlauf
|
||||
- Optional für ad-hoc Generierungen
|
||||
|
||||
**Charakteristik**: N:M-Mapping zwischen Transaktionen und Prompts mit flexiblen Output-Keys
|
||||
|
||||
---
|
||||
|
||||
## **Public Schema**
|
||||
|
||||
### **3. public.companies**
|
||||
|
||||
**Zweck**: Normalisierte Firmenstammdaten für KYC (Know Your Customer) und Compliance
|
||||
|
||||
**Struktur**: 11 Spalten
|
||||
|
||||
#### Identifikation
|
||||
- **id** (bigint, NOT NULL, AUTO_INCREMENT)
|
||||
- Primärschlüssel
|
||||
- Sequenz: `companies_id_seq`
|
||||
|
||||
#### Firmenidentifikation
|
||||
- **name** (varchar, NOT NULL)
|
||||
- Primärer Firmenname (Kurzform/Handelsname)
|
||||
|
||||
- **legal_name** (varchar, nullable)
|
||||
- Offizieller rechtlicher Name
|
||||
- Kann vom Handelsnamen abweichen
|
||||
|
||||
- **ticker** (varchar, nullable)
|
||||
- Börsenticker-Symbol (z.B. "AAPL", "MSFT")
|
||||
- Nur für börsennotierte Unternehmen
|
||||
|
||||
#### Klassifikation & Lokalisierung
|
||||
- **sector** (varchar, nullable)
|
||||
- Wirtschaftssektor/Branche
|
||||
- Z.B. "Technology", "Finance", "Manufacturing"
|
||||
|
||||
- **country** (varchar, NOT NULL, default: 'DE')
|
||||
- Ländercode (ISO 2-Letter)
|
||||
- Standard: Deutschland
|
||||
|
||||
- **headquarters** (varchar, nullable)
|
||||
- Hauptsitz/Firmenzentrale
|
||||
- Stadt oder Stadt + Land
|
||||
|
||||
#### Risk & Compliance
|
||||
- **kyc_risk_level** (varchar, NOT NULL, default: 'medium')
|
||||
- KYC-Risikoeinstufung
|
||||
- Mögliche Werte: "low", "medium", "high"
|
||||
- Default: mittleres Risiko
|
||||
|
||||
#### Zusatzinformationen
|
||||
- **summary** (text, nullable)
|
||||
- Firmenbeschreibung/Zusammenfassung
|
||||
- Freitextfeld für Kontext
|
||||
|
||||
#### Zeitstempel
|
||||
- **created_at** (timestamp, nullable)
|
||||
- Erstellungszeitpunkt
|
||||
|
||||
- **updated_at** (timestamp, nullable)
|
||||
- Letzte Aktualisierung
|
||||
- Laravel-Standard für Timestamps
|
||||
|
||||
**Charakteristik**: Saubere, normalisierte Stammdatentabelle mit KYC-Fokus
|
||||
|
||||
---
|
||||
|
||||
### **4. public.transactions**
|
||||
|
||||
**Zweck**: Produktive Transaktionsdaten mit umfassender Anreicherung aus externen Datenquellen und Risikoanalyse
|
||||
|
||||
**Struktur**: 54 Spalten (!)
|
||||
|
||||
#### Identifikation
|
||||
- **id** (bigint, NOT NULL, AUTO_INCREMENT) - 9x aufgelistet (⚠️ Schema-Anomalie!)
|
||||
- Primärschlüssel
|
||||
- Sequenz: `transactions_id_seq`
|
||||
|
||||
#### Transaktions-Basis
|
||||
- **company_id** (bigint, NOT NULL, default: 1)
|
||||
- Foreign Key zu `public.companies.id`
|
||||
- Zuordnung zur durchführenden Firma
|
||||
|
||||
- **reference** (varchar, NOT NULL)
|
||||
- Transaktionsreferenz/Buchungsnummer
|
||||
- Eindeutiger Identifier
|
||||
|
||||
- **amount** (numeric, NOT NULL)
|
||||
- Transaktionsbetrag
|
||||
- Numeric für präzise Währungsbeträge
|
||||
|
||||
- **currency** (varchar, NOT NULL, default: 'EUR')
|
||||
- Währungscode
|
||||
- Standard: Euro
|
||||
|
||||
- **counterparty** (varchar, NOT NULL)
|
||||
- Name der Gegenpartei
|
||||
|
||||
- **counterparty_country** (varchar, nullable)
|
||||
- Land der Gegenpartei
|
||||
|
||||
- **channel** (varchar, nullable)
|
||||
- Transaktionskanal (z.B. "wire", "sepa", "swift")
|
||||
|
||||
- **executed_at** (timestamp, NOT NULL)
|
||||
- Ausführungszeitpunkt der Transaktion
|
||||
|
||||
#### Risk Management
|
||||
- **risk_score** (smallint, NOT NULL, default: 0)
|
||||
- Risikobewertung (0-100 oder ähnlich)
|
||||
|
||||
- **status** (varchar, NOT NULL)
|
||||
- Transaktionsstatus (z.B. "pending", "approved", "flagged")
|
||||
|
||||
- **requires_review** (boolean, NOT NULL, default: true)
|
||||
- Manuelles Review erforderlich?
|
||||
|
||||
- **flagged_by** (varchar, nullable)
|
||||
- System/User der die Transaktion markiert hat
|
||||
|
||||
- **flagged_reason** (text, nullable)
|
||||
- Grund für Markierung
|
||||
|
||||
- **signals** (json, nullable)
|
||||
- Risikosignale/Trigger als JSON
|
||||
- Strukturierte Risikoindikatoren
|
||||
|
||||
#### Externe Datenquellen (11 Integrationen)
|
||||
|
||||
**1. Registry (Handelsregister Basic)**
|
||||
- **registry_company_number** (text)
|
||||
- **registry_source** (text) - Quelle (z.B. "Handelsregister")
|
||||
- **registry_match_score** (double precision) - Matching-Genauigkeit
|
||||
- **registry_data** (jsonb) - Registrierungsdaten
|
||||
- **registry_last_refreshed_at** (timestamp)
|
||||
|
||||
**2. Genesis (Statistisches Bundesamt)**
|
||||
- **genesis_context** (jsonb)
|
||||
- **genesis_last_refreshed_at** (timestamp)
|
||||
|
||||
**3. GovData (Offene Verwaltungsdaten)**
|
||||
- **govdata_data** (jsonb)
|
||||
- **govdata_last_refreshed_at** (timestamp)
|
||||
|
||||
**4. Bundesanzeiger**
|
||||
- **bundesanzeiger_data** (jsonb)
|
||||
- **bundesanzeiger_last_refreshed_at** (timestamp)
|
||||
|
||||
**5. Insolvency (Insolvenzregister)**
|
||||
- **insolvency_data** (jsonb)
|
||||
- **insolvency_last_refreshed_at** (timestamp)
|
||||
|
||||
**6. RSS Alerts (News/Medien)**
|
||||
- **rss_alerts** (jsonb)
|
||||
- **rss_last_refreshed_at** (timestamp)
|
||||
|
||||
**7. Sanctions (Sanktionslisten)**
|
||||
- **sanctions_data** (jsonb)
|
||||
- **sanctions_last_refreshed_at** (timestamp)
|
||||
|
||||
**8. PEP (Politically Exposed Persons)**
|
||||
- **pep_data** (jsonb)
|
||||
- **pep_last_refreshed_at** (timestamp)
|
||||
|
||||
**9. GLEIF (Legal Entity Identifier)**
|
||||
- **gleif_lei** (text) - LEI-Nummer
|
||||
- **gleif_data** (json)
|
||||
- **gleif_last_refreshed_at** (timestamp)
|
||||
|
||||
**10. EU Sanctions**
|
||||
- **eu_sanctions_data** (jsonb)
|
||||
- **eu_sanctions_last_refreshed_at** (timestamp)
|
||||
|
||||
**11. Handelsregister (Extended)**
|
||||
- **handelsregister_data** (jsonb)
|
||||
- **handelsregister_last_refreshed_at** (timestamp)
|
||||
- **handelsregister_status** (text)
|
||||
- **handelsregister_entity_id** (bigint)
|
||||
|
||||
#### Zeitstempel
|
||||
- **created_at** (timestamp, nullable)
|
||||
- **updated_at** (timestamp, nullable)
|
||||
|
||||
**Charakteristik**: Hochgradig angereichertes Data Warehouse für Compliance und Risk Management mit Multi-Source-Integration
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung der Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Backend Schema (Staging) │
|
||||
├─────────────────────────────────────┤
|
||||
│ • Rohdaten-Import │
|
||||
│ • Lockere Typisierung (text) │
|
||||
│ • Source-Tracking │
|
||||
│ • Prompt/AI-Integration │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
│ ETL/Processing
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Public Schema (Production) │
|
||||
├─────────────────────────────────────┤
|
||||
│ • Normalisierte Daten │
|
||||
│ • Strikte Typisierung │
|
||||
│ • Multi-Source-Enrichment │
|
||||
│ • Risk & Compliance Features │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Datenfluss-Hypothese
|
||||
|
||||
1. **Import**: Rohdaten landen in `backend.transactions`
|
||||
2. **AI-Verarbeitung**: Prompts generieren Outputs in `backend.transaction_outputs`
|
||||
3. **Enrichment**: Externe Datenquellen werden abgefragt
|
||||
4. **Normalisierung**: Daten werden nach `public.companies` und `public.transactions` übertragen
|
||||
5. **Risk Assessment**: Risikoscores und Flags werden berechnet
|
||||
6. **Review**: Transaktionen mit `requires_review=true` landen in der Queue
|
||||
|
||||
## Technische Hinweise
|
||||
|
||||
### Probleme
|
||||
- ⚠️ `public.transactions` hat 9x duplizierte `id` Spalte im Schema
|
||||
- ⚠️ `backend.transactions` speichert Timestamps als TEXT statt TIMESTAMP
|
||||
- ⚠️ Keine expliziten Foreign Key Constraints sichtbar zwischen den Schemas
|
||||
|
||||
### Empfehlungen
|
||||
1. Schema-Anomalie in `public.transactions` untersuchen
|
||||
2. Datum-Felder in `backend.transactions` zu echten TIMESTAMP-Typen migrieren
|
||||
3. Indizes auf häufig genutzte JOIN/WHERE Spalten prüfen
|
||||
4. Foreign Key Constraints zwischen den Schemas dokumentieren
|
||||
@@ -0,0 +1,56 @@
|
||||
# Datenbank-Tabellen Übersicht
|
||||
|
||||
## Alle Tabellen in deiner PostgreSQL-Datenbank (Schema: public)
|
||||
|
||||
**Insgesamt: 41 Tabellen**
|
||||
|
||||
### ✅ Von Laravel-Migrationen erstellt (13 Tabellen):
|
||||
1. `cache`
|
||||
2. `cache_locks`
|
||||
3. `companies`
|
||||
4. `failed_jobs`
|
||||
5. `job_batches`
|
||||
6. `jobs`
|
||||
7. `migrations` (Laravel-interne Tracking-Tabelle)
|
||||
8. `password_reset_tokens`
|
||||
9. `sessions`
|
||||
10. `transactions`
|
||||
11. `users`
|
||||
|
||||
*(Die `users`-Tabelle wurde zusätzlich durch Migration [2025_09_02_075243_add_two_factor_columns_to_users_table.php](database/migrations/2025_09_02_075243_add_two_factor_columns_to_users_table.php) um 2FA-Spalten erweitert)*
|
||||
|
||||
---
|
||||
|
||||
### ❌ NICHT von Laravel-Migrationen erstellt (28 Tabellen):
|
||||
1. `alembic_version` (Python Alembic Migrations)
|
||||
2. `bundesanzeiger_cache`
|
||||
3. `companies_view` (PostgreSQL View)
|
||||
4. `company_gleif_cache`
|
||||
5. `company_master_data`
|
||||
6. `company_master_data_links`
|
||||
7. `company_opencorporates_cache`
|
||||
8. `company_registry_cache`
|
||||
9. `dpma_cache`
|
||||
10. `entity_corporate_context`
|
||||
11. `eu_sanctions_cache`
|
||||
12. `evidence_registry`
|
||||
13. `genesis_cache`
|
||||
14. `govdata_cache`
|
||||
15. `handelsregister_cache`
|
||||
16. `handelsregister_document_links`
|
||||
17. `handelsregister_documents`
|
||||
18. `handelsregister_entities`
|
||||
19. `handelsregister_entity_transactions`
|
||||
20. `handelsregister_relations`
|
||||
21. `insolvency_cache`
|
||||
22. `pep_cache`
|
||||
23. `prompt_runs`
|
||||
24. `prompt_templates`
|
||||
25. `rss_cache`
|
||||
26. `sanctions_cache`
|
||||
27. `test_transaction_llm`
|
||||
28. `transaction` (Singular-Version, eventuell Legacy?)
|
||||
29. `transaction_outputs`
|
||||
30. `transactions_enriched`
|
||||
|
||||
Die meisten dieser externen Tabellen scheinen Cache-Tabellen für verschiedene Datenquellen (Handelsregister, Sanctions, GLEIF, etc.) und Enrichment-Daten zu sein. Die `alembic_version`-Tabelle deutet darauf hin, dass möglicherweise ein Python-Backend parallel läuft.
|
||||
@@ -0,0 +1,610 @@
|
||||
# Analyse: Laravel Models vs Migrations vs Datenbank
|
||||
|
||||
## Übersicht
|
||||
|
||||
Diese Analyse vergleicht die Laravel Eloquent Models mit den entsprechenden Migration-Dateien und der tatsächlichen Datenbankstruktur.
|
||||
|
||||
---
|
||||
|
||||
## **1. Company Model & Migration**
|
||||
|
||||
### ✅ **PERFEKT SYNCHRON**
|
||||
|
||||
#### Migration
|
||||
**Datei**: [database/migrations/2025_10_20_181750_create_companies_table.php](database/migrations/2025_10_20_181750_create_companies_table.php)
|
||||
|
||||
```php
|
||||
Schema::create('companies', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('legal_name')->nullable();
|
||||
$table->string('ticker')->nullable();
|
||||
$table->string('sector')->nullable();
|
||||
$table->string('country', 2)->default('DE');
|
||||
$table->string('headquarters')->nullable();
|
||||
$table->string('kyc_risk_level')->default('medium');
|
||||
$table->text('summary')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
**Felder**:
|
||||
- `id` (auto-increment)
|
||||
- `name` (string, unique)
|
||||
- `legal_name` (string, nullable)
|
||||
- `ticker` (string, nullable)
|
||||
- `sector` (string, nullable)
|
||||
- `country` (string(2), default: 'DE')
|
||||
- `headquarters` (string, nullable)
|
||||
- `kyc_risk_level` (string, default: 'medium')
|
||||
- `summary` (text, nullable)
|
||||
- `timestamps` (created_at, updated_at)
|
||||
|
||||
#### Model
|
||||
**Datei**: [app/Models/Company.php](app/Models/Company.php)
|
||||
|
||||
```php
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'legal_name',
|
||||
'ticker',
|
||||
'sector',
|
||||
'country',
|
||||
'headquarters',
|
||||
'kyc_risk_level',
|
||||
'summary',
|
||||
];
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
```
|
||||
|
||||
#### Datenbank-Status
|
||||
- ✅ Alle Felder vorhanden
|
||||
- ✅ Datentypen stimmen überein
|
||||
- ✅ Defaults korrekt gesetzt
|
||||
- ✅ Unique Constraint auf `name`
|
||||
- ✅ Relationship `hasMany(Transaction::class)` definiert
|
||||
|
||||
---
|
||||
|
||||
## **2. Transaction Model & Migration**
|
||||
|
||||
### ⚠️ **TEILWEISE DISKREPANZEN**
|
||||
|
||||
#### Migration
|
||||
**Datei**: [database/migrations/2025_10_20_181753_create_transactions_table.php](database/migrations/2025_10_20_181753_create_transactions_table.php)
|
||||
|
||||
**Gesamt**: 49 Spalten (ohne timestamps)
|
||||
|
||||
##### Core Felder (15 Spalten)
|
||||
```php
|
||||
$table->id();
|
||||
$table->foreignId('company_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('reference')->unique();
|
||||
$table->decimal('amount', 16, 2);
|
||||
$table->string('currency', 3)->default('EUR');
|
||||
$table->string('counterparty');
|
||||
$table->string('counterparty_country', 2)->nullable();
|
||||
$table->string('channel')->nullable();
|
||||
$table->dateTime('executed_at');
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('status', 32)->index();
|
||||
$table->boolean('requires_review')->default(true);
|
||||
$table->string('flagged_by')->nullable();
|
||||
$table->text('flagged_reason')->nullable();
|
||||
$table->json('signals')->nullable();
|
||||
```
|
||||
|
||||
##### Enrichment-Felder (11 Datenquellen, 34 Spalten)
|
||||
|
||||
**1. Registry (5 Felder)**
|
||||
```php
|
||||
$table->text('registry_company_number')->nullable();
|
||||
$table->text('registry_source')->nullable();
|
||||
$table->double('registry_match_score')->nullable();
|
||||
$table->jsonb('registry_data')->nullable();
|
||||
$table->dateTime('registry_last_refreshed_at')->nullable();
|
||||
```
|
||||
|
||||
**2. Genesis (2 Felder)**
|
||||
```php
|
||||
$table->jsonb('genesis_context')->nullable();
|
||||
$table->dateTime('genesis_last_refreshed_at')->nullable();
|
||||
```
|
||||
|
||||
**3. GovData (2 Felder)**
|
||||
```php
|
||||
$table->jsonb('govdata_data')->nullable();
|
||||
$table->dateTime('govdata_last_refreshed_at')->nullable();
|
||||
```
|
||||
|
||||
**4. Bundesanzeiger (2 Felder)**
|
||||
```php
|
||||
$table->jsonb('bundesanzeiger_data')->nullable();
|
||||
$table->dateTime('bundesanzeiger_last_refreshed_at')->nullable();
|
||||
```
|
||||
|
||||
**5. Insolvency (2 Felder)**
|
||||
```php
|
||||
$table->jsonb('insolvency_data')->nullable();
|
||||
$table->dateTime('insolvency_last_refreshed_at')->nullable();
|
||||
```
|
||||
|
||||
**6. RSS Alerts (2 Felder)**
|
||||
```php
|
||||
$table->jsonb('rss_alerts')->nullable();
|
||||
$table->dateTime('rss_last_refreshed_at')->nullable();
|
||||
```
|
||||
|
||||
**7. Sanctions (2 Felder)**
|
||||
```php
|
||||
$table->jsonb('sanctions_data')->nullable();
|
||||
$table->dateTime('sanctions_last_refreshed_at')->nullable();
|
||||
```
|
||||
|
||||
**8. PEP (2 Felder)**
|
||||
```php
|
||||
$table->jsonb('pep_data')->nullable();
|
||||
$table->dateTime('pep_last_refreshed_at')->nullable();
|
||||
```
|
||||
|
||||
**9. GLEIF (3 Felder)**
|
||||
```php
|
||||
$table->text('gleif_lei')->nullable();
|
||||
$table->json('gleif_data')->nullable();
|
||||
$table->dateTime('gleif_last_refreshed_at')->nullable();
|
||||
```
|
||||
|
||||
**10. EU Sanctions (2 Felder)**
|
||||
```php
|
||||
$table->jsonb('eu_sanctions_data')->nullable();
|
||||
$table->dateTime('eu_sanctions_last_refreshed_at')->nullable();
|
||||
```
|
||||
|
||||
**11. Handelsregister (4 Felder)**
|
||||
```php
|
||||
$table->jsonb('handelsregister_data')->nullable();
|
||||
$table->dateTime('handelsregister_last_refreshed_at')->nullable();
|
||||
$table->text('handelsregister_status')->nullable();
|
||||
$table->bigInteger('handelsregister_entity_id')->nullable();
|
||||
```
|
||||
|
||||
#### Model
|
||||
**Datei**: [app/Models/Transaction.php](app/Models/Transaction.php)
|
||||
|
||||
```php
|
||||
// Status-Konstanten
|
||||
public const STATUS_TRUE_POSITIVE = 'true_positive';
|
||||
public const STATUS_FALSE_POSITIVE = 'false_positive';
|
||||
public const STATUS_CLEARED = 'cleared';
|
||||
|
||||
// Fillable (nur Core-Felder!)
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'reference',
|
||||
'amount',
|
||||
'currency',
|
||||
'counterparty',
|
||||
'counterparty_country',
|
||||
'channel',
|
||||
'executed_at',
|
||||
'risk_score',
|
||||
'status',
|
||||
'requires_review',
|
||||
'flagged_by',
|
||||
'flagged_reason',
|
||||
'signals',
|
||||
];
|
||||
|
||||
// Casts
|
||||
protected $casts = [
|
||||
'executed_at' => 'datetime',
|
||||
'requires_review' => 'boolean',
|
||||
'signals' => 'array',
|
||||
];
|
||||
|
||||
// Relationship
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
// Helper-Methode
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_TRUE_POSITIVE => __('Bestätigter Treffer'),
|
||||
self::STATUS_FALSE_POSITIVE => __('Fehlalarm'),
|
||||
default => __('Freigegeben'),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ **FEHLENDE FELDER IM MODEL**
|
||||
|
||||
Das Transaction Model hat **NUR 14 Core-Felder** im `$fillable` Array, aber die Migration definiert **49 Felder** (exkl. timestamps).
|
||||
|
||||
#### Fehlende Enrichment-Felder (34 Spalten):
|
||||
|
||||
**Registry-Felder:**
|
||||
- `registry_company_number`
|
||||
- `registry_source`
|
||||
- `registry_match_score`
|
||||
- `registry_data`
|
||||
- `registry_last_refreshed_at`
|
||||
|
||||
**Genesis-Felder:**
|
||||
- `genesis_context`
|
||||
- `genesis_last_refreshed_at`
|
||||
|
||||
**GovData-Felder:**
|
||||
- `govdata_data`
|
||||
- `govdata_last_refreshed_at`
|
||||
|
||||
**Bundesanzeiger-Felder:**
|
||||
- `bundesanzeiger_data`
|
||||
- `bundesanzeiger_last_refreshed_at`
|
||||
|
||||
**Insolvency-Felder:**
|
||||
- `insolvency_data`
|
||||
- `insolvency_last_refreshed_at`
|
||||
|
||||
**RSS-Felder:**
|
||||
- `rss_alerts`
|
||||
- `rss_last_refreshed_at`
|
||||
|
||||
**Sanctions-Felder:**
|
||||
- `sanctions_data`
|
||||
- `sanctions_last_refreshed_at`
|
||||
|
||||
**PEP-Felder:**
|
||||
- `pep_data`
|
||||
- `pep_last_refreshed_at`
|
||||
|
||||
**GLEIF-Felder:**
|
||||
- `gleif_lei`
|
||||
- `gleif_data`
|
||||
- `gleif_last_refreshed_at`
|
||||
|
||||
**EU Sanctions-Felder:**
|
||||
- `eu_sanctions_data`
|
||||
- `eu_sanctions_last_refreshed_at`
|
||||
|
||||
**Handelsregister-Felder:**
|
||||
- `handelsregister_data`
|
||||
- `handelsregister_last_refreshed_at`
|
||||
- `handelsregister_status`
|
||||
- `handelsregister_entity_id`
|
||||
|
||||
### ⚠️ **FEHLENDE CASTS**
|
||||
|
||||
Das Model sollte Casts für alle zeitbasierten und JSON-Felder haben:
|
||||
|
||||
**Fehlende DateTime-Casts:**
|
||||
- `registry_last_refreshed_at`
|
||||
- `genesis_last_refreshed_at`
|
||||
- `govdata_last_refreshed_at`
|
||||
- `bundesanzeiger_last_refreshed_at`
|
||||
- `insolvency_last_refreshed_at`
|
||||
- `rss_last_refreshed_at`
|
||||
- `sanctions_last_refreshed_at`
|
||||
- `pep_last_refreshed_at`
|
||||
- `gleif_last_refreshed_at`
|
||||
- `eu_sanctions_last_refreshed_at`
|
||||
- `handelsregister_last_refreshed_at`
|
||||
|
||||
**Fehlende JSON/Array-Casts:**
|
||||
- `registry_data`
|
||||
- `genesis_context`
|
||||
- `govdata_data`
|
||||
- `bundesanzeiger_data`
|
||||
- `insolvency_data`
|
||||
- `rss_alerts`
|
||||
- `sanctions_data`
|
||||
- `pep_data`
|
||||
- `gleif_data`
|
||||
- `eu_sanctions_data`
|
||||
- `handelsregister_data`
|
||||
|
||||
---
|
||||
|
||||
## **3. Vergleich: Datenbank vs Migration**
|
||||
|
||||
### public.companies
|
||||
|
||||
**Status**: ✅ **100% Übereinstimmung**
|
||||
|
||||
| Feature | Migration | Datenbank | Status |
|
||||
|---------|-----------|-----------|--------|
|
||||
| Spalten | 11 | 11 | ✅ |
|
||||
| Unique Constraint | `name` | `name` | ✅ |
|
||||
| Defaults | `country='DE'`, `kyc_risk_level='medium'` | Identisch | ✅ |
|
||||
|
||||
### public.transactions
|
||||
|
||||
**Status**: ✅ **Migration deckt alle DB-Felder ab**
|
||||
|
||||
#### Constraints & Indizes
|
||||
| Constraint | Migration | Datenbank | Status |
|
||||
|------------|-----------|-----------|--------|
|
||||
| Foreign Key | `company_id → companies.id` | Vorhanden | ✅ |
|
||||
| Cascade Delete | `cascadeOnDelete()` | Implementiert | ✅ |
|
||||
| Unique | `reference` | Vorhanden | ✅ |
|
||||
| Index | `status` | Vorhanden | ✅ |
|
||||
|
||||
#### Datentypen-Vergleich
|
||||
|
||||
| Feld | Migration | Datenbank | Status |
|
||||
|------|-----------|-----------|--------|
|
||||
| id | `id()` | bigint | ✅ |
|
||||
| company_id | `foreignId()` | bigint | ✅ |
|
||||
| amount | `decimal(16,2)` | numeric | ✅ |
|
||||
| currency | `string(3)` | varchar | ✅ |
|
||||
| counterparty_country | `string(2)` | varchar | ✅ |
|
||||
| risk_score | `unsignedTinyInteger` | smallint | ⚠️* |
|
||||
| status | `string(32)` | varchar | ✅ |
|
||||
| requires_review | `boolean` | boolean | ✅ |
|
||||
| signals | `json` | json | ✅ |
|
||||
| *_data | `jsonb` | jsonb | ✅ |
|
||||
| gleif_data | `json` | json | ✅ |
|
||||
| executed_at | `dateTime` | timestamp | ✅ |
|
||||
| *_last_refreshed_at | `dateTime` | timestamp | ✅ |
|
||||
|
||||
*`unsignedTinyInteger` (0-255) vs `smallint` (-32768 bis 32767) sind funktional kompatibel
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
### ✅ **Stärken**
|
||||
|
||||
1. **Migration-Dateien sind vollständig**
|
||||
- Alle Datenbank-Felder korrekt definiert
|
||||
- Foreign Key Constraints implementiert
|
||||
- Indizes sinnvoll gesetzt
|
||||
|
||||
2. **Core-Model-Felder stimmen überein**
|
||||
- Basis-Transaktionsfelder vollständig
|
||||
- Relationships sauber definiert
|
||||
|
||||
3. **Datenbank-Konsistenz**
|
||||
- Migrations wurden korrekt ausgeführt
|
||||
- Constraints sind aktiv
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ **Probleme & Empfehlungen**
|
||||
|
||||
### Problem 1: Transaction Model ist unvollständig
|
||||
|
||||
**Problem**:
|
||||
- Das Model definiert nur 14 von 49 Feldern im `$fillable` Array
|
||||
- Alle 34 Enrichment-Felder fehlen
|
||||
|
||||
**Auswirkungen**:
|
||||
- ❌ Enrichment-Felder können nicht via Mass Assignment gesetzt werden
|
||||
- ❌ Keine automatischen Type Casts für externe Datenfelder
|
||||
- ❌ Potenzielle Fehler beim Zugriff auf nicht-gecastete JSON-Daten
|
||||
- ❌ DateTime-Felder werden als Strings zurückgegeben
|
||||
|
||||
**Lösungsvorschläge**:
|
||||
|
||||
**Option 1**: Alle Felder zu `$fillable` hinzufügen
|
||||
```php
|
||||
protected $fillable = [
|
||||
// Core fields
|
||||
'company_id', 'reference', 'amount', 'currency',
|
||||
'counterparty', 'counterparty_country', 'channel',
|
||||
'executed_at', 'risk_score', 'status',
|
||||
'requires_review', 'flagged_by', 'flagged_reason', 'signals',
|
||||
|
||||
// Registry
|
||||
'registry_company_number', 'registry_source', 'registry_match_score',
|
||||
'registry_data', 'registry_last_refreshed_at',
|
||||
|
||||
// Genesis
|
||||
'genesis_context', 'genesis_last_refreshed_at',
|
||||
|
||||
// ... alle weiteren Felder
|
||||
];
|
||||
```
|
||||
|
||||
**Option 2**: `$guarded` verwenden (empfohlen für interne Anwendungen)
|
||||
```php
|
||||
protected $guarded = ['id'];
|
||||
```
|
||||
|
||||
**Option 3**: Separate Accessor/Mutator für Enrichment-Felder
|
||||
```php
|
||||
public function registryData(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn ($value) => json_decode($value, true),
|
||||
set: fn ($value) => json_encode($value),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Problem 2: Fehlende Casts für Enrichment-Felder
|
||||
|
||||
**Problem**:
|
||||
- Keine Casts für `*_last_refreshed_at` Felder
|
||||
- Keine Casts für `*_data` JSON-Felder
|
||||
|
||||
**Auswirkungen**:
|
||||
- DateTime-Felder werden als Strings zurückgegeben (kein Carbon-Objekt)
|
||||
- JSON-Felder müssen manuell dekodiert werden
|
||||
|
||||
**Lösung**:
|
||||
```php
|
||||
protected $casts = [
|
||||
// Existing
|
||||
'executed_at' => 'datetime',
|
||||
'requires_review' => 'boolean',
|
||||
'signals' => 'array',
|
||||
|
||||
// DateTime casts for all refresh timestamps
|
||||
'registry_last_refreshed_at' => 'datetime',
|
||||
'genesis_last_refreshed_at' => 'datetime',
|
||||
'govdata_last_refreshed_at' => 'datetime',
|
||||
'bundesanzeiger_last_refreshed_at' => 'datetime',
|
||||
'insolvency_last_refreshed_at' => 'datetime',
|
||||
'rss_last_refreshed_at' => 'datetime',
|
||||
'sanctions_last_refreshed_at' => 'datetime',
|
||||
'pep_last_refreshed_at' => 'datetime',
|
||||
'gleif_last_refreshed_at' => 'datetime',
|
||||
'eu_sanctions_last_refreshed_at' => 'datetime',
|
||||
'handelsregister_last_refreshed_at' => 'datetime',
|
||||
|
||||
// JSON/Array casts for all data fields
|
||||
'registry_data' => 'array',
|
||||
'genesis_context' => 'array',
|
||||
'govdata_data' => 'array',
|
||||
'bundesanzeiger_data' => 'array',
|
||||
'insolvency_data' => 'array',
|
||||
'rss_alerts' => 'array',
|
||||
'sanctions_data' => 'array',
|
||||
'pep_data' => 'array',
|
||||
'gleif_data' => 'array',
|
||||
'eu_sanctions_data' => 'array',
|
||||
'handelsregister_data' => 'array',
|
||||
];
|
||||
```
|
||||
|
||||
### Problem 3: Datenbank-Schema-Anomalie
|
||||
|
||||
**Problem**:
|
||||
- Die `public.transactions` Tabelle zeigt 9x duplizierte `id` Spalten im describe_table Output
|
||||
|
||||
**Mögliche Ursachen**:
|
||||
- Korruptes Schema-Metadaten
|
||||
- Mehrfache Migration-Ausführungen ohne Rollback
|
||||
- PostgreSQL-Katalog-Problem
|
||||
|
||||
**Lösung**:
|
||||
1. Schema inspizieren: `\d+ transactions` in psql
|
||||
2. Bei Bedarf Migration neu ausführen
|
||||
3. Oder manuelles ALTER TABLE zur Bereinigung
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
### Empfohlene Reihenfolge:
|
||||
|
||||
1. ✅ **Transaction Model aktualisieren**
|
||||
- Alle fehlenden Felder zu `$fillable` hinzufügen
|
||||
- Alle fehlenden Casts definieren
|
||||
|
||||
2. ✅ **Tests schreiben**
|
||||
- Unit-Tests für Model-Casts
|
||||
- Feature-Tests für Enrichment-Datenfluss
|
||||
|
||||
3. ⚠️ **Datenbank-Anomalie untersuchen**
|
||||
- PostgreSQL-Schema inspizieren
|
||||
- Ggf. Migration neu ausführen
|
||||
|
||||
4. 📝 **Dokumentation erweitern**
|
||||
- Enrichment-Pipeline dokumentieren
|
||||
- API für externe Datenquellen dokumentieren
|
||||
|
||||
---
|
||||
|
||||
## Checkliste
|
||||
|
||||
### Companies
|
||||
- [x] Migration vollständig
|
||||
- [x] Model synchron mit Migration
|
||||
- [x] Datenbank korrekt strukturiert
|
||||
- [x] Relationships definiert
|
||||
- [x] Casts korrekt
|
||||
|
||||
### Transactions
|
||||
- [x] Migration vollständig
|
||||
- [ ] Model synchron mit Migration ⚠️
|
||||
- [x] Datenbank korrekt strukturiert
|
||||
- [x] Relationships definiert
|
||||
- [ ] Casts vollständig ⚠️
|
||||
- [ ] Schema-Anomalie behoben ⚠️
|
||||
|
||||
---
|
||||
|
||||
## Anhang: Vollständige Feldliste Transaction Model
|
||||
|
||||
### Core Felder (14)
|
||||
✅ Im Model vorhanden
|
||||
|
||||
1. company_id
|
||||
2. reference
|
||||
3. amount
|
||||
4. currency
|
||||
5. counterparty
|
||||
6. counterparty_country
|
||||
7. channel
|
||||
8. executed_at
|
||||
9. risk_score
|
||||
10. status
|
||||
11. requires_review
|
||||
12. flagged_by
|
||||
13. flagged_reason
|
||||
14. signals
|
||||
|
||||
### Enrichment Felder (34)
|
||||
❌ Im Model fehlend
|
||||
|
||||
**Registry (5)**
|
||||
15. registry_company_number
|
||||
16. registry_source
|
||||
17. registry_match_score
|
||||
18. registry_data
|
||||
19. registry_last_refreshed_at
|
||||
|
||||
**Genesis (2)**
|
||||
20. genesis_context
|
||||
21. genesis_last_refreshed_at
|
||||
|
||||
**GovData (2)**
|
||||
22. govdata_data
|
||||
23. govdata_last_refreshed_at
|
||||
|
||||
**Bundesanzeiger (2)**
|
||||
24. bundesanzeiger_data
|
||||
25. bundesanzeiger_last_refreshed_at
|
||||
|
||||
**Insolvency (2)**
|
||||
26. insolvency_data
|
||||
27. insolvency_last_refreshed_at
|
||||
|
||||
**RSS (2)**
|
||||
28. rss_alerts
|
||||
29. rss_last_refreshed_at
|
||||
|
||||
**Sanctions (2)**
|
||||
30. sanctions_data
|
||||
31. sanctions_last_refreshed_at
|
||||
|
||||
**PEP (2)**
|
||||
32. pep_data
|
||||
33. pep_last_refreshed_at
|
||||
|
||||
**GLEIF (3)**
|
||||
34. gleif_lei
|
||||
35. gleif_data
|
||||
36. gleif_last_refreshed_at
|
||||
|
||||
**EU Sanctions (2)**
|
||||
37. eu_sanctions_data
|
||||
38. eu_sanctions_last_refreshed_at
|
||||
|
||||
**Handelsregister (4)**
|
||||
39. handelsregister_data
|
||||
40. handelsregister_last_refreshed_at
|
||||
41. handelsregister_status
|
||||
42. handelsregister_entity_id
|
||||
|
||||
---
|
||||
|
||||
*Analysiert am: 2025-11-12*
|
||||
@@ -0,0 +1,68 @@
|
||||
-- =====================================================
|
||||
-- Migration Script: Move Non-Laravel Tables to Archive
|
||||
-- =====================================================
|
||||
-- This script creates a new schema 'public_archiv' and
|
||||
-- moves all tables that were not created by Laravel
|
||||
-- migrations from the 'public' schema to 'public_archiv'.
|
||||
-- =====================================================
|
||||
|
||||
-- Step 1: Create new schema
|
||||
CREATE SCHEMA IF NOT EXISTS public_archiv;
|
||||
|
||||
-- Step 2: Move tables to public_archiv schema
|
||||
-- (28 tables that were not created by Laravel migrations)
|
||||
|
||||
ALTER TABLE public.alembic_version SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.bundesanzeiger_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.companies_view SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.company_gleif_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.company_master_data SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.company_master_data_links SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.company_opencorporates_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.company_registry_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.dpma_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.entity_corporate_context SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.eu_sanctions_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.evidence_registry SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.genesis_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.govdata_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.handelsregister_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.handelsregister_document_links SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.handelsregister_documents SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.handelsregister_entities SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.handelsregister_entity_transactions SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.handelsregister_relations SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.insolvency_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.pep_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.prompt_runs SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.prompt_templates SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.rss_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.sanctions_cache SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.test_transaction_llm SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.transaction SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.transaction_outputs SET SCHEMA public_archiv;
|
||||
ALTER TABLE public.transactions_enriched SET SCHEMA public_archiv;
|
||||
|
||||
-- =====================================================
|
||||
-- Verification Queries
|
||||
-- =====================================================
|
||||
|
||||
-- Check tables in public_archiv schema
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public_archiv'
|
||||
ORDER BY table_name;
|
||||
|
||||
-- Check remaining tables in public schema (should only be Laravel tables)
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name;
|
||||
|
||||
-- Count tables per schema
|
||||
SELECT
|
||||
table_schema,
|
||||
COUNT(*) as table_count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema IN ('public', 'public_archiv')
|
||||
GROUP BY table_schema;
|
||||
@@ -0,0 +1,18 @@
|
||||
SELECT * FROM public.test_transaction_llm
|
||||
ORDER BY id ASC ;
|
||||
|
||||
|
||||
select * from backend.transactions;
|
||||
|
||||
Select * from
|
||||
backend.transactions as t
|
||||
left join backend.transaction_outputs as tout on t.id = tout.transaction_id
|
||||
left join backend.prompt_templates as prt on tout.prompt_id = prt.prompt_id
|
||||
--where t.id = 2
|
||||
order by transaction_id, tout.prompt_id;
|
||||
|
||||
select * from backend.prompt_templates;
|
||||
|
||||
Select * from public.companies;
|
||||
|
||||
select * from public.transactions;
|
||||
@@ -16,6 +16,7 @@
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Übersicht') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="magnifying-glass" :href="route('company-search')" :current="request()->routeIs('company-search')" wire:navigate>{{ __('Unternehmensauskunft') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="shield-check" :href="route('transaction-review')" :current="request()->routeIs('transaction-review')" wire:navigate>{{ __('Transaktionsprüfung') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="arrow-up-tray" :href="route('upload')" :current="request()->routeIs('upload')" wire:navigate>{{ __('Datei-Upload') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
</flux:navlist>
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
//
|
||||
}; ?>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-zinc-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="bg-white dark:bg-zinc-800 shadow rounded-lg p-8">
|
||||
<flux:heading size="xl" class="mb-2">{{ __('File Upload') }}</flux:heading>
|
||||
<flux:subheading class="mb-8">{{ __('Upload files to the processing endpoint') }}</flux:subheading>
|
||||
|
||||
<div x-data="{
|
||||
file: null,
|
||||
fileName: '',
|
||||
fileSize: 0,
|
||||
uploading: false,
|
||||
uploadStatus: null,
|
||||
uploadMessage: '',
|
||||
|
||||
selectFile(event) {
|
||||
this.file = event.target.files[0];
|
||||
if (this.file) {
|
||||
this.fileName = this.file.name;
|
||||
this.fileSize = (this.file.size / 1024).toFixed(2);
|
||||
}
|
||||
},
|
||||
|
||||
async upload() {
|
||||
if (!this.file) return;
|
||||
|
||||
this.uploading = true;
|
||||
this.uploadStatus = null;
|
||||
this.uploadMessage = '';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.file);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://upload.trai.mcs.local/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.uploadStatus = 'success';
|
||||
this.uploadMessage = '{{ __('File uploaded successfully!') }}';
|
||||
this.file = null;
|
||||
this.fileName = '';
|
||||
this.fileSize = 0;
|
||||
this.$refs.fileInput.value = '';
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
this.uploadStatus = 'error';
|
||||
this.uploadMessage = `{{ __('Upload failed') }}: ${errorText}`;
|
||||
}
|
||||
} catch (error) {
|
||||
this.uploadStatus = 'error';
|
||||
this.uploadMessage = `{{ __('Upload error') }}: ${error.message}`;
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
}
|
||||
},
|
||||
|
||||
clearFile() {
|
||||
this.file = null;
|
||||
this.fileName = '';
|
||||
this.fileSize = 0;
|
||||
this.$refs.fileInput.value = '';
|
||||
}
|
||||
}">
|
||||
<form @submit.prevent="upload" class="space-y-6">
|
||||
<div>
|
||||
<label for="file-upload" class="block text-sm font-medium text-zinc-950 dark:text-white mb-2">
|
||||
{{ __('Select File') }}
|
||||
</label>
|
||||
<input
|
||||
x-ref="fileInput"
|
||||
id="file-upload"
|
||||
type="file"
|
||||
@change="selectFile"
|
||||
required
|
||||
class="block w-full text-sm text-zinc-900 dark:text-white border border-zinc-300 dark:border-zinc-600 rounded-lg cursor-pointer bg-zinc-50 dark:bg-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-600 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-blue-900 dark:file:text-blue-300 dark:hover:file:bg-blue-800"
|
||||
/>
|
||||
|
||||
<p x-show="fileName" x-text="`{{ __('Selected file') }}: ${fileName} (${fileSize} KB)`" class="mt-2 text-sm text-zinc-600 dark:text-zinc-400"></p>
|
||||
</div>
|
||||
|
||||
<div x-show="uploadStatus">
|
||||
<flux:callout x-bind:variant="uploadStatus === 'success' ? 'success' : 'danger'">
|
||||
<span x-text="uploadMessage"></span>
|
||||
</flux:callout>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<flux:button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
x-bind:disabled="uploading || !file"
|
||||
>
|
||||
<span x-show="!uploading">{{ __('Upload File') }}</span>
|
||||
<span x-show="uploading">{{ __('Uploading...') }}</span>
|
||||
</flux:button>
|
||||
|
||||
<flux:button
|
||||
x-show="file"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
@click="clearFile"
|
||||
x-bind:disabled="uploading"
|
||||
>
|
||||
{{ __('Clear') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div x-show="uploading" class="mt-4">
|
||||
<div class="w-full bg-gray-200 dark:bg-zinc-700 rounded-full h-2.5">
|
||||
<div class="bg-blue-600 h-2.5 rounded-full animate-pulse" style="width: 100%"></div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
{{ __('Please wait while your file is being uploaded...') }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<flux:separator class="my-8" />
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<p class="font-semibold">{{ __('Information:') }}</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>{{ __('Maximum file size: 50 MB') }}</li>
|
||||
<li>{{ __('Upload endpoint: https://upload.trai.mcs.local/api/upload') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,6 +43,10 @@ Volt::route('companies/{company}/transactions', 'companies.transactions')
|
||||
->middleware(['auth', 'verified'])
|
||||
->name('company.transactions');
|
||||
|
||||
Volt::route('upload', 'upload.index')
|
||||
->middleware(['auth', 'verified'])
|
||||
->name('upload');
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::redirect('settings', 'settings/profile');
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Backend\Transaction;
|
||||
use App\Models\Backend\TransactionOutput;
|
||||
|
||||
test('can connect to backend schema', function () {
|
||||
$count = Transaction::count();
|
||||
|
||||
expect($count)->toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('can read transactions from backend schema', function () {
|
||||
$transaction = Transaction::first();
|
||||
|
||||
if ($transaction) {
|
||||
expect($transaction)->toBeInstanceOf(Transaction::class);
|
||||
expect($transaction->corporate_entity)->toBeString();
|
||||
expect($transaction->corporate_counterparty)->toBeString();
|
||||
expect($transaction->tx_amount)->not->toBeNull();
|
||||
} else {
|
||||
expect(true)->toBeTrue(); // No transactions yet
|
||||
}
|
||||
});
|
||||
|
||||
test('can read transaction outputs', function () {
|
||||
$output = TransactionOutput::first();
|
||||
|
||||
if ($output) {
|
||||
expect($output)->toBeInstanceOf(TransactionOutput::class);
|
||||
expect($output->transaction_id)->toBeInt();
|
||||
expect($output->output_key)->toBeString();
|
||||
expect($output->content)->toBeArray();
|
||||
} else {
|
||||
expect(true)->toBeTrue(); // No outputs yet
|
||||
}
|
||||
});
|
||||
|
||||
test('transaction has outputs relationship', function () {
|
||||
$transaction = Transaction::with('outputs')->first();
|
||||
|
||||
if ($transaction) {
|
||||
expect($transaction->outputs)->toBeInstanceOf(\Illuminate\Database\Eloquent\Collection::class);
|
||||
} else {
|
||||
expect(true)->toBeTrue(); // No transactions yet
|
||||
}
|
||||
});
|
||||
|
||||
test('can get specific output by key', function () {
|
||||
$transaction = Transaction::with('outputs')->first();
|
||||
|
||||
if ($transaction && $transaction->outputs->isNotEmpty()) {
|
||||
$firstKey = $transaction->outputs->first()->output_key;
|
||||
$output = $transaction->getOutput($firstKey);
|
||||
|
||||
expect($output)->toBeArray();
|
||||
} else {
|
||||
expect(true)->toBeTrue(); // No transactions with outputs yet
|
||||
}
|
||||
});
|
||||
|
||||
test('can get company info from transaction', function () {
|
||||
$transaction = Transaction::with('outputs')->first();
|
||||
|
||||
if ($transaction) {
|
||||
$companyInfo = $transaction->getCompanyInfo();
|
||||
|
||||
// Kann null sein wenn kein company_info output existiert
|
||||
expect($companyInfo)->toBeIn([null, 'array']);
|
||||
} else {
|
||||
expect(true)->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
test('can get risk assessment from transaction', function () {
|
||||
$transaction = Transaction::with('outputs')->first();
|
||||
|
||||
if ($transaction) {
|
||||
$risk = $transaction->getRiskAssessment();
|
||||
|
||||
// Kann null sein wenn kein risk_assessment output existiert
|
||||
expect($risk)->toBeIn([null, 'array']);
|
||||
|
||||
if ($risk) {
|
||||
expect($risk)->toHaveKey('score');
|
||||
}
|
||||
} else {
|
||||
expect(true)->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
test('can check if transaction requires review', function () {
|
||||
$transaction = Transaction::with('outputs')->first();
|
||||
|
||||
if ($transaction) {
|
||||
$requiresReview = $transaction->requiresReview();
|
||||
|
||||
expect($requiresReview)->toBeBool();
|
||||
} else {
|
||||
expect(true)->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
test('transaction output keys constants exist', function () {
|
||||
expect(TransactionOutput::KEY_COMPANY_INFO)->toBe('company_info');
|
||||
expect(TransactionOutput::KEY_RISK_ASSESSMENT)->toBe('risk_assessment');
|
||||
expect(TransactionOutput::KEY_SANCTIONS)->toBe('sanctions');
|
||||
expect(TransactionOutput::KEY_PEP)->toBe('pep');
|
||||
});
|
||||
|
||||
test('can get all available output keys', function () {
|
||||
$keys = TransactionOutput::availableKeys();
|
||||
|
||||
expect($keys)->toBeArray();
|
||||
expect($keys)->toContain('company_info');
|
||||
expect($keys)->toContain('risk_assessment');
|
||||
});
|
||||
|
||||
test('can get label for output key', function () {
|
||||
$label = TransactionOutput::getKeyLabel('company_info');
|
||||
|
||||
expect($label)->toBe('Company Information');
|
||||
|
||||
$label = TransactionOutput::getKeyLabel('risk_assessment');
|
||||
|
||||
expect($label)->toBe('Risk Assessment');
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
it('requires authentication to access upload page', function () {
|
||||
$response = $this->get('/upload');
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
});
|
||||
|
||||
it('can render upload page when authenticated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Volt::test('upload.index');
|
||||
|
||||
$component
|
||||
->assertSee('File Upload')
|
||||
->assertSee('Select File')
|
||||
->assertSee('Maximum file size: 50 MB')
|
||||
->assertSee('https://upload.trai.mcs.local/api/upload');
|
||||
});
|
||||
|
||||
it('can upload file successfully', function () {
|
||||
Http::fake([
|
||||
'https://upload.trai.mcs.local/api/upload' => Http::response('Success', 200),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
$file = UploadedFile::fake()->create('document.pdf', 100);
|
||||
|
||||
Volt::test('upload.index')
|
||||
->set('file', $file)
|
||||
->call('upload')
|
||||
->assertSet('uploadStatus', 'success')
|
||||
->assertSet('uploadMessage', 'File uploaded successfully!')
|
||||
->assertSet('file', null);
|
||||
});
|
||||
|
||||
it('validates file is required', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Volt::test('upload.index')
|
||||
->set('file', null)
|
||||
->call('upload')
|
||||
->assertHasErrors(['file' => 'required']);
|
||||
});
|
||||
|
||||
it('validates file size limit', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
$file = UploadedFile::fake()->create('large.pdf', 51201); // Exceeds 50MB
|
||||
|
||||
Volt::test('upload.index')
|
||||
->set('file', $file)
|
||||
->call('upload')
|
||||
->assertHasErrors('file');
|
||||
});
|
||||
|
||||
it('handles upload errors gracefully', function () {
|
||||
Http::fake([
|
||||
'https://upload.trai.mcs.local/api/upload' => Http::response('Server Error', 500),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
$file = UploadedFile::fake()->create('document.pdf', 100);
|
||||
|
||||
Volt::test('upload.index')
|
||||
->set('file', $file)
|
||||
->call('upload')
|
||||
->assertSet('uploadStatus', 'error')
|
||||
->assertSee('Upload failed');
|
||||
});
|
||||
|
||||
it('can clear selected file', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
$file = UploadedFile::fake()->create('document.pdf', 100);
|
||||
|
||||
Volt::test('upload.index')
|
||||
->set('file', $file)
|
||||
->assertSet('file', fn ($value) => $value !== null)
|
||||
->set('file', null)
|
||||
->assertSet('file', null);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
it('displays upload link in navigation for authenticated users', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/dashboard')
|
||||
->assertSee('Datei-Upload')
|
||||
->assertSee(route('upload'));
|
||||
});
|
||||
Reference in New Issue
Block a user