From 7cc3c7917443008b44b79047434666d8fcf90e32 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Tue, 13 Jan 2026 07:07:03 +0100 Subject: [PATCH] added stuff to manualinvoice --- .../ManualInvoice/ManualInvoiceController.php | 162 +++++++-- .../ManualInvoice/ManualInvoiceModel.php | 2 + ...000_add_lock_exported_to_manualinvoice.php | 39 +++ .../js/pages/ManualInvoice/ManualInvoice.js | 30 +- .../ManualInvoice/create-mock-invoices.php | 314 ++++++++++++++++++ 5 files changed, 512 insertions(+), 35 deletions(-) create mode 100644 db/migrations/20260113120000_add_lock_exported_to_manualinvoice.php create mode 100644 scripts/ManualInvoice/create-mock-invoices.php diff --git a/application/ManualInvoice/ManualInvoiceController.php b/application/ManualInvoice/ManualInvoiceController.php index caa91d659..f2225af5e 100644 --- a/application/ManualInvoice/ManualInvoiceController.php +++ b/application/ManualInvoice/ManualInvoiceController.php @@ -208,8 +208,6 @@ class ManualInvoiceController extends TTCrud $post = json_decode(file_get_contents('php://input'), true); $id = $post['id'] ?? null; $recipientEmail = $post['email'] ?? null; - $subject = $post['subject'] ?? 'Ihre Rechnung von XINON GmbH'; - $bodyText = $post['body'] ?? 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung.\n\nMit freundlichen Grüßen\nIhr Xinon Team'; if (!$id || !$recipientEmail) { self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']); @@ -222,6 +220,19 @@ class ManualInvoiceController extends TTCrud return; } + // Format invoice date for display + $invoiceDateFormatted = date('d.m.Y', $invoice->invoice_date); + + // Set default subject and body with invoice number and date + $defaultSubject = "Ihre Rechnung {$invoice->invoice_number} vom {$invoiceDateFormatted}"; + $defaultBody = "Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung Nr. {$invoice->invoice_number} vom {$invoiceDateFormatted}.\n\nMit freundlichen Grüßen\nIhr XINON Team"; + + $subject = $post['subject'] ?? $defaultSubject; + $bodyText = $post['body'] ?? $defaultBody; + + // Convert literal \n strings to actual newlines (in case frontend sends escaped strings) + $bodyText = str_replace('\n', "\n", $bodyText); + // Generate PDF $pdf_filename = $this->createPDFAction(true); if (!$pdf_filename || !file_exists($pdf_filename)) { @@ -232,19 +243,33 @@ class ManualInvoiceController extends TTCrud $pdfContent = file_get_contents($pdf_filename); // --- HTML Email Generation --- - $logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png'; $logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png'; - $logoToolExists = file_exists($logoToolPath); $logoXinonExists = file_exists($logoXinonPath); - // Construct HTML Body - $html = 'Rechnung'; - $html .= '
'; + // Construct HTML Body with Outlook compatibility + $html = ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= 'Rechnung'; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; - // Logos - $html .= '
'; - if ($logoToolExists) $html .= 'The Tool'; - if ($logoXinonExists) $html .= 'Xinon'; + // Outlook-safe container table + $html .= ''; + $html .= '
'; + + // Logo with Outlook-safe sizing + $html .= '
'; + if ($logoXinonExists) { + $html .= ''; + $html .= 'XINON GmbH'; + $html .= ''; + } $html .= '
'; $html .= '

' . htmlspecialchars($subject) . '

'; @@ -254,7 +279,9 @@ class ManualInvoiceController extends TTCrud $html .= '
'; $html .= 'XINON GmbH | www.xinon.at'; - $html .= '
'; + $html .= '
'; + $html .= ''; + $html .= ''; $mail = new PHPMailer(true); try { @@ -269,12 +296,11 @@ class ManualInvoiceController extends TTCrud $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = 587; - // Logos - if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool'); + // Logo embedding if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); $mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice'); - $mail->setFrom('thetool@xinon.at', 'XINON TheTool'); + $mail->setFrom('thetool@xinon.at', 'XINON GmbH - Rechnungswesen'); $customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname); $mail->addAddress($recipientEmail, $customerName); @@ -283,7 +309,10 @@ class ManualInvoiceController extends TTCrud $mail->Body = $html; $mail->AltBody = strip_tags($bodyText); - $mail->addStringAttachment($pdfContent, $invoice->invoice_number . '_Rechnung.pdf', 'base64', 'application/pdf'); + // Attachment filename: YYYY-MM-DD_InvoiceNumber_Rechnung.pdf + $invoiceDateFile = date('Y-m-d', $invoice->invoice_date); + $attachmentFilename = "{$invoiceDateFile}_{$invoice->invoice_number}_Rechnung.pdf"; + $mail->addStringAttachment($pdfContent, $attachmentFilename, 'base64', 'application/pdf'); $mail->send(); @@ -349,20 +378,21 @@ class ManualInvoiceController extends TTCrud $data['invoice_date'] = strtotime($data['invoice_date']); } - $data = array_merge([ - 'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(), - 'invoice_date' => $data['invoice_date'] ?? time(), - 'status' => 'erstellt', - 'fibu_payment_skonto' => 0, - 'fibu_payment_skonto_rate' => 0, - 'gesamtrabatt' => 0, - 'total' => 0, - 'total_gross' => 0, - 'create_by' => $me->id, - 'edit_by' => $me->id, - 'create' => time(), - 'edit' => time() - ], $data); + // Always generate invoice number (override any null from frontend) + $data['invoice_number'] = ManualInvoiceModel::getNextInvoiceNumber(); + $data['invoice_date'] = $data['invoice_date'] ?? time(); + $data['status'] = 'erstellt'; + $data['fibu_payment_skonto'] = $data['fibu_payment_skonto'] ?? 0; + $data['fibu_payment_skonto_rate'] = $data['fibu_payment_skonto_rate'] ?? 0; + $data['gesamtrabatt'] = $data['gesamtrabatt'] ?? 0; + $data['total'] = $data['total'] ?? 0; + $data['total_gross'] = $data['total_gross'] ?? 0; + $data['lock'] = 0; + $data['exported'] = 0; + $data['create_by'] = $me->id; + $data['edit_by'] = $me->id; + $data['create'] = time(); + $data['edit'] = time(); return true; } @@ -389,9 +419,15 @@ class ManualInvoiceController extends TTCrud unset($data['positions']); } - if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exportiert') { - $this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden'; - return false; + if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id']))) { + if ($invoice->lock == 1) { + $this->infoMessages['update'] = 'Rechnung ist gesperrt und kann nicht bearbeitet werden'; + return false; + } + if ($invoice->status === 'exportiert') { + $this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden'; + return false; + } } // Convert invoice_date from string to timestamp if needed @@ -626,6 +662,12 @@ class ManualInvoiceController extends TTCrud if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) { self::returnJson(['success' => false, 'message' => 'Ungültige Anfrage']); + return; + } + + if ($originalInvoice->lock == 1) { + self::returnJson(['success' => false, 'message' => 'Originalrechnung ist gesperrt und kann nicht gutgeschrieben werden']); + return; } $me = new User(); @@ -673,6 +715,8 @@ class ManualInvoiceController extends TTCrud 'vatgroup_id' => $originalInvoice->vatgroup_id, 'credit_for_invoice_id' => $originalInvoiceId, 'status' => 'erstellt', + 'lock' => 0, + 'exported' => 0, 'create' => time(), 'edit' => time(), 'create_by' => $me->id, @@ -681,6 +725,7 @@ class ManualInvoiceController extends TTCrud if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) { self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']); + return; } foreach ($positions as $pos) { @@ -718,7 +763,11 @@ class ManualInvoiceController extends TTCrud protected function beforeDelete(): bool { if ($id = $this->request->id) { $invoice = ManualInvoiceModel::get($id); - if ($invoice && $invoice->status === 'exported') { + if ($invoice && $invoice->lock == 1) { + $this->infoMessages['delete'] = 'Rechnung ist gesperrt und kann nicht gelöscht werden'; + return false; + } + if ($invoice && ($invoice->status === 'exported' || $invoice->status === 'exportiert')) { $this->infoMessages['delete'] = 'Rechnung wurde bereits exportiert und kann nicht gelöscht werden'; return false; } @@ -732,4 +781,49 @@ class ManualInvoiceController extends TTCrud } return true; } + + protected function getArticleVatInfoAction() { + $articleId = $_GET['article_id'] ?? null; + $vatarea = $_GET['vatarea'] ?? 'domestic'; + + if (!$articleId) { + self::returnJson(['success' => false, 'message' => 'Article ID required']); + return; + } + + $article = WarehouseArticleModel::get($articleId); + if (!$article) { + self::returnJson(['success' => false, 'message' => 'Article not found']); + return; + } + + // Map revenueAccount to vatgroup_id + // revenueAccount 0 = Dienstleistungen = vatgroup_id 2 + // revenueAccount 1 = Handelswaren = vatgroup_id 3 + $vatgroupId = $article->revenueAccount == 0 ? 2 : 3; + + // Get vatrate for this vatgroup and area + $vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]); + + if (!$vatrate) { + self::returnJson(['success' => false, 'message' => 'Vatrate not found for vatgroup ' . $vatgroupId . ' and area ' . $vatarea]); + return; + } + + self::returnJson([ + 'success' => true, + 'article' => [ + 'id' => $article->id, + 'title' => $article->title, + 'articleNumber' => $article->articleNumber, + 'description' => $article->description, + 'revenueAccount' => $article->revenueAccount + ], + 'vatgroup_id' => $vatgroupId, + 'fibu_cost_account' => $vatrate->account, + 'fibu_cost_account_legacy' => $vatrate->legacy_account, + 'fibu_taxcode' => $vatrate->taxcode, + 'vatrate' => $vatrate->rate + ]); + } } \ No newline at end of file diff --git a/application/ManualInvoice/ManualInvoiceModel.php b/application/ManualInvoice/ManualInvoiceModel.php index 77408527e..7c6d82560 100644 --- a/application/ManualInvoice/ManualInvoiceModel.php +++ b/application/ManualInvoice/ManualInvoiceModel.php @@ -44,6 +44,8 @@ class ManualInvoiceModel extends TTCrudBaseModel { public ?int $bmd_export_date; public ?int $date_delivered; public string $status; + public int $lock = 0; + public int $exported = 0; public ?int $credit_for_invoice_id; public int $create_by; public int $edit_by; diff --git a/db/migrations/20260113120000_add_lock_exported_to_manualinvoice.php b/db/migrations/20260113120000_add_lock_exported_to_manualinvoice.php new file mode 100644 index 000000000..c2a09bfe7 --- /dev/null +++ b/db/migrations/20260113120000_add_lock_exported_to_manualinvoice.php @@ -0,0 +1,39 @@ +getEnvironment() == "thetool") { + $table = $this->table("ManualInvoice"); + + $table->addColumn("lock", "integer", [ + "null" => false, + "default" => 0, + "limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, + "after" => "status" + ]); + + $table->addColumn("exported", "integer", [ + "null" => false, + "default" => 0, + "limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, + "after" => "lock" + ]); + + $table->save(); + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $table = $this->table("ManualInvoice"); + $table->removeColumn("lock")->save(); + $table->removeColumn("exported")->save(); + } + } +} diff --git a/public/js/pages/ManualInvoice/ManualInvoice.js b/public/js/pages/ManualInvoice/ManualInvoice.js index 4ce7a72cd..a8699e002 100644 --- a/public/js/pages/ManualInvoice/ManualInvoice.js +++ b/public/js/pages/ManualInvoice/ManualInvoice.js @@ -170,7 +170,7 @@ Vue.component('manual-invoice-modal', { - + @@ -208,6 +208,12 @@ Vue.component('manual-invoice-modal', { billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}], positionsConfig: { fields: { + article_id: { + type: 'autocomplete', + label: 'Artikel (optional)', + apiUrl: '/WarehouseArticle/autocomplete', + customFieldReference: 'WarehouseArticle' + }, product_name: { type: 'input', label: 'Bezeichnung' }, product_info: { type: 'input', label: 'Zusatzinfo' }, amount: { type: 'input', label: 'Menge', inputType: 'number' }, @@ -330,6 +336,28 @@ Vue.component('manual-invoice-modal', { if (e.ctrlKey && e.key === 'q') { e.preventDefault(); this.togglePreviewVisibility(); } }, togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; }, + async onArticleSelected(articleId) { + if (!articleId) return; + try { + const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getArticleVatInfo?article_id=${articleId}`); + if (data.success && this.$refs.positionsManager) { + // Update the formData in the positions manager + const pm = this.$refs.positionsManager; + if (data.article) { + pm.$set(pm.formData, 'product_name', data.article.title); + pm.$set(pm.formData, 'product_info', data.article.description || ''); + } + pm.$set(pm.formData, 'vatrate', parseFloat(data.vatrate) || 20); + pm.$set(pm.formData, 'fibu_cost_account', data.fibu_cost_account); + pm.$set(pm.formData, 'fibu_cost_account_legacy', data.fibu_cost_account_legacy); + pm.$set(pm.formData, 'fibu_taxcode', data.fibu_taxcode); + // Store vatgroup_id on invoice level if needed + this.invoiceData.vatgroup_id = data.vatgroup_id; + } + } catch (e) { + console.error('Error fetching article VAT info:', e); + } + }, debouncedPreviewUpdate() { clearTimeout(this.previewDebounceTimer); this.previewDebounceTimer = setTimeout(() => this.updatePdfPreview(), 2000); diff --git a/scripts/ManualInvoice/create-mock-invoices.php b/scripts/ManualInvoice/create-mock-invoices.php new file mode 100644 index 000000000..57cc5835e --- /dev/null +++ b/scripts/ManualInvoice/create-mock-invoices.php @@ -0,0 +1,314 @@ +#!/usr/bin/php +id); +define("INTERNAL_USER_USERNAME", $me->username); + +echo "========================================\n"; +echo "Creating 20 mock manual invoices...\n"; +echo "========================================\n\n"; + +$db = FronkDB::singleton(); + +// Get random customers with valid data for invoicing +$customerSql = "SELECT * FROM Address + WHERE customer_number IS NOT NULL + AND customer_number > 0 + AND (company IS NOT NULL OR firstname IS NOT NULL) + AND street IS NOT NULL + AND zip IS NOT NULL + AND city IS NOT NULL + ORDER BY RAND() + LIMIT 50"; +$customerRes = $db->query($customerSql); +$customers = []; +while ($row = $db->fetch_object($customerRes)) { + $customers[] = $row; +} + +if (empty($customers)) { + echo "ERROR: No valid customers found in database!\n"; + exit(1); +} + +echo "Found " . count($customers) . " random customers to use.\n\n"; + +// Get last 20 shipping notes for position data +$sql = "SELECT * FROM WarehouseShippingNote ORDER BY `create` DESC LIMIT 20"; +$res = $db->query($sql); + +$shippingNotes = []; +while ($row = $db->fetch_object($res)) { + $shippingNotes[] = new WarehouseShippingNote($row); +} + +$count = 0; +$errors = 0; + +foreach ($shippingNotes as $index => $shippingNote) { + echo "Processing shipping note #{$shippingNote->id}...\n"; + + // Pick a random customer + $customer = $customers[array_rand($customers)]; + $address = new Address($customer->id); + + if (!$address || !$address->id) { + echo " - Skipping: Could not load customer address\n"; + $errors++; + continue; + } + + // Build positions from shipping note + $positions = json_decode($shippingNote->positions, true) ?: []; + $enrichedPositions = []; + + foreach ($positions as $position) { + if (isset($position['article'])) { + $article = WarehouseArticleModel::get($position['article']); + if (!$article) continue; + + $prices = json_decode($article->cheapestSellPrice, true) ?: []; + $price = 0; + foreach ($prices as $p) { + if (isset($p['price'])) { + $price = $p['price']; + break; + } + } + // Use random price if no price found + if ($price == 0) { + $price = rand(10, 500); + } + + $enrichedPositions[] = [ + 'product_name' => $article->articleNumber . " | " . $article->title, + 'product_info' => $article->description ?: '', + 'amount' => $position['amount'] ?: 1, + 'unit' => $article->unit ?: 'Stk.', + 'price' => $price, + 'discount' => 0, + 'vatrate' => 20, + 'article_id' => $article->id + ]; + } elseif (isset($position['articlePacket'])) { + $packet = WarehouseArticlePacketModel::get($position['articlePacket']); + if (!$packet) continue; + + $enrichedPositions[] = [ + 'product_name' => $packet->title, + 'product_info' => $packet->description ?? '', + 'amount' => $position['amount'] ?: 1, + 'unit' => 'Pau.', + 'price' => rand(50, 300), + 'discount' => 0, + 'vatrate' => 20 + ]; + } elseif (isset($position['articleText'])) { + $enrichedPositions[] = [ + 'product_name' => $position['articleText'], + 'product_info' => '', + 'amount' => $position['amount'] ?? 1, + 'unit' => 'Stk.', + 'price' => rand(10, 100), + 'discount' => 0, + 'vatrate' => 20 + ]; + } + } + + // Add hours entries + $hoursEntries = json_decode($shippingNote->hoursEntries, true) ?: []; + foreach ($hoursEntries as $hoursEntry) { + $hourCount = floatval(str_replace(",", ".", $hoursEntry['hourCount'] ?? 0)); + if ($hourCount <= 0) continue; + + $userName = 'Mitarbeiter'; + if (!empty($hoursEntry['userId']) && is_numeric($hoursEntry['userId'])) { + $user = UserModel::getOne($hoursEntry['userId']); + $userName = $user ? $user->name : 'Mitarbeiter'; + } elseif (!empty($hoursEntry['userId_text'])) { + $userName = $hoursEntry['userId_text']; + } + + $enrichedPositions[] = [ + 'product_name' => 'Arbeitsstunden - ' . $userName, + 'product_info' => 'Datum: ' . (isset($hoursEntry['date']) ? date('d.m.Y', strtotime($hoursEntry['date'])) : date('d.m.Y')), + 'amount' => $hourCount, + 'unit' => 'h', + 'price' => 60, + 'discount' => 0, + 'vatrate' => 20 + ]; + } + + // If still no positions, create some mock positions + if (empty($enrichedPositions)) { + $mockPositions = [ + ['name' => 'Beratungsleistung', 'unit' => 'h', 'price' => 85], + ['name' => 'Installationsarbeiten', 'unit' => 'Pau.', 'price' => 250], + ['name' => 'Netzwerkkabel Cat6', 'unit' => 'm', 'price' => 3.50], + ['name' => 'Router TP-Link', 'unit' => 'Stk.', 'price' => 89.90], + ['name' => 'Montage vor Ort', 'unit' => 'h', 'price' => 65], + ]; + + // Add 1-3 random mock positions + $numPositions = rand(1, 3); + for ($i = 0; $i < $numPositions; $i++) { + $mock = $mockPositions[array_rand($mockPositions)]; + $enrichedPositions[] = [ + 'product_name' => $mock['name'], + 'product_info' => 'Mock-Position für Testzwecke', + 'amount' => rand(1, 10), + 'unit' => $mock['unit'], + 'price' => $mock['price'], + 'discount' => rand(0, 1) ? rand(5, 15) : 0, + 'vatrate' => 20 + ]; + } + } + + // Use random invoice date within last 90 days + $randomDaysAgo = rand(0, 90); + $invoiceDate = strtotime("-{$randomDaysAgo} days"); + + // Create invoice data + $invoiceData = [ + 'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(), + 'invoice_date' => $invoiceDate, + 'owner_id' => $address->id, + 'billingaddress_id' => $address->id, + 'customer_number' => $address->customer_number, + 'company' => $address->company, + 'firstname' => $address->firstname, + 'lastname' => $address->lastname, + 'street' => $address->street, + 'zip' => $address->zip, + 'city' => $address->city, + 'country' => $address->country ?: 'Österreich', + 'email' => $address->email, + 'uid' => $address->uid, + 'fibu_account_number' => $address->fibu_account_number, + 'fibu_payment_due' => $address->fibu_payment_due ?: 14, + 'fibu_payment_skonto' => $address->fibu_payment_skonto ?: 0, + 'fibu_payment_skonto_rate' => $address->fibu_payment_skonto_rate ?: 0, + 'billing_type' => $address->billing_type ?: 'invoice', + 'billing_delivery' => $address->billing_delivery ?: 'email', + 'bank_account_bank' => $address->bank_account_bank, + 'bank_account_owner' => $address->bank_account_owner, + 'bank_account_iban' => $address->bank_account_iban, + 'bank_account_bic' => $address->bank_account_bic, + 'sepa_date' => $address->sepa_date ? (is_numeric($address->sepa_date) ? date('Y-m-d', $address->sepa_date) : $address->sepa_date) : null, + 'leistungszeitraum' => date('m/Y', $invoiceDate), + 'einleitender_text' => 'Testrechnung basierend auf Lieferschein #' . $shippingNote->id, + 'externe_referenz' => 'TEST-LS-' . $shippingNote->id, + 'gesamtrabatt' => rand(0, 1) ? rand(0, 10) : 0, + 'total' => 0, + 'total_gross' => 0, + 'vatgroup_id' => rand(1, 3), + 'status' => 'erstellt', + 'lock' => 0, + 'exported' => 0, + 'create_by' => 1, + 'edit_by' => 1, + 'create' => time(), + 'edit' => time() + ]; + + // Create the invoice + $invoiceId = ManualInvoiceModel::create($invoiceData); + + if (!$invoiceId) { + echo " - Error creating invoice\n"; + $errors++; + continue; + } + + // Create positions + $total = 0; + $totalGross = 0; + $gesamtrabatt = floatval($invoiceData['gesamtrabatt']); + + foreach ($enrichedPositions as $pos) { + $amount = floatval($pos['amount']); + $price = floatval($pos['price']); + $discount = floatval($pos['discount'] ?? 0); + $vatrate = floatval($pos['vatrate'] ?? 20); + + // Validate amount is within reasonable bounds + if ($amount <= 0 || $amount > 999999) { + $amount = 1; + } + if ($price < 0 || $price > 999999) { + $price = 0; + } + + $priceTotal = $amount * $price * (1 - $discount / 100); + $priceTotalAfterGesamtrabatt = $priceTotal * (1 - $gesamtrabatt / 100); + $priceGross = $priceTotalAfterGesamtrabatt * (1 + $vatrate / 100); + + // Use direct SQL to bypass model validation for mock data + $posProduct = $db->escape($pos['product_name']); + $posInfo = $db->escape($pos['product_info'] ?? ''); + $posProductId = intval($pos['article_id'] ?? 0); + $posUnit = $db->escape($pos['unit'] ?? 'Stk.'); + $posTime = time(); + + // Ensure values are numeric and within DB limits + $amount = round($amount, 2); + $price = round($price, 2); + $priceTotal = round($priceTotal, 2); + $priceGross = round($priceGross, 2); + + $insertSql = "INSERT INTO ManualInvoiceposition + (manualinvoice_id, position_group, product_id, product_name, product_info, amount, unit, price, discount, vatrate, price_total, price_gross, matchcode, fibu_cost_account, fibu_cost_account_legacy, fibu_taxcode, contract_id, billing_id, create_by, edit_by, `create`, edit) + VALUES + ($invoiceId, NULL, $posProductId, '$posProduct', '$posInfo', $amount, '$posUnit', $price, $discount, $vatrate, $priceTotal, $priceGross, NULL, NULL, NULL, NULL, 0, NULL, 1, 1, $posTime, $posTime)"; + + try { + $db->query($insertSql); + } catch (Throwable $e) { + echo " Warning: Position skipped (amount=$amount, price=$price): " . $e->getMessage() . "\n"; + continue; + } + + $total += $priceTotal; + $totalGross += $priceGross; + } + + // Apply gesamtrabatt to total + $totalAfterRabatt = $total * (1 - $gesamtrabatt / 100); + + // Update invoice totals using direct SQL (bypass model validation) + $db->query("UPDATE ManualInvoice SET total = " . floatval($totalAfterRabatt) . ", total_gross = " . floatval($totalGross) . " WHERE id = " . intval($invoiceId)); + + // Create journal entry using direct SQL + $journalText = $db->escape('Mock-Rechnung erstellt (basierend auf LS #' . $shippingNote->id . ')'); + $journalTime = time(); + $db->query("INSERT INTO ManualInvoiceJournal (manualinvoiceId, text, statusChange, createBy, `create`) + VALUES ($invoiceId, '$journalText', 'erstellt', 1, $journalTime)"); + + $invoiceNumber = $invoiceData['invoice_number']; + $customerName = trim(($address->company ?: '') . ' ' . $address->firstname . ' ' . $address->lastname); + echo " - Created invoice #{$invoiceId} ({$invoiceNumber})\n"; + echo " Customer: {$customerName}\n"; + echo " Positions: " . count($enrichedPositions) . ", Total: €" . number_format($totalAfterRabatt, 2) . "\n"; + $count++; +} + +echo "\n========================================\n"; +echo "Mock invoice creation complete!\n"; +echo "Created: {$count} invoices\n"; +echo "Errors/Skipped: {$errors}\n"; +echo "========================================\n"; +echo "\nNOTE: No emails were sent. These are test invoices only.\n";