mivita/app/Services/DatevExportService.php
2026-02-20 17:55:06 +01:00

1079 lines
39 KiB
PHP

<?php
namespace App\Services;
use App\Models\Country;
use App\Models\DatevExport;
use App\Models\DatevExportLine;
use App\Models\UserCredit;
use App\Models\UserInvoice;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class DatevExportService
{
/**
* DATEV Spaltenüberschriften (116 Spalten, fixe Reihenfolge)
*/
private const COLUMN_HEADERS = [
'Umsatz (ohne Soll/Haben-Kz)',
'Soll/Haben-Kennzeichen',
'WKZ Umsatz',
'Kurs',
'Basis-Umsatz',
'WKZ Basis-Umsatz',
'Konto',
'Gegenkonto (ohne BU-Schlüssel)',
'BU-Schlüssel',
'Belegdatum',
'Belegfeld 1',
'Belegfeld 2',
'Skonto',
'Buchungstext',
'Postensperre',
'Diverse Adressnummer',
'Geschäftspartnerbank',
'Sachverhalt',
'Zinssperre',
'Beleglink',
'Beleginfo - Art 1',
'Beleginfo - Inhalt 1',
'Beleginfo - Art 2',
'Beleginfo - Inhalt 2',
'Beleginfo - Art 3',
'Beleginfo - Inhalt 3',
'Beleginfo - Art 4',
'Beleginfo - Inhalt 4',
'Beleginfo - Art 5',
'Beleginfo - Inhalt 5',
'Beleginfo - Art 6',
'Beleginfo - Inhalt 6',
'Beleginfo - Art 7',
'Beleginfo - Inhalt 7',
'Beleginfo - Art 8',
'Beleginfo - Inhalt 8',
'KOST1 - Kostenstelle',
'KOST2 - Kostenstelle',
'Kost-Menge',
'EU-Land u. UStID',
'EU-Steuersatz',
'Abw. Versteuerungsart',
'Sachverhalt L+L',
'Funktionsergänzung L+L',
'BU 49 Hauptfunktionstyp',
'BU 49 Hauptfunktionsnummer',
'BU 49 Funktionsergänzung',
'Zusatzinformation - Art 1',
'Zusatzinformation- Inhalt 1',
'Zusatzinformation - Art 2',
'Zusatzinformation- Inhalt 2',
'Zusatzinformation - Art 3',
'Zusatzinformation- Inhalt 3',
'Zusatzinformation - Art 4',
'Zusatzinformation- Inhalt 4',
'Zusatzinformation - Art 5',
'Zusatzinformation- Inhalt 5',
'Zusatzinformation - Art 6',
'Zusatzinformation- Inhalt 6',
'Zusatzinformation - Art 7',
'Zusatzinformation- Inhalt 7',
'Zusatzinformation - Art 8',
'Zusatzinformation- Inhalt 8',
'Zusatzinformation - Art 9',
'Zusatzinformation- Inhalt 9',
'Zusatzinformation - Art 10',
'Zusatzinformation- Inhalt 10',
'Zusatzinformation - Art 11',
'Zusatzinformation- Inhalt 11',
'Zusatzinformation - Art 12',
'Zusatzinformation- Inhalt 12',
'Zusatzinformation - Art 13',
'Zusatzinformation- Inhalt 13',
'Zusatzinformation - Art 14',
'Zusatzinformation- Inhalt 14',
'Zusatzinformation - Art 15',
'Zusatzinformation- Inhalt 15',
'Zusatzinformation - Art 16',
'Zusatzinformation- Inhalt 16',
'Zusatzinformation - Art 17',
'Zusatzinformation- Inhalt 17',
'Zusatzinformation - Art 18',
'Zusatzinformation- Inhalt 18',
'Zusatzinformation - Art 19',
'Zusatzinformation- Inhalt 19',
'Zusatzinformation - Art 20',
'Zusatzinformation- Inhalt 20',
'Stück',
'Gewicht',
'Zahlweise',
'Forderungsart',
'Veranlagungsjahr',
'Zugeordnete Fälligkeit',
'Skontotyp',
'Auftragsnummer',
'Buchungstyp',
'USt-Schlüssel (Anzahlungen)',
'EU-Land (Anzahlungen)',
'Sachverhalt L+L (Anzahlungen)',
'EU-Steuersatz (Anzahlungen)',
'Erlöskonto (Anzahlungen)',
'Herkunft-Kz',
'Buchungs GUID',
'KOST-Datum',
'SEPA-Mandatsreferenz',
'Skontosperre',
'Gesellschaftername',
'Beteiligtennummer',
'Identifikationsnummer',
'Zeichnernummer',
'Postensperre bis',
'Bezeichnung SoBil-Sachverhalt',
'Kennzeichen SoBil-Buchung',
'Festschreibung',
'Leistungsdatum',
'Datum Zuord. Steuerperiode',
];
/**
* Anzahl der DATEV-Spalten
*/
private const COLUMN_COUNT = 116;
/**
* Country cache (um wiederholte DB-Abfragen zu vermeiden)
*/
private array $countryCache = [];
/*
|--------------------------------------------------------------------------
| Hauptmethode: Export generieren
|--------------------------------------------------------------------------
*/
/**
* Generiert einen vollständigen DATEV-Export für einen Monat.
*/
public function generateExport(int $month, int $year): DatevExport
{
// Duplikatschutz: Prüfen ob bereits ein nicht-gelöschter Export existiert
$existing = DatevExport::forPeriod($month, $year)
->generated()
->first();
if ($existing && $existing->isLocked()) {
throw new \RuntimeException("Export für {$month}/{$year} ist gesperrt und kann nicht neu generiert werden.");
}
// Alten Entwurf/generierten Export löschen (soft delete)
if ($existing) {
$existing->delete();
}
// Daten sammeln
$invoiceLines = $this->collectInvoiceLines($month, $year);
$creditLines = $this->collectCreditLines($month, $year);
$cancellationLines = $this->collectCancellationLines($month, $year);
$allLines = $invoiceLines->concat($creditLines)->concat($cancellationLines);
// Validierung
$validation = $this->validate($allLines);
// Period berechnen
$periodFrom = Carbon::create($year, $month, 1)->startOfMonth();
$periodTo = Carbon::create($year, $month, 1)->endOfMonth();
// Export-Record anlegen
$export = DatevExport::create([
'period_from' => $periodFrom,
'period_to' => $periodTo,
'month' => $month,
'year' => $year,
'status' => DatevExport::STATUS_GENERATED,
'berater_nr' => config('datev.berater_nr'),
'mandant_nr' => config('datev.mandant_nr'),
'invoice_count' => $invoiceLines->count(),
'credit_count' => $creditLines->count(),
'cancellation_count' => $cancellationLines->count(),
'total_revenue' => $invoiceLines->sum('amount_gross'),
'total_commissions' => $creditLines->sum('amount_gross'),
'created_by' => Auth::id(),
'warning_count' => count($validation['warnings']),
'error_count' => count($validation['errors']),
'validation_summary' => $validation,
]);
// Export-Lines speichern
$lineNumber = 1;
foreach ($allLines as $lineData) {
$lineData['datev_export_id'] = $export->id;
$lineData['line_number'] = $lineNumber++;
// CSV-Zeile rendern
$lineData['row_csv'] = $this->renderCsvRow($lineData);
DatevExportLine::create($lineData);
}
// CSV-Datei generieren und speichern
$csvContent = $this->buildCsv($export);
$filename = $this->generateFilename($month, $year);
$storagePath = $export->getStoragePath();
$fullPath = $storagePath . '/' . $filename;
Storage::disk(config('datev.storage_disk'))->makeDirectory($storagePath);
Storage::disk(config('datev.storage_disk'))->put($fullPath, $csvContent);
$export->update([
'filename' => $filename,
'file_path' => $fullPath,
'file_hash' => hash('sha256', $csvContent),
]);
return $export->fresh();
}
/*
|--------------------------------------------------------------------------
| Datensammlung: Rechnungen (Umsatzerlöse)
|--------------------------------------------------------------------------
*/
/**
* Sammelt alle Rechnungszeilen für die Periode.
* Pro Steuersatz in tax_split wird eine eigene Zeile erzeugt.
*/
public function collectInvoiceLines(int $month, int $year): Collection
{
$lines = collect();
$invoices = UserInvoice::with([
'shopping_order.shopping_payments',
'shopping_order.country.country',
'shopping_order.shopping_user',
'shopping_order.auth_user.account',
])
->where('month', $month)
->where('year', $year)
->where(function ($q) {
$q->where('cancellation', false)
->orWhereNull('cancellation');
})
->whereNotIn('status', [11, 12]) // Nicht stornierte
->get();
foreach ($invoices as $invoice) {
$order = $invoice->shopping_order;
if (! $order) {
continue;
}
$gegenkonto = $this->determineCounterAccountForOrder($order);
$buchungstext = $this->buildBuchungstext($order);
$belegdatum = $this->parseBelegdatum($invoice);
$euLand = null;
// Country-Infos ermitteln
$country = $this->resolveCountry($order);
$isDomestic = $this->isDomestic($country);
$isEu = $this->isEuCountry($country);
$euUstid = $this->resolveEuUstid($order, $isDomestic);
$hasValidUstid = ! empty($euUstid);
if (! $isDomestic && $country) {
$euLand = $country->code ?? null;
}
// Tax-Split vorhanden? -> Mehrere Zeilen pro Steuersatz
if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) {
foreach ($order->tax_split as $taxRate => $taxAmount) {
$taxRate = intval($taxRate);
$taxAmountFloat = $this->parseNumber($taxAmount);
$netAmount = 0;
if ($order->net_split && isset($order->net_split[$taxRate])) {
$netAmount = $this->parseNumber($order->net_split[$taxRate]);
}
$grossAmount = round($netAmount + $taxAmountFloat, 2);
if ($grossAmount <= 0) {
continue;
}
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid);
$lines->push([
'source_type' => DatevExportLine::SOURCE_INVOICE,
'source_id' => $invoice->id,
'order_id' => $order->id,
'user_id' => $order->auth_user_id ?? $order->member_id,
'amount_gross' => $grossAmount,
'soll_haben' => 'H',
'konto' => $revenueMapping['konto'],
'gegenkonto' => $gegenkonto,
'bu_schluessel' => $revenueMapping['bu'],
'belegdatum' => $belegdatum,
'belegfeld1' => $invoice->full_number ?? '',
'buchungstext' => $buchungstext,
'eu_ustid' => $euUstid,
'eu_land' => $euLand,
]);
}
} else {
// Kein Tax-Split: Gesamtbetrag als eine Zeile
$grossAmount = floatval($order->total_shipping ?? $order->total ?? 0);
if ($grossAmount <= 0) {
continue;
}
$taxRate = intval($order->tax_rate ?? 19);
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid);
$lines->push([
'source_type' => DatevExportLine::SOURCE_INVOICE,
'source_id' => $invoice->id,
'order_id' => $order->id,
'user_id' => $order->auth_user_id ?? $order->member_id,
'amount_gross' => $grossAmount,
'soll_haben' => 'H',
'konto' => $revenueMapping['konto'],
'gegenkonto' => $gegenkonto,
'bu_schluessel' => $revenueMapping['bu'],
'belegdatum' => $belegdatum,
'belegfeld1' => $invoice->full_number ?? '',
'buchungstext' => $buchungstext,
'eu_ustid' => $euUstid,
'eu_land' => $euLand,
]);
}
}
return $lines;
}
/*
|--------------------------------------------------------------------------
| Datensammlung: Gutschriften / Provisionen
|--------------------------------------------------------------------------
*/
/**
* Sammelt alle Gutschriftszeilen (Provisionen) für die Periode.
* Pro Provisionstyp-Gruppe (shop, payline, growth_bonus) wird eine Zeile erzeugt.
*/
public function collectCreditLines(int $month, int $year): Collection
{
$lines = collect();
$credits = UserCredit::with([
'user.account',
'user_credit_items',
])
->where('month', $month)
->where('year', $year)
->where('status', '!=', 10) // Nicht storniert
->whereNotNull('full_number') // Muss eine Nummer haben
->get();
$statusMap = config('datev.credit_item_status_map', []);
$commissionAccounts = config('datev.commission_accounts', []);
foreach ($credits as $credit) {
$user = $credit->user;
$account = $user ? $user->account : null;
// Steuerstatus des Beraters ermitteln
$taxStatus = $this->determineCommissionTaxStatus($account);
$buSchluessel = config('datev.commission_tax_keys.' . $taxStatus, 9);
// USt-ID für Reverse Charge
$euUstid = null;
$euLand = null;
if ($taxStatus === 'reverse_charge' && $account) {
$euUstid = $account->tax_identification_number ?? null;
$euLand = $account->reverse_charge_code ?? null;
}
$buchungstext = $this->buildCreditBuchungstext($account, $user, $credit->user_id);
// Items nach Provisionstyp gruppieren
$grouped = $credit->user_credit_items
->filter(function ($item) use ($statusMap) {
return isset($statusMap[$item->status]);
})
->groupBy(function ($item) use ($statusMap) {
return $statusMap[$item->status] ?? 'shop';
});
foreach ($grouped as $type => $items) {
$totalAmount = $items->sum('credit');
if ($totalAmount <= 0) {
continue;
}
// Konto bestimmen
$konto = $commissionAccounts[$type] ?? $commissionAccounts['shop'];
// Bei USt-pflichtigen Beratern: Brutto berechnen
$grossAmount = $totalAmount;
if ($taxStatus === 'normal' && $credit->taxable == 1) {
// Netto + USt = Brutto
$taxRate = config('app.main_tax_rate', 19);
$grossAmount = round($totalAmount * (1 + $taxRate / 100), 2);
}
$lines->push([
'source_type' => DatevExportLine::SOURCE_CREDIT,
'source_id' => $credit->id,
'user_id' => $user?->id,
'amount_gross' => $grossAmount,
'soll_haben' => 'S',
'konto' => $konto,
'gegenkonto' => intval(config('datev.sammelkreditor', 70000)),
'bu_schluessel' => $buSchluessel,
'belegdatum' => $credit->getRawOriginal('date') ?? now()->format('Y-m-d'),
'belegfeld1' => $credit->full_number ?? '',
'buchungstext' => $buchungstext,
'eu_ustid' => $euUstid,
'eu_land' => $euLand,
]);
}
// Falls keine Items nach Typ gruppierbar, aber Credit hat Gesamtbetrag
if ($grouped->isEmpty() && $credit->total > 0) {
$lines->push([
'source_type' => DatevExportLine::SOURCE_CREDIT,
'source_id' => $credit->id,
'user_id' => $user?->id,
'amount_gross' => abs($credit->total),
'soll_haben' => 'S',
'konto' => $commissionAccounts['shop'],
'gegenkonto' => intval(config('datev.sammelkreditor', 70000)),
'bu_schluessel' => $buSchluessel,
'belegdatum' => $credit->getRawOriginal('date') ?? now()->format('Y-m-d'),
'belegfeld1' => $credit->full_number ?? '',
'buchungstext' => $buchungstext,
'eu_ustid' => $euUstid,
'eu_land' => $euLand,
]);
}
}
return $lines;
}
/*
|--------------------------------------------------------------------------
| Datensammlung: Stornorechnungen
|--------------------------------------------------------------------------
*/
/**
* Sammelt alle Stornorechnungen für die Periode.
* Stornos werden als negative Beträge exportiert.
*/
public function collectCancellationLines(int $month, int $year): Collection
{
$lines = collect();
$cancellations = UserInvoice::with([
'shopping_order.shopping_payments',
'shopping_order.country.country',
'shopping_order.shopping_user',
'shopping_order.auth_user.account',
])
->where('month', $month)
->where('year', $year)
->where('cancellation', true)
->get();
foreach ($cancellations as $invoice) {
$order = $invoice->shopping_order;
if (! $order) {
continue;
}
$gegenkonto = $this->determineCounterAccountForOrder($order);
$buchungstext = 'STORNO ' . $this->buildBuchungstext($order);
$buchungstext = mb_substr($buchungstext, 0, 60);
$belegdatum = $this->parseBelegdatum($invoice);
$country = $this->resolveCountry($order);
$isDomestic = $this->isDomestic($country);
$isEu = $this->isEuCountry($country);
$euLand = (! $isDomestic && $country) ? ($country->code ?? null) : null;
$euUstid = $this->resolveEuUstid($order, $isDomestic);
$hasValidUstid = ! empty($euUstid);
if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) {
foreach ($order->tax_split as $taxRate => $taxAmount) {
$taxRate = intval($taxRate);
$taxAmountFloat = $this->parseNumber($taxAmount);
$netAmount = 0;
if ($order->net_split && isset($order->net_split[$taxRate])) {
$netAmount = $this->parseNumber($order->net_split[$taxRate]);
}
$grossAmount = round($netAmount + $taxAmountFloat, 2);
if ($grossAmount <= 0) {
continue;
}
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid);
// Storno: Negativer Betrag
$lines->push([
'source_type' => DatevExportLine::SOURCE_CANCELLATION,
'source_id' => $invoice->id,
'order_id' => $order->id,
'user_id' => $order->auth_user_id ?? $order->member_id,
'amount_gross' => $grossAmount, // Betrag positiv, aber S statt H
'soll_haben' => 'S', // Umkehrung: Soll statt Haben
'konto' => $revenueMapping['konto'],
'gegenkonto' => $gegenkonto,
'bu_schluessel' => $revenueMapping['bu'],
'belegdatum' => $belegdatum,
'belegfeld1' => $invoice->full_number ?? '',
'buchungstext' => $buchungstext,
'eu_ustid' => $euUstid,
'eu_land' => $euLand,
]);
}
} else {
$grossAmount = floatval($order->total_shipping ?? $order->total ?? 0);
if ($grossAmount <= 0) {
continue;
}
$taxRate = intval($order->tax_rate ?? 19);
$revenueMapping = $this->determineRevenueAccount($taxRate, $isDomestic, $isEu, $hasValidUstid);
$lines->push([
'source_type' => DatevExportLine::SOURCE_CANCELLATION,
'source_id' => $invoice->id,
'order_id' => $order->id,
'user_id' => $order->auth_user_id ?? $order->member_id,
'amount_gross' => $grossAmount,
'soll_haben' => 'S',
'konto' => $revenueMapping['konto'],
'gegenkonto' => $gegenkonto,
'bu_schluessel' => $revenueMapping['bu'],
'belegdatum' => $belegdatum,
'belegfeld1' => $invoice->full_number ?? '',
'buchungstext' => $buchungstext,
'eu_ustid' => $euUstid,
'eu_land' => $euLand,
]);
}
}
return $lines;
}
/*
|--------------------------------------------------------------------------
| CSV-Generator (EXTF-Format)
|--------------------------------------------------------------------------
*/
/**
* Baut die komplette DATEV EXTF CSV-Datei.
*/
public function buildCsv(DatevExport $export): string
{
$delimiter = config('datev.delimiter', ';');
$lineEnding = config('datev.line_ending', "\r\n");
$output = '';
// UTF-8 BOM
if (config('datev.encoding') === 'UTF-8') {
$output .= "\xEF\xBB\xBF";
}
// Zeile 1: Header
$output .= $this->buildHeaderLine($export) . $lineEnding;
// Zeile 2: Spaltenüberschriften
$output .= implode($delimiter, self::COLUMN_HEADERS) . $lineEnding;
// Zeile 3+: Datenzeilen
$lines = $export->lines()->orderBy('line_number')->get();
foreach ($lines as $line) {
if ($line->row_csv) {
$output .= $line->row_csv . $lineEnding;
} else {
$output .= $this->renderCsvRow($line->toArray()) . $lineEnding;
}
}
return $output;
}
/**
* Baut die DATEV Header-Zeile (Zeile 1) gemäß EXTF-Spezifikation.
*/
private function buildHeaderLine(DatevExport $export): string
{
$delimiter = config('datev.delimiter', ';');
// DATEV Header Felder (gemäß DATEV Developer Portal)
$header = [
'EXTF', // 1: Kennzeichen
config('datev.format_version', 700), // 2: Versionsnummer
config('datev.format_category', 21), // 3: Formatkategorie (21=Buchungsstapel)
config('datev.format_name', 'Buchungsstapel'), // 4: Formatname
'', // 5: Formatversion
'', // 6: Erzeugt am (YYYYMMDDHHMMSS000)
'', // 7: Importiert (leer lassen)
config('datev.source', 'ERP'), // 8: Herkunft
config('datev.source_name', 'MIVITA'), // 9: Exportiert von
'', // 10: Importiert von
$export->berater_nr ?? '', // 11: Berater-Nr
$export->mandant_nr ?? '', // 12: Mandant-Nr
$export->period_from->format('Ymd'), // 13: WJ-Beginn (Wirtschaftsjahrbeginn)
config('datev.sachkontenlaenge', 4), // 14: Sachkontenlänge
$export->period_from->format('Ymd'), // 15: Datum von
$export->period_to->format('Ymd'), // 16: Datum bis
'', // 17: Bezeichnung
'', // 18: Diktatkürzel
1, // 19: Buchungstyp (1=Finanzbuchführung)
0, // 20: Rechnungslegungszweck
0, // 21: Festschreibung (0=keine)
config('datev.currency', 'EUR'), // 22: WKZ
];
// Pad mit leeren Feldern bis die Header-Zeile vollständig ist
// Der Rest des Headers besteht aus optionalen Feldern
while (count($header) < 100) {
$header[] = '';
}
return implode($delimiter, $header);
}
/**
* Rendert eine einzelne CSV-Datenzeile aus den Line-Daten.
*/
private function renderCsvRow(array $data): string
{
$delimiter = config('datev.delimiter', ';');
// DATEV Belegdatum-Format: TTMM (4 Stellen)
$belegdatum = '';
if (! empty($data['belegdatum'])) {
$date = $data['belegdatum'] instanceof Carbon
? $data['belegdatum']
: Carbon::parse($data['belegdatum']);
$belegdatum = $date->format('dm'); // TTMM
}
// Betrag formatieren: DATEV erwartet Komma als Dezimaltrennzeichen
$amount = str_replace('.', ',', number_format(abs($data['amount_gross'] ?? 0), 2, '.', ''));
// 116 Spalten befüllen (die meisten leer)
$row = array_fill(0, self::COLUMN_COUNT, '');
$row[0] = $amount; // Spalte A: Umsatz
$row[1] = $data['soll_haben'] ?? 'H'; // Spalte B: Soll/Haben
// Spalte C-F: WKZ, Kurs, Basis, WKZ Basis (leer)
$row[6] = $data['konto'] ?? ''; // Spalte G: Konto
$row[7] = $data['gegenkonto'] ?? ''; // Spalte H: Gegenkonto
$row[8] = $data['bu_schluessel'] ?? ''; // Spalte I: BU-Schlüssel
$row[9] = $belegdatum; // Spalte J: Belegdatum
$row[10] = $data['belegfeld1'] ?? ''; // Spalte K: Belegfeld 1
// Spalte L: Belegfeld 2 (leer)
// Spalte M: Skonto (leer)
$row[13] = $this->escapeCsvField($data['buchungstext'] ?? ''); // Spalte N: Buchungstext
// Spalte AN (Index 39): EU-Land u. UStID
if (! empty($data['eu_ustid'])) {
$row[39] = $data['eu_ustid'];
}
return implode($delimiter, $row);
}
/*
|--------------------------------------------------------------------------
| Validierung
|--------------------------------------------------------------------------
*/
/**
* Validiert die gesammelten Zeilen und gibt Warnings/Errors zurück.
*/
public function validate(Collection $lines): array
{
$warnings = [];
$errors = [];
foreach ($lines as $index => $line) {
$meta = [
'source_type' => $line['source_type'] ?? null,
'source_id' => $line['source_id'] ?? null,
'order_id' => $line['order_id'] ?? null,
'user_id' => $line['user_id'] ?? null,
'belegfeld1' => $line['belegfeld1'] ?? null,
];
$lineRef = ($meta['source_type'] ?? '?') . ' #' . ($meta['source_id'] ?? '?');
if (empty($line['belegdatum'])) {
$errors[] = $this->buildValidationEntry("{$lineRef}: Belegdatum fehlt.", $meta);
}
if (empty($line['belegfeld1'])) {
$errors[] = $this->buildValidationEntry("{$lineRef}: Belegnummer (Belegfeld 1) fehlt.", $meta);
}
if (empty($line['buchungstext']) || trim($line['buchungstext']) === '-') {
$warnings[] = $this->buildValidationEntry("{$lineRef}: Buchungstext fehlt oder ist leer.", $meta);
}
if (($line['amount_gross'] ?? 0) <= 0) {
$warnings[] = $this->buildValidationEntry("{$lineRef}: Betrag ist 0 oder negativ ({$line['amount_gross']}).", $meta);
}
if (($line['bu_schluessel'] ?? 0) == 1 && empty($line['eu_ustid'])) {
$warnings[] = $this->buildValidationEntry("{$lineRef}: EU-Lieferung (BU 1) ohne USt-ID.", $meta);
}
if (($line['bu_schluessel'] ?? 0) == 94 && empty($line['eu_ustid'])) {
$warnings[] = $this->buildValidationEntry("{$lineRef}: Reverse Charge (BU 94) ohne USt-ID.", $meta);
}
$knownBu = [1, 8, 9, 11, 50, 94];
if (! in_array($line['bu_schluessel'] ?? 0, $knownBu)) {
$warnings[] = $this->buildValidationEntry("{$lineRef}: Unbekannter BU-Schlüssel ({$line['bu_schluessel']}).", $meta);
}
}
return [
'warnings' => $warnings,
'errors' => $errors,
'valid' => count($errors) === 0,
];
}
/**
* @param array{source_type: ?string, source_id: ?int, order_id: ?int, user_id: ?int, belegfeld1: ?string} $meta
* @return array{message: string, source_type: ?string, source_id: ?int, order_id: ?int, user_id: ?int, belegfeld1: ?string}
*/
private function buildValidationEntry(string $message, array $meta): array
{
return array_merge(['message' => $message], $meta);
}
/*
|--------------------------------------------------------------------------
| Vorschau (ohne Speicherung)
|--------------------------------------------------------------------------
*/
/**
* Erzeugt eine gruppierte Vorschau der Daten für die Periode.
*/
public function getPreview(int $month, int $year): array
{
$invoiceLines = $this->collectInvoiceLines($month, $year);
$creditLines = $this->collectCreditLines($month, $year);
$cancellationLines = $this->collectCancellationLines($month, $year);
$allLines = $invoiceLines->concat($creditLines)->concat($cancellationLines);
$validation = $this->validate($allLines);
// Gruppierte Zusammenfassung nach Konto + BU
$grouped = $allLines->groupBy(function ($line) {
return $line['konto'] . '-' . $line['bu_schluessel'] . '-' . $line['soll_haben'];
})->map(function ($group, $key) {
$first = $group->first();
return [
'konto' => $first['konto'],
'bu_schluessel' => $first['bu_schluessel'],
'soll_haben' => $first['soll_haben'],
'count' => $group->count(),
'total' => round($group->sum('amount_gross'), 2),
];
})->values();
return [
'summary' => [
'invoice_count' => $invoiceLines->count(),
'credit_count' => $creditLines->count(),
'cancellation_count' => $cancellationLines->count(),
'total_lines' => $allLines->count(),
'total_revenue' => round($invoiceLines->sum('amount_gross'), 2),
'total_commissions' => round($creditLines->sum('amount_gross'), 2),
'total_cancellations' => round($cancellationLines->sum('amount_gross'), 2),
],
'grouped' => $grouped,
'validation' => $validation,
];
}
/*
|--------------------------------------------------------------------------
| Hilfsmethoden: Konten-Mapping
|--------------------------------------------------------------------------
*/
/**
* Bestimmt das Erlöskonto und den BU-Schlüssel basierend auf Steuersatz, Land und USt-ID.
*
* EU-steuerfreie IG-Lieferung (BU 1 / Konto 8125) nur wenn eine gültige USt-ID vorliegt.
* Ohne USt-ID wird auch bei EU-Lieferungen die reguläre Inlandsbesteuerung angewendet.
*/
private function determineRevenueAccount(int $taxRate, bool $isDomestic, bool $isEu, bool $hasValidUstid = false): array
{
$accounts = config('datev.revenue_accounts');
if (! $isDomestic && ! $isEu) {
return $accounts['third_country_exempt'];
}
if ($isEu && ! $isDomestic && $hasValidUstid) {
return $accounts['eu_exempt'];
}
if ($taxRate == 7 || $taxRate == 5) {
return $accounts['domestic_7'];
}
return $accounts['domestic_19'];
}
/**
* Bestimmt das Gegenkonto basierend auf der Zahlungsart.
*/
private function determineCounterAccountForOrder($order): int
{
$sammeldebitor = intval(config('datev.sammeldebitor', 10000));
if (! $order->shopping_payments || $order->shopping_payments->isEmpty()) {
return $sammeldebitor;
}
$payment = $order->shopping_payments->first();
if (! $payment) {
return $sammeldebitor;
}
// Zusammengesetzter Key aus clearingtype + wallettype
$key = $payment->clearingtype;
if ($payment->wallettype) {
$key .= '_' . $payment->wallettype;
}
$map = config('datev.counteraccount_map', []);
return isset($map[$key]) ? intval($map[$key]) : $sammeldebitor;
}
/**
* Bestimmt den Steuerstatus eines Beraters für die Provision.
*/
private function determineCommissionTaxStatus($account): string
{
if (! $account) {
return 'normal';
}
// Reverse Charge hat Vorrang
if ($account->reverse_charge) {
return 'reverse_charge';
}
// Kleinunternehmer (taxable_sales = 2)
if ($account->taxable_sales == 2) {
return 'kleinunternehmer';
}
// Ausland nicht USt-pflichtig (taxable_sales = 3)
// Wird wie Reverse Charge behandelt, falls aus EU
if ($account->taxable_sales == 3) {
$country = $account->country;
if ($country && $country->eu_country) {
return 'reverse_charge';
}
return 'kleinunternehmer'; // Drittland: kein BU nötig, aber KU als Fallback
}
return 'normal';
}
/*
|--------------------------------------------------------------------------
| Hilfsmethoden: Datenaufbereitung
|--------------------------------------------------------------------------
*/
/**
* Löst die Country-Entität aus ShoppingOrder auf.
* ShoppingOrder.country_id -> ShippingCountry -> Country
*/
private function resolveCountry($order): ?Country
{
if (! $order->country) {
return null;
}
$shippingCountryId = $order->country->country_id ?? null;
if (! $shippingCountryId) {
return null;
}
if (! isset($this->countryCache[$shippingCountryId])) {
$this->countryCache[$shippingCountryId] = Country::find($shippingCountryId);
}
return $this->countryCache[$shippingCountryId];
}
/**
* Prüft ob das Land Deutschland ist.
*/
private function isDomestic(?Country $country): bool
{
if (! $country) {
return true; // Fallback: Inland
}
return $country->id == config('datev.germany_country_id', 1);
}
/**
* Prüft ob das Land in der EU ist.
*/
private function isEuCountry(?Country $country): bool
{
if (! $country) {
return false;
}
return (bool) $country->eu_country;
}
/**
* Ermittelt die USt-ID des Bestellers (auth_user).
* Nur registrierte Berater mit hinterlegter USt-ID qualifizieren sich
* für steuerfreie innergemeinschaftliche Lieferungen.
*/
private function resolveEuUstid($order, bool $isDomestic): ?string
{
if ($isDomestic) {
return null;
}
return $order->auth_user?->account?->tax_identification_number ?: null;
}
/**
* Baut den Buchungstext für Umsatz-Buchungen.
* Format: "Nachname Vorname"
*/
private function buildBuchungstext($order): string
{
if ($order->shopping_user) {
$name = trim(
($order->shopping_user->billing_lastname ?? '') . ' ' .
($order->shopping_user->billing_firstname ?? '')
);
if (! empty($name)) {
return mb_substr($name, 0, 60);
}
}
return '-';
}
/**
* Baut den Buchungstext für Gutschrift-Buchungen.
* Format: "Nachname Vorname; BeraterNr"
*/
private function buildCreditBuchungstext($account, $user, ?int $creditUserId = null): string
{
$parts = [];
if ($account) {
$name = trim(($account->last_name ?? '') . ' ' . ($account->first_name ?? ''));
if (! empty($name)) {
$parts[] = $name;
}
}
$userId = $user?->id ?? $creditUserId;
if ($userId) {
$parts[] = 'Nr.' . $userId;
}
$text = implode('; ', $parts);
return mb_substr($text ?: '-', 0, 60);
}
/**
* Parst das Belegdatum aus der Invoice.
*/
private function parseBelegdatum($invoice): string
{
// UserInvoice.date hat einen Custom Accessor der formatiert
// Wir brauchen den Rohwert
$rawDate = $invoice->getRawOriginal('date');
if ($rawDate) {
return $rawDate;
}
// Fallback: Erster des Monats
return Carbon::create($invoice->year, $invoice->month, 1)->format('Y-m-d');
}
/**
* Parst einen formatierten Zahlenwert (z.B. "5.00" oder "5,00") zu float.
* Behandelt sowohl einfache Werte als auch tax_split Arrays (homeparty).
*/
private function parseNumber($value): float
{
if (is_array($value)) {
// Homeparty tax_split Format: ['vk_tax' => '5.00', 'ek_tax' => '2.00']
return floatval(str_replace(',', '.', $value['vk_tax'] ?? 0));
}
return floatval(str_replace(',', '.', $value));
}
/**
* Escaped ein CSV-Feld (Semikolon, Anführungszeichen, Newlines).
*/
private function escapeCsvField(string $value): string
{
// Semikolons und Anführungszeichen in Buchungstext escapen
$value = str_replace('"', '""', $value);
if (str_contains($value, ';') || str_contains($value, '"') || str_contains($value, "\n")) {
return '"' . $value . '"';
}
return $value;
}
/**
* Generiert den Dateinamen für die Export-Datei.
*/
private function generateFilename(int $month, int $year): string
{
$monthPad = str_pad($month, 2, '0', STR_PAD_LEFT);
return "EXTF_Buchungsstapel_{$year}_{$monthPad}_" . date('YmdHis') . '.csv';
}
}