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 .= '

';
- if ($logoXinonExists) $html .= '

';
+ // Outlook-safe container table
+ $html .= '';
+ $html .= '
';
+
+ // Logo with Outlook-safe sizing
+ $html .= '
';
+ if ($logoXinonExists) {
+ $html .= '';
+ $html .= '

';
+ $html .= '';
+ }
$html .= '
';
$html .= '
' . htmlspecialchars($subject) . '
';
@@ -254,7 +279,9 @@ class ManualInvoiceController extends TTCrud
$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', {
Positionen
-
+
Texte & Rabatt
@@ -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";