db = $db; $this->parser = new ZugferdParser($db); $this->import = new ZugferdImport($db); $this->mapping = new ProductMapping($db); } /** * Process a ZUGFeRD PDF file * * @param string $pdf_path Path to PDF file * @param User $user Current user * @param bool $create_invoice Whether to create supplier invoice * @param bool $force_reimport Whether to bypass duplicate check * @return int <0 if KO, >0 if OK (import record ID) */ public function processPdf($pdf_path, $user, $create_invoice = false, $force_reimport = false) { global $conf; $this->result = array( 'import_id' => 0, 'invoice_id' => 0, 'supplier_id' => 0, 'supplier_found' => false, 'is_duplicate' => false, 'lines' => array(), 'warnings' => array(), ); // Extract XML from PDF $res = $this->parser->extractFromPdf($pdf_path); if ($res < 0) { $this->error = $this->parser->error; return -1; } // Parse XML $res = $this->parser->parse(); if ($res < 0) { $this->error = $this->parser->error; return -2; } $invoice_data = $this->parser->getInvoiceData(); // Check for duplicates $file_hash = $this->parser->getFileHash($pdf_path); if ($this->import->isDuplicate($file_hash)) { if ($force_reimport) { // Delete existing import record to allow reimport $this->deleteExistingImport($file_hash, $user); } else { global $langs; $langs->load('importzugferd@importzugferd'); $this->result['is_duplicate'] = true; $this->error = $langs->trans('ErrorDuplicateInvoice'); return -3; } } // Find supplier $supplier_id = $this->findSupplier($invoice_data); $this->result['supplier_id'] = $supplier_id; $this->result['supplier_found'] = ($supplier_id > 0); // Create import record $this->import->invoice_number = $invoice_data['invoice_number']; $this->import->invoice_date = $invoice_data['invoice_date']; $this->import->seller_name = $invoice_data['seller']['name']; $this->import->seller_vat = $invoice_data['seller']['vat_id']; $this->import->buyer_reference = $invoice_data['buyer']['reference'] ?: $invoice_data['buyer']['id']; $this->import->total_ht = $invoice_data['totals']['net']; $this->import->total_ttc = $invoice_data['totals']['gross']; $this->import->currency = $invoice_data['totals']['currency'] ?: 'EUR'; $this->import->fk_soc = $supplier_id; $this->import->xml_content = $this->parser->getXmlContent(); $this->import->pdf_filename = basename($pdf_path); $this->import->file_hash = $file_hash; $this->import->status = ZugferdImport::STATUS_IMPORTED; $this->import->date_import = dol_now(); $import_id = $this->import->create($user); if ($import_id < 0) { $this->error = $this->import->error; return -4; } $this->result['import_id'] = $import_id; // Process line items $this->result['lines'] = $this->processLineItems($invoice_data['lines'], $supplier_id); // Copy PDF to documents folder $this->copyToDocuments($pdf_path, $import_id); // Create supplier invoice if requested if ($create_invoice && $supplier_id > 0) { $invoice_id = $this->createSupplierInvoice($invoice_data, $supplier_id, $user, $pdf_path); if ($invoice_id > 0) { $this->result['invoice_id'] = $invoice_id; $this->import->fk_facture_fourn = $invoice_id; // Check validation result - status may have been set to ERROR in validateTotals() if ($this->import->status != ZugferdImport::STATUS_ERROR) { $this->import->status = ZugferdImport::STATUS_PROCESSED; } $this->import->update($user); // Add validation warning if there was a sum mismatch if (!empty($this->result['validation']) && !$this->result['validation']['valid']) { $this->result['warnings'][] = $this->result['validation']['message']; } } else { $this->result['warnings'][] = 'Could not create supplier invoice: ' . $this->error; } } return $import_id; } /** * Find supplier by buyer reference (customer number) * * @param array $invoice_data Parsed invoice data * @return int Supplier ID or 0 */ public function findSupplier($invoice_data) { global $conf; $buyer_ref = $invoice_data['buyer']['reference'] ?: $invoice_data['buyer']['id']; $seller_vat = $invoice_data['seller']['vat_id']; $seller_name = $invoice_data['seller']['name']; // 1. Search by buyer reference in extrafield if (!empty($buyer_ref)) { $sql = "SELECT fk_object FROM " . MAIN_DB_PREFIX . "societe_extrafields"; $sql .= " WHERE supplier_customer_number = '" . $this->db->escape($buyer_ref) . "'"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); return (int) $obj->fk_object; } } // 2. Search by VAT ID if (!empty($seller_vat)) { $sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "societe"; $sql .= " WHERE tva_intra = '" . $this->db->escape($seller_vat) . "'"; $sql .= " AND fournisseur = 1"; $sql .= " AND entity IN (" . getEntity('societe') . ")"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); return (int) $obj->rowid; } } // 3. Search by name (fuzzy) if (!empty($seller_name)) { $sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "societe"; $sql .= " WHERE (nom LIKE '" . $this->db->escape($seller_name) . "%'"; $sql .= " OR nom LIKE '%" . $this->db->escape(substr($seller_name, 0, 20)) . "%')"; $sql .= " AND fournisseur = 1"; $sql .= " AND entity IN (" . getEntity('societe') . ")"; $sql .= " LIMIT 1"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); return (int) $obj->rowid; } } return 0; } /** * Process line items and find matching products * * @param array $lines Line items from invoice * @param int $supplier_id Supplier ID * @return array Processed lines with product info */ public function processLineItems($lines, $supplier_id) { $processed = array(); foreach ($lines as $line) { $processed_line = array( 'line_id' => $line['line_id'], 'supplier_ref' => $line['product']['seller_id'], 'ean' => $line['product']['global_id'], 'name' => $line['product']['name'], 'description' => $line['product']['description'], 'quantity' => $line['quantity'], 'unit_code' => $line['unit_code'], 'unit_price' => $line['unit_price'], 'unit_price_raw' => isset($line['unit_price_raw']) ? $line['unit_price_raw'] : $line['unit_price'], 'basis_quantity' => isset($line['basis_quantity']) ? $line['basis_quantity'] : 1, 'basis_quantity_unit' => isset($line['basis_quantity_unit']) ? $line['basis_quantity_unit'] : '', 'line_total' => $line['line_total'], 'tax_percent' => $line['tax_percent'], 'fk_product' => 0, 'product_ref' => '', 'product_label' => '', 'match_method' => '', 'needs_creation' => false, ); // Try to find product if ($supplier_id > 0) { $match = $this->mapping->findProduct($supplier_id, $line['product']); if ($match['fk_product'] > 0) { $processed_line['fk_product'] = $match['fk_product']; $processed_line['match_method'] = $match['method']; // Get product info $product = new Product($this->db); if ($product->fetch($match['fk_product']) > 0) { $processed_line['product_ref'] = $product->ref; $processed_line['product_label'] = $product->label; } } else { $processed_line['needs_creation'] = true; } } else { $processed_line['needs_creation'] = true; } $processed[] = $processed_line; } return $processed; } /** * Create supplier invoice from parsed data * * @param array $invoice_data Parsed invoice data * @param int $supplier_id Supplier ID * @param User $user Current user * @param string $pdf_path Path to source PDF file (optional) * @return int Invoice ID or <0 if error */ public function createSupplierInvoice($invoice_data, $supplier_id, $user, $pdf_path = '') { global $conf, $langs; $invoice = new FactureFournisseur($this->db); $invoice->socid = $supplier_id; $invoice->ref_supplier = $invoice_data['invoice_number']; $invoice->date = strtotime($invoice_data['invoice_date']); $invoice->date_echeance = !empty($invoice_data['due_date']) ? strtotime($invoice_data['due_date']) : null; $invoice->note_private = $langs->trans('ImportedFromZugferd') . ' - ' . $this->import->ref; $invoice->multicurrency_code = $invoice_data['totals']['currency'] ?: 'EUR'; $this->db->begin(); $invoice_id = $invoice->create($user); if ($invoice_id < 0) { $this->error = $invoice->error; $this->db->rollback(); return -1; } // Add lines foreach ($this->result['lines'] as $line) { $result = $this->addInvoiceLine($invoice, $line, $user); if ($result < 0) { $this->db->rollback(); return -2; } } $this->db->commit(); // Validate totals - re-fetch invoice to get calculated totals $invoice->fetch($invoice_id); $validation_result = $this->validateTotals($invoice_data, $invoice); $this->result['validation'] = $validation_result; // Attach PDF to supplier invoice if (!empty($pdf_path) && file_exists($pdf_path)) { $this->attachPdfToInvoice($invoice, $pdf_path); } return $invoice_id; } /** * Attach PDF file to supplier invoice * * @param FactureFournisseur $invoice Invoice object * @param string $pdf_path Source PDF path * @return bool Success */ public function attachPdfToInvoice($invoice, $pdf_path) { global $conf; // Get supplier for folder name $supplier = new Societe($this->db); $supplier->fetch($invoice->socid); // Build destination directory path for supplier invoice // Format: DOL_DATA_ROOT/fournisseur/facture/[thirdparty_name]/[invoice_ref]/ $destdir = $conf->fournisseur->facture->dir_output; $destdir .= '/' . dol_sanitizeFileName($supplier->nom); $destdir .= '/' . dol_sanitizeFileName($invoice->ref); // Create directory if it doesn't exist if (!is_dir($destdir)) { dol_mkdir($destdir); } // Build descriptive filename // Format: YYYY-MM-DD - Lieferant - Rechnungsnummer - Material - Preis EUR.pdf $newFilename = $this->buildInvoiceFilename($invoice, $supplier); $destfile = $destdir . '/' . $newFilename; if (copy($pdf_path, $destfile)) { dol_syslog("Attached PDF as " . $newFilename . " to supplier invoice " . $invoice->ref, LOG_INFO); return true; } return false; } /** * Build descriptive filename for invoice PDF * Format: YYYY-MM-DD - Lieferant - Rechnungsnummer - Material - Preis EUR.pdf * * @param FactureFournisseur $invoice Invoice object * @param Societe $supplier Supplier object * @return string Filename */ private function buildInvoiceFilename($invoice, $supplier) { // Date: YYYY-MM-DD $date = dol_print_date($invoice->date, '%Y-%m-%d'); // Supplier name (shortened if too long) $supplierName = dol_sanitizeFileName($supplier->nom); if (strlen($supplierName) > 30) { $supplierName = substr($supplierName, 0, 30); } // Invoice number from supplier $invoiceNumber = dol_sanitizeFileName($invoice->ref_supplier); if (empty($invoiceNumber)) { $invoiceNumber = $invoice->ref; } // Get material description from first line item or use generic term $material = 'Material'; if (!empty($this->result['lines'])) { // Try to get a meaningful description from line items $firstLine = reset($this->result['lines']); if (!empty($firstLine['name'])) { // Use first product name, shortened $material = dol_sanitizeFileName($firstLine['name']); if (strlen($material) > 25) { $material = substr($material, 0, 25); } } // If multiple lines, indicate it if (count($this->result['lines']) > 1) { $material .= ' ua'; // "und andere" / "and others" } } // Price rounded $price = round($invoice->total_ttc); // Build filename $filename = sprintf( '%s - %s - %s - %s - %d EUR.pdf', $date, $supplierName, $invoiceNumber, $material, $price ); // Clean up any double spaces or invalid characters $filename = preg_replace('/\s+/', ' ', $filename); $filename = str_replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], '-', $filename); return $filename; } /** * Validate that ZUGFeRD totals match Dolibarr calculated totals * * @param array $invoice_data Parsed ZUGFeRD invoice data * @param FactureFournisseur $invoice Created Dolibarr invoice * @return array Validation result with status and message */ public function validateTotals($invoice_data, $invoice) { global $langs; $langs->load('importzugferd@importzugferd'); $result = array( 'valid' => true, 'zugferd_ht' => (float) $invoice_data['totals']['net'], 'zugferd_ttc' => (float) $invoice_data['totals']['gross'], 'dolibarr_ht' => (float) $invoice->total_ht, 'dolibarr_ttc' => (float) $invoice->total_ttc, 'diff_ht' => 0, 'diff_ttc' => 0, 'message' => '', ); $result['diff_ht'] = abs($result['zugferd_ht'] - $result['dolibarr_ht']); $result['diff_ttc'] = abs($result['zugferd_ttc'] - $result['dolibarr_ttc']); // Allow small deviations (max 0.05€ per total) $tolerance = 0.05; if ($result['diff_ht'] > $tolerance || $result['diff_ttc'] > $tolerance) { $result['valid'] = false; $result['message'] = $langs->trans( 'SumValidationError', price($result['zugferd_ttc']), price($result['dolibarr_ttc']), price($result['diff_ttc']) ); // Update import record with error $this->import->status = ZugferdImport::STATUS_ERROR; $this->import->error_message = $result['message']; } else { $result['message'] = $langs->trans('SumValidationOk'); // Keep status as PROCESSED (already set) } return $result; } /** * Add a line to supplier invoice * * @param FactureFournisseur $invoice Invoice object * @param array $line Line data * @param User $user Current user * @return int >0 if OK, <0 if error */ private function addInvoiceLine($invoice, $line, $user) { $desc = $line['name']; if (!empty($line['description']) && $line['description'] != $line['name']) { $desc .= "\n" . $line['description']; } // Add supplier reference to description if no product found if ($line['fk_product'] == 0 && !empty($line['supplier_ref'])) { $desc .= "\n[" . $line['supplier_ref'] . "]"; } // Determine VAT rate $tva_tx = $line['tax_percent'] ?: 19; // Add line $result = $invoice->addline( $desc, // description $line['unit_price'], // pu_ht $tva_tx, // tva_tx 0, // localtax1_tx 0, // localtax2_tx $line['quantity'], // qty $line['fk_product'] ?: 0, // fk_product 0, // remise_percent '', // date_start '', // date_end 0, // ventil 0, // info_bits 'HT', // price_base_type 0, // type (0=product, 1=service) -1, // rang 0, // notrigger array(), // array_options '', // fk_unit 0, // origin_id 0, // pu_ht_devise $line['supplier_ref'] ?: '' // ref_supplier ); if ($result < 0) { $this->error = $invoice->error; return -1; } // Update supplier price with EAN if product was matched and EAN is available if ($line['fk_product'] > 0 && !empty($line['ean'])) { $this->updateSupplierPriceBarcode($invoice->socid, $line['fk_product'], $line['ean'], $line['supplier_ref']); } return 1; } /** * Update barcode in supplier price record * * @param int $supplier_id Supplier ID * @param int $product_id Product ID * @param string $barcode EAN/GTIN barcode * @param string $ref_fourn Supplier reference (optional, to identify correct price record) * @return int >0 if updated, 0 if no update needed, <0 if error */ public function updateSupplierPriceBarcode($supplier_id, $product_id, $barcode, $ref_fourn = '') { global $conf; // Check if barcode column exists in product_fournisseur_price table if (!$this->checkSupplierPriceBarcodeColumn()) { return 0; // Column doesn't exist, skip update } // Find supplier price record $sql = "SELECT rowid, barcode FROM " . MAIN_DB_PREFIX . "product_fournisseur_price"; $sql .= " WHERE fk_soc = " . (int) $supplier_id; $sql .= " AND fk_product = " . (int) $product_id; $sql .= " AND entity IN (" . getEntity('product') . ")"; if (!empty($ref_fourn)) { $sql .= " AND ref_fourn = '" . $this->db->escape($ref_fourn) . "'"; } $sql .= " ORDER BY rowid DESC LIMIT 1"; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { $obj = $this->db->fetch_object($resql); // Only update if barcode is empty or different if (empty($obj->barcode) || $obj->barcode != $barcode) { $sql_update = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price"; $sql_update .= " SET barcode = '" . $this->db->escape($barcode) . "'"; $sql_update .= " WHERE rowid = " . (int) $obj->rowid; $res = $this->db->query($sql_update); if ($res) { dol_syslog("Updated supplier price barcode for product " . $product_id . " supplier " . $supplier_id . " to " . $barcode, LOG_DEBUG); return 1; } else { return -1; } } return 0; // No update needed } return 0; // No supplier price record found } /** * Check if barcode column exists in product_fournisseur_price table * * @return bool */ private function checkSupplierPriceBarcodeColumn() { static $has_barcode_column = null; if ($has_barcode_column === null) { $sql = "SHOW COLUMNS FROM " . MAIN_DB_PREFIX . "product_fournisseur_price LIKE 'barcode'"; $resql = $this->db->query($sql); $has_barcode_column = ($resql && $this->db->num_rows($resql) > 0); } return $has_barcode_column; } /** * Delete existing import record by file hash (for reimport) * * @param string $file_hash File hash * @param User $user Current user * @return int >0 if deleted, 0 if not found, <0 if error */ public function deleteExistingImport($file_hash, $user) { global $conf; // Find existing import by hash $existingImport = new ZugferdImport($this->db); $result = $existingImport->fetch(0, null, $file_hash); if ($result > 0) { // Delete the existing import record $deleteResult = $existingImport->delete($user); if ($deleteResult > 0) { dol_syslog("Deleted existing import record " . $existingImport->ref . " for reimport", LOG_INFO); return 1; } else { $this->error = $existingImport->error; return -1; } } return 0; // Not found } /** * Copy PDF to documents folder * * @param string $pdf_path Source PDF path * @param int $import_id Import record ID * @return bool */ public function copyToDocuments($pdf_path, $import_id) { global $conf; $destdir = $conf->importzugferd->dir_output . '/imports'; if (!is_dir($destdir)) { dol_mkdir($destdir); } $destfile = $destdir . '/' . $this->import->ref . '_' . basename($pdf_path); return copy($pdf_path, $destfile); } /** * Get import result * * @return array */ public function getResult() { return $this->result; } /** * Get parsed invoice data * * @return array */ public function getInvoiceData() { return $this->parser->getInvoiceData(); } }