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 */ 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'; } }