array('type' => 'integer', 'label' => 'TechnicalID', 'enabled' => 1, 'position' => 1, 'notnull' => 1, 'visible' => 0, 'index' => 1), 'ref' => array('type' => 'varchar(128)', 'label' => 'Ref', 'enabled' => 1, 'position' => 10, 'notnull' => 1, 'visible' => 4, 'index' => 1, 'searchall' => 1), 'invoice_number' => array('type' => 'varchar(128)', 'label' => 'InvoiceNumber', 'enabled' => 1, 'position' => 20, 'notnull' => 1, 'visible' => 1, 'searchall' => 1), 'invoice_date' => array('type' => 'date', 'label' => 'InvoiceDate', 'enabled' => 1, 'position' => 30, 'notnull' => 1, 'visible' => 1), 'seller_name' => array('type' => 'varchar(255)', 'label' => 'SellerName', 'enabled' => 1, 'position' => 40, 'notnull' => 0, 'visible' => 1, 'searchall' => 1), 'seller_vat' => array('type' => 'varchar(50)', 'label' => 'SellerVAT', 'enabled' => 1, 'position' => 50, 'notnull' => 0, 'visible' => 1), 'buyer_reference' => array('type' => 'varchar(128)', 'label' => 'BuyerReference', 'enabled' => 1, 'position' => 60, 'notnull' => 0, 'visible' => 1), 'total_ht' => array('type' => 'price', 'label' => 'TotalHT', 'enabled' => 1, 'position' => 70, 'notnull' => 0, 'visible' => 1), 'total_ttc' => array('type' => 'price', 'label' => 'TotalTTC', 'enabled' => 1, 'position' => 80, 'notnull' => 0, 'visible' => 1), 'currency' => array('type' => 'varchar(3)', 'label' => 'Currency', 'enabled' => 1, 'position' => 90, 'notnull' => 0, 'visible' => 1, 'default' => 'EUR'), 'fk_soc' => array('type' => 'integer:Societe:societe/class/societe.class.php', 'label' => 'Supplier', 'enabled' => 1, 'position' => 100, 'notnull' => 0, 'visible' => 1), 'fk_facture_fourn' => array('type' => 'integer:FactureFournisseur:fourn/class/fournisseur.facture.class.php', 'label' => 'SupplierInvoice', 'enabled' => 1, 'position' => 110, 'notnull' => 0, 'visible' => 1), 'status' => array('type' => 'integer', 'label' => 'Status', 'enabled' => 1, 'position' => 500, 'notnull' => 1, 'visible' => 2, 'default' => 0, 'index' => 1, 'arrayofkeyval' => array(0 => 'Imported', 1 => 'Processed', 2 => 'Error')), 'error_message' => array('type' => 'text', 'label' => 'ErrorMessage', 'enabled' => 1, 'position' => 510, 'notnull' => 0, 'visible' => 0), 'file_hash' => array('type' => 'varchar(64)', 'label' => 'FileHash', 'enabled' => 1, 'position' => 520, 'notnull' => 0, 'visible' => 0), 'pdf_filename' => array('type' => 'varchar(255)', 'label' => 'PDFFilename', 'enabled' => 1, 'position' => 530, 'notnull' => 0, 'visible' => 1), 'date_creation' => array('type' => 'datetime', 'label' => 'DateCreation', 'enabled' => 1, 'position' => 600, 'notnull' => 1, 'visible' => 2), 'date_import' => array('type' => 'datetime', 'label' => 'DateImport', 'enabled' => 1, 'position' => 610, 'notnull' => 0, 'visible' => 2), 'tms' => array('type' => 'timestamp', 'label' => 'DateModification', 'enabled' => 1, 'position' => 620, 'notnull' => 0, 'visible' => 0), 'fk_user_creat' => array('type' => 'integer:User:user/class/user.class.php', 'label' => 'UserCreator', 'enabled' => 1, 'position' => 700, 'notnull' => 0, 'visible' => 0), 'fk_user_modif' => array('type' => 'integer:User:User/class/user.class.php', 'label' => 'UserModifier', 'enabled' => 1, 'position' => 710, 'notnull' => 0, 'visible' => 0), 'import_key' => array('type' => 'varchar(14)', 'label' => 'ImportKey', 'enabled' => 1, 'position' => 800, 'notnull' => 0, 'visible' => 0), 'entity' => array('type' => 'integer', 'label' => 'Entity', 'enabled' => 1, 'position' => 900, 'notnull' => 1, 'visible' => 0, 'default' => 1, 'index' => 1), ); /** * @var string Ref */ public $ref; /** * @var string Invoice number from ZUGFeRD */ public $invoice_number; /** * @var string Invoice date */ public $invoice_date; /** * @var string Seller name */ public $seller_name; /** * @var string Seller VAT ID */ public $seller_vat; /** * @var string Buyer reference (our customer number at supplier) */ public $buyer_reference; /** * @var float Net total */ public $total_ht; /** * @var float Gross total */ public $total_ttc; /** * @var string Currency */ public $currency = 'EUR'; /** * @var int Supplier ID */ public $fk_soc; /** * @var int Created supplier invoice ID */ public $fk_facture_fourn; /** * @var string XML content */ public $xml_content; /** * @var string PDF filename */ public $pdf_filename; /** * @var string File hash for duplicate detection */ public $file_hash; /** * @var int Status: 0=imported, 1=processed, 2=error */ public $status = 0; /** * @var string Error message */ public $error_message; /** * @var string Date creation */ public $date_creation; /** * @var string Date import */ public $date_import; /** * @var int User creator */ public $fk_user_creat; /** * @var int User modifier */ public $fk_user_modif; /** * @var string Import key */ public $import_key; /** * @var array Parsed line items */ public $lines = array(); /** * Status constants */ const STATUS_IMPORTED = 0; const STATUS_PROCESSED = 1; const STATUS_ERROR = 2; const STATUS_PENDING = 3; // Pending manual product assignment /** * Constructor * * @param DoliDB $db Database handler */ public function __construct($db) { global $conf, $langs; $this->db = $db; if (empty($conf->global->MAIN_SHOW_TECHNICAL_ID) && isset($this->fields['rowid'])) { $this->fields['rowid']['visible'] = 0; } } /** * Create object into database * * @param User $user User that creates * @param bool $notrigger false=launch triggers, true=disable triggers * @return int <0 if KO, Id of created object if OK */ public function create($user, $notrigger = false) { global $conf; $this->entity = $conf->entity; if (empty($this->ref)) { $this->ref = $this->getNextRef(); } if (empty($this->date_creation)) { $this->date_creation = dol_now(); } $this->fk_user_creat = $user->id; $sql = "INSERT INTO " . MAIN_DB_PREFIX . $this->table_element . " ("; $sql .= "ref, invoice_number, invoice_date, seller_name, seller_vat, buyer_reference,"; $sql .= "total_ht, total_ttc, currency, fk_soc, fk_facture_fourn,"; $sql .= "xml_content, pdf_filename, file_hash, status, error_message,"; $sql .= "date_creation, date_import, fk_user_creat, import_key, entity"; $sql .= ") VALUES ("; $sql .= "'" . $this->db->escape($this->ref) . "',"; $sql .= "'" . $this->db->escape($this->invoice_number) . "',"; $sql .= "'" . $this->db->escape($this->invoice_date) . "',"; $sql .= "'" . $this->db->escape($this->seller_name) . "',"; $sql .= "'" . $this->db->escape($this->seller_vat) . "',"; $sql .= "'" . $this->db->escape($this->buyer_reference) . "',"; $sql .= price2num($this->total_ht) . ","; $sql .= price2num($this->total_ttc) . ","; $sql .= "'" . $this->db->escape($this->currency) . "',"; $sql .= ($this->fk_soc > 0 ? $this->fk_soc : "null") . ","; $sql .= ($this->fk_facture_fourn > 0 ? $this->fk_facture_fourn : "null") . ","; // Normalize XML before storing (compact format without whitespace) $normalizedXml = self::normalizeXml($this->xml_content); $sql .= "'" . $this->db->escape($normalizedXml) . "',"; $sql .= "'" . $this->db->escape($this->pdf_filename) . "',"; $sql .= "'" . $this->db->escape($this->file_hash) . "',"; $sql .= (int) $this->status . ","; $sql .= "'" . $this->db->escape($this->error_message) . "',"; $sql .= "'" . $this->db->escape($this->db->idate($this->date_creation)) . "',"; $sql .= ($this->date_import ? "'" . $this->db->escape($this->db->idate($this->date_import)) . "'" : "null") . ","; $sql .= (int) $this->fk_user_creat . ","; $sql .= "'" . $this->db->escape($this->import_key) . "',"; $sql .= (int) $this->entity; $sql .= ")"; $this->db->begin(); dol_syslog(get_class($this) . "::create", LOG_DEBUG); $resql = $this->db->query($sql); if (!$resql) { $this->error = $this->db->lasterror(); $this->db->rollback(); return -1; } $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX . $this->table_element); $this->db->commit(); return $this->id; } /** * Load object in memory from database * * @param int $id Id object * @param string $ref Ref * @param string $file_hash File hash * @return int <0 if KO, 0 if not found, >0 if OK */ public function fetch($id, $ref = null, $file_hash = null) { global $conf; $sql = "SELECT rowid, ref, invoice_number, invoice_date, seller_name, seller_vat, buyer_reference,"; $sql .= " total_ht, total_ttc, currency, fk_soc, fk_facture_fourn,"; $sql .= " xml_content, pdf_filename, file_hash, status, error_message,"; $sql .= " date_creation, date_import, tms, fk_user_creat, fk_user_modif, import_key, entity"; $sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element; $sql .= " WHERE entity IN (" . getEntity($this->table_element) . ")"; if ($id) { $sql .= " AND rowid = " . (int) $id; } elseif ($ref) { $sql .= " AND ref = '" . $this->db->escape($ref) . "'"; } elseif ($file_hash) { $sql .= " AND file_hash = '" . $this->db->escape($file_hash) . "'"; } else { return -1; } dol_syslog(get_class($this) . "::fetch", LOG_DEBUG); $resql = $this->db->query($sql); if ($resql) { if ($this->db->num_rows($resql)) { $obj = $this->db->fetch_object($resql); $this->id = $obj->rowid; $this->ref = $obj->ref; $this->invoice_number = $obj->invoice_number; $this->invoice_date = $this->db->jdate($obj->invoice_date); $this->seller_name = $obj->seller_name; $this->seller_vat = $obj->seller_vat; $this->buyer_reference = $obj->buyer_reference; $this->total_ht = $obj->total_ht; $this->total_ttc = $obj->total_ttc; $this->currency = $obj->currency; $this->fk_soc = $obj->fk_soc; $this->fk_facture_fourn = $obj->fk_facture_fourn; $this->xml_content = $obj->xml_content; $this->pdf_filename = $obj->pdf_filename; $this->file_hash = $obj->file_hash; $this->status = $obj->status; $this->error_message = $obj->error_message; $this->date_creation = $this->db->jdate($obj->date_creation); $this->date_import = $this->db->jdate($obj->date_import); $this->tms = $this->db->jdate($obj->tms); $this->fk_user_creat = $obj->fk_user_creat; $this->fk_user_modif = $obj->fk_user_modif; $this->import_key = $obj->import_key; $this->entity = $obj->entity; $this->db->free($resql); return 1; } else { $this->db->free($resql); return 0; } } else { $this->error = $this->db->lasterror(); return -1; } } /** * Update object in database * * @param User $user User that modifies * @param bool $notrigger false=launch triggers, true=disable triggers * @return int <0 if KO, >0 if OK */ public function update($user, $notrigger = false) { $this->fk_user_modif = $user->id; $sql = "UPDATE " . MAIN_DB_PREFIX . $this->table_element . " SET"; $sql .= " ref = '" . $this->db->escape($this->ref) . "',"; $sql .= " invoice_number = '" . $this->db->escape($this->invoice_number) . "',"; $sql .= " invoice_date = " . ($this->invoice_date ? "'" . $this->db->idate($this->invoice_date) . "'" : "null") . ","; $sql .= " seller_name = '" . $this->db->escape($this->seller_name) . "',"; $sql .= " seller_vat = '" . $this->db->escape($this->seller_vat) . "',"; $sql .= " buyer_reference = '" . $this->db->escape($this->buyer_reference) . "',"; $sql .= " total_ht = " . price2num($this->total_ht) . ","; $sql .= " total_ttc = " . price2num($this->total_ttc) . ","; $sql .= " currency = '" . $this->db->escape($this->currency) . "',"; $sql .= " fk_soc = " . ($this->fk_soc > 0 ? (int) $this->fk_soc : "null") . ","; $sql .= " fk_facture_fourn = " . ($this->fk_facture_fourn > 0 ? (int) $this->fk_facture_fourn : "null") . ","; $sql .= " status = " . (int) $this->status . ","; $sql .= " date_import = " . ($this->date_import ? "'" . $this->db->idate($this->date_import) . "'" : "null") . ","; $sql .= " error_message = " . ($this->error_message ? "'" . $this->db->escape($this->error_message) . "'" : "null") . ","; $sql .= " fk_user_modif = " . (int) $this->fk_user_modif; $sql .= " WHERE rowid = " . (int) $this->id; dol_syslog(get_class($this) . "::update sql=" . $sql, LOG_DEBUG); $resql = $this->db->query($sql); if (!$resql) { $this->error = $this->db->lasterror(); dol_syslog(get_class($this) . "::update error=" . $this->error, LOG_ERR); return -1; } return 1; } /** * Delete object from database * * @param User $user User that deletes * @param bool $notrigger false=launch triggers, true=disable triggers * @return int <0 if KO, >0 if OK */ public function delete($user, $notrigger = false) { $sql = "DELETE FROM " . MAIN_DB_PREFIX . $this->table_element; $sql .= " WHERE rowid = " . (int) $this->id; dol_syslog(get_class($this) . "::delete", LOG_DEBUG); $resql = $this->db->query($sql); if (!$resql) { $this->error = $this->db->lasterror(); return -1; } return 1; } /** * Check if file already imported (duplicate detection) * * @param string $file_hash SHA256 hash of file * @return bool true if already exists */ public function isDuplicate($file_hash) { global $conf; $sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . $this->table_element; $sql .= " WHERE file_hash = '" . $this->db->escape($file_hash) . "'"; $sql .= " AND entity = " . (int) $conf->entity; $resql = $this->db->query($sql); if ($resql && $this->db->num_rows($resql) > 0) { return true; } return false; } /** * Get next reference number * * @return string */ public function getNextRef() { global $conf; $sql = "SELECT MAX(CAST(SUBSTRING(ref, 4) AS UNSIGNED)) as maxref"; $sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element; $sql .= " WHERE ref LIKE 'ZI-%'"; $sql .= " AND entity = " . (int) $conf->entity; $resql = $this->db->query($sql); if ($resql) { $obj = $this->db->fetch_object($resql); $num = $obj->maxref ? $obj->maxref + 1 : 1; return 'ZI-' . str_pad($num, 6, '0', STR_PAD_LEFT); } return 'ZI-000001'; } /** * Normalize XML for database storage * Removes whitespace between tags to store compact XML * * @param string $xml XML content * @return string Normalized XML */ public static function normalizeXml($xml) { if (empty($xml)) { return ''; } $dom = new DOMDocument('1.0', 'UTF-8'); $dom->preserveWhiteSpace = false; $dom->formatOutput = false; // Try to load XML if (@$dom->loadXML($xml)) { // Return compact XML without declaration $result = $dom->saveXML($dom->documentElement); return $result ? $result : $xml; } // Fallback: just remove whitespace between tags return preg_replace('/>\s+<', trim($xml)); } /** * Format XML for display * Takes compact XML and formats it with proper indentation * * @param string $xml Compact XML content * @return string Formatted XML */ public static function formatXmlForDisplay($xml) { if (empty($xml)) { return ''; } // Clean up any escaped newlines from old data (literal \n strings) $xml = str_replace('\n', '', $xml); $xml = str_replace('\r', '', $xml); $xml = str_replace('\t', '', $xml); $dom = new DOMDocument('1.0', 'UTF-8'); $dom->preserveWhiteSpace = false; $dom->formatOutput = true; if (@$dom->loadXML($xml)) { return $dom->saveXML(); } // Fallback: return as-is return $xml; } /** * Import a ZUGFeRD invoice from PDF file * This is the main entry point for batch/automated imports * * @param User $user User performing import * @param string $file_path Path to PDF file * @param bool $auto_create_invoice Whether to auto-create supplier invoice * @return int >0 (import ID) if OK, -2 if duplicate, <0 if error */ public function importFromFile($user, $file_path, $auto_create_invoice = false) { global $conf, $langs; $langs->load('importzugferd@importzugferd'); dol_include_once('/importzugferd/class/zugferdparser.class.php'); dol_include_once('/importzugferd/class/productmapping.class.php'); dol_include_once('/importzugferd/class/importline.class.php'); dol_include_once('/importzugferd/class/importnotification.class.php'); // Parse PDF $parser = new ZugferdParser($this->db); $result = $parser->extractFromPdf($file_path); if ($result < 0) { $this->error = $parser->error; return -1; } $result = $parser->parse(); if ($result < 0) { $this->error = $parser->error; return -1; } $invoice_data = $parser->getInvoiceData(); // Check for duplicates $file_hash = $parser->getFileHash($file_path); if ($this->isDuplicate($file_hash)) { $this->error = $langs->trans('ErrorDuplicateInvoice'); return -2; // Duplicate } // Find supplier $supplier_id = $this->findSupplier($invoice_data); // Set import record data $this->invoice_number = $invoice_data['invoice_number']; $this->invoice_date = $invoice_data['invoice_date']; $this->seller_name = $invoice_data['seller']['name']; $this->seller_vat = $invoice_data['seller']['vat_id']; $this->buyer_reference = $invoice_data['buyer']['reference'] ?: $invoice_data['buyer']['id']; $this->total_ht = $invoice_data['totals']['net']; $this->total_ttc = $invoice_data['totals']['gross']; $this->currency = $invoice_data['totals']['currency'] ?: 'EUR'; $this->fk_soc = $supplier_id; $this->xml_content = $parser->getXmlContent(); $this->pdf_filename = basename($file_path); $this->file_hash = $file_hash; $this->date_import = dol_now(); // Create import record $import_id = $this->create($user); if ($import_id < 0) { return -3; } // Process and store line items $mapping = new ProductMapping($this->db); $unmatched_count = 0; $matched_count = 0; $total_lines = count($invoice_data['lines']); foreach ($invoice_data['lines'] as $line_data) { $line = new ImportLine($this->db); $line->fk_import = $import_id; $line->line_id = $line_data['line_id']; $line->supplier_ref = $line_data['product']['seller_id']; $line->product_name = $line_data['product']['name']; $line->description = $line_data['product']['description']; $line->quantity = $line_data['quantity']; $line->unit_code = $line_data['unit_code']; $line->unit_price = $line_data['unit_price']; $line->unit_price_raw = isset($line_data['unit_price_raw']) ? $line_data['unit_price_raw'] : $line_data['unit_price']; $line->basis_quantity = isset($line_data['basis_quantity']) ? $line_data['basis_quantity'] : 1; $line->basis_quantity_unit = isset($line_data['basis_quantity_unit']) ? $line_data['basis_quantity_unit'] : ''; $line->line_total = $line_data['line_total']; $line->tax_percent = $line_data['tax_percent']; $line->ean = $line_data['product']['global_id']; // Try to match product $fk_product = 0; $match_method = ''; if ($supplier_id > 0) { $match = $mapping->findProduct($supplier_id, $line_data['product']); if (!empty($match) && $match['fk_product'] > 0) { $fk_product = $match['fk_product']; $match_method = $match['method']; } } $line->fk_product = $fk_product; $line->match_method = $match_method; if ($fk_product == 0) { $unmatched_count++; } else { $matched_count++; } $line->create($user); } // Determine status based on matching results // STATUS_IMPORTED only if: supplier found, has lines, and ALL lines have matched products if ($supplier_id == 0 || $total_lines == 0 || $unmatched_count > 0 || $matched_count == 0) { // Missing supplier, no lines, unmatched products, or no matches at all - needs manual intervention $this->status = self::STATUS_PENDING; } else { // All lines matched $this->status = self::STATUS_IMPORTED; } // Copy PDF to documents $destdir = $conf->importzugferd->dir_output . '/imports'; if (!is_dir($destdir)) { dol_mkdir($destdir); } $destfile = $destdir . '/' . $this->ref . '_' . basename($file_path); copy($file_path, $destfile); // Update status $this->update($user); // Send notification if manual intervention required if ($this->status == self::STATUS_PENDING && class_exists('ImportNotification')) { $notification = new ImportNotification($this->db); $importLine = new ImportLine($this->db); $storedLines = $importLine->fetchAllByImport($this->id); $notification->sendManualInterventionNotification($this, $storedLines); } return $import_id; } /** * Find supplier by buyer reference or VAT ID * * @param array $invoice_data Parsed invoice data * @return int Supplier ID or 0 */ protected 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; } /** * Get status label * * @param int $mode 0=short, 1=long * @return string */ public function getLibStatut($mode = 0) { return $this->LibStatut($this->status, $mode); } /** * Return status label for a given status * * @param int $status Status * @param int $mode 0=short, 1=long * @return string */ public function LibStatut($status, $mode = 0) { global $langs; $langs->load('importzugferd@importzugferd'); $statusLabels = array( self::STATUS_IMPORTED => array('short' => 'Imported', 'long' => 'StatusImported', 'class' => 'status4'), self::STATUS_PROCESSED => array('short' => 'Processed', 'long' => 'StatusProcessed', 'class' => 'status6'), self::STATUS_ERROR => array('short' => 'Error', 'long' => 'StatusError', 'class' => 'status8'), self::STATUS_PENDING => array('short' => 'Pending', 'long' => 'StatusPending', 'class' => 'status1'), ); $statusType = isset($statusLabels[$status]) ? $statusLabels[$status] : $statusLabels[0]; $label = $mode == 0 ? $statusType['short'] : $statusType['long']; return dolGetStatus($langs->trans($label), '', '', $statusType['class']); } }