1125 lines
40 KiB
PHP
1125 lines
40 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',
|
|
'shopping_order.shopping_collect_order',
|
|
])
|
|
->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) {
|
|
$netSplit = $this->resolveNetSplit($order);
|
|
|
|
foreach ($order->tax_split as $taxRate => $taxAmount) {
|
|
$taxRate = intval($taxRate);
|
|
$taxAmountFloat = $this->parseNumber($taxAmount, 'ek_tax');
|
|
$netAmount = 0;
|
|
|
|
if ($netSplit && isset($netSplit[$taxRate])) {
|
|
$netAmount = $this->parseNumber($netSplit[$taxRate], 'ek_net');
|
|
}
|
|
|
|
$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',
|
|
'shopping_order.shopping_collect_order',
|
|
])
|
|
->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) {
|
|
$netSplit = $this->resolveNetSplit($order);
|
|
|
|
foreach ($order->tax_split as $taxRate => $taxAmount) {
|
|
$taxRate = intval($taxRate);
|
|
$taxAmountFloat = $this->parseNumber($taxAmount, 'ek_tax');
|
|
$netAmount = 0;
|
|
|
|
if ($netSplit && isset($netSplit[$taxRate])) {
|
|
$netAmount = $this->parseNumber($netSplit[$taxRate], 'ek_net');
|
|
}
|
|
|
|
$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');
|
|
}
|
|
|
|
/**
|
|
* Ermittelt den Netto-Split. Historische Sammelrechnungen haben ihn teils nur
|
|
* an der ShoppingCollectOrder, nicht an der erzeugten ShoppingOrder.
|
|
*/
|
|
private function resolveNetSplit($order): ?array
|
|
{
|
|
if ($order->net_split && is_array($order->net_split)) {
|
|
return $order->net_split;
|
|
}
|
|
|
|
$collectOrderNetSplit = $order->shopping_collect_order?->net_split;
|
|
|
|
if ($collectOrderNetSplit && is_array($collectOrderNetSplit)) {
|
|
return $collectOrderNetSplit;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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, ?string $preferredArrayKey = null): float
|
|
{
|
|
if (is_array($value)) {
|
|
$arrayKeys = $this->getSplitArrayKeys($preferredArrayKey);
|
|
|
|
foreach ($arrayKeys as $arrayKey) {
|
|
if (array_key_exists($arrayKey, $value)) {
|
|
return $this->parseNumber($value[$arrayKey]);
|
|
}
|
|
}
|
|
|
|
return 0.0;
|
|
}
|
|
|
|
return floatval(str_replace(',', '.', $value));
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function getSplitArrayKeys(?string $preferredArrayKey): array
|
|
{
|
|
return match ($preferredArrayKey) {
|
|
'ek_tax' => ['ek_tax', 'vk_tax', 'ek_net', 'vk_net'],
|
|
'ek_net' => ['ek_net', 'vk_net', 'ek_tax', 'vk_tax'],
|
|
'vk_tax' => ['vk_tax', 'ek_tax', 'vk_net', 'ek_net'],
|
|
'vk_net' => ['vk_net', 'ek_net', 'vk_tax', 'ek_tax'],
|
|
default => ['vk_tax', 'vk_net', 'ek_tax', 'ek_net'],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
}
|
|
}
|