956 lines
34 KiB
PHP
956 lines
34 KiB
PHP
<?php
|
|
/* Copyright (C) 2026 ZUGFeRD Import Module
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*/
|
|
|
|
/**
|
|
* \file class/actions_importzugferd.class.php
|
|
* \ingroup importzugferd
|
|
* \brief Actions class for ZUGFeRD import operations
|
|
*/
|
|
|
|
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php';
|
|
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php';
|
|
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
|
|
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
|
|
|
|
dol_include_once('/importzugferd/class/zugferdparser.class.php');
|
|
dol_include_once('/importzugferd/class/zugferdimport.class.php');
|
|
dol_include_once('/importzugferd/class/productmapping.class.php');
|
|
|
|
/**
|
|
* Class ActionsImportZugferd
|
|
* Handles the import process of ZUGFeRD invoices
|
|
*/
|
|
class ActionsImportZugferd
|
|
{
|
|
/**
|
|
* @var DoliDB Database handler
|
|
*/
|
|
public $db;
|
|
|
|
/**
|
|
* @var string Error message
|
|
*/
|
|
public $error = '';
|
|
|
|
/**
|
|
* @var array Error messages
|
|
*/
|
|
public $errors = array();
|
|
|
|
/**
|
|
* @var array Warning messages
|
|
*/
|
|
public $warnings = array();
|
|
|
|
/**
|
|
* @var ZugferdParser Parser instance
|
|
*/
|
|
public $parser;
|
|
|
|
/**
|
|
* @var ZugferdImport Import record
|
|
*/
|
|
public $import;
|
|
|
|
/**
|
|
* @var ProductMapping Mapping helper
|
|
*/
|
|
public $mapping;
|
|
|
|
/**
|
|
* @var array Import result data
|
|
*/
|
|
public $result = array();
|
|
|
|
/**
|
|
* @var array Results for hooks
|
|
*/
|
|
public $results = array();
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param DoliDB $db Database handler
|
|
*/
|
|
public function __construct($db)
|
|
{
|
|
$this->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();
|
|
$last_product_index = -1;
|
|
|
|
foreach ($lines as $idx => $line) {
|
|
// Check if this is a metal surcharge line
|
|
$is_surcharge = $this->isMetalSurchargeLine($line);
|
|
|
|
// Get copper surcharge directly from parsed line data (if available)
|
|
$copper_surcharge_per_unit = isset($line['copper_surcharge_per_unit']) ? $line['copper_surcharge_per_unit'] : null;
|
|
|
|
$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,
|
|
'is_metal_surcharge' => $is_surcharge,
|
|
'metal_surcharge' => $copper_surcharge_per_unit ?: 0, // From parsed XML or will be filled from surcharge lines
|
|
'copper_surcharge_raw' => isset($line['copper_surcharge']) ? $line['copper_surcharge'] : null,
|
|
'copper_surcharge_basis_qty' => isset($line['copper_surcharge_basis_qty']) ? $line['copper_surcharge_basis_qty'] : null,
|
|
);
|
|
|
|
// Try to find product
|
|
if ($supplier_id > 0 && !$is_surcharge) {
|
|
$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;
|
|
}
|
|
} elseif (!$is_surcharge) {
|
|
$processed_line['needs_creation'] = true;
|
|
}
|
|
|
|
$processed[] = $processed_line;
|
|
$current_index = count($processed) - 1;
|
|
|
|
// If this is a metal surcharge line, associate it with the previous product
|
|
// Only use this fallback if the product line doesn't already have copper_surcharge from XML
|
|
if ($is_surcharge && $last_product_index >= 0) {
|
|
// Only apply if the previous product doesn't already have a copper surcharge from XML
|
|
if (empty($processed[$last_product_index]['metal_surcharge'])) {
|
|
// Calculate surcharge per unit based on the product's quantity
|
|
$product_qty = $processed[$last_product_index]['quantity'];
|
|
if ($product_qty > 0) {
|
|
$surcharge_per_unit = $line['line_total'] / $product_qty;
|
|
$processed[$last_product_index]['metal_surcharge'] = $surcharge_per_unit;
|
|
|
|
dol_syslog("Metal surcharge from separate line: " . $line['line_total'] . " EUR for " . $product_qty . " units = " . $surcharge_per_unit . " EUR/unit", LOG_INFO);
|
|
}
|
|
}
|
|
|
|
// Copy product info to surcharge line for reference
|
|
$processed_line['fk_product'] = $processed[$last_product_index]['fk_product'];
|
|
$processed_line['associated_product_ref'] = $processed[$last_product_index]['product_ref'];
|
|
$processed[$current_index] = $processed_line;
|
|
}
|
|
|
|
// Log if copper surcharge was extracted from XML
|
|
if ($copper_surcharge_per_unit > 0) {
|
|
dol_syslog("Copper surcharge from XML: " . $copper_surcharge_per_unit . " EUR/unit for " . $line['product']['name'], LOG_INFO);
|
|
}
|
|
|
|
// Remember the last non-surcharge product line
|
|
if (!$is_surcharge && $processed_line['fk_product'] > 0) {
|
|
$last_product_index = $current_index;
|
|
}
|
|
}
|
|
|
|
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']);
|
|
}
|
|
|
|
// Check if this line has a metal surcharge associated and update extrafield
|
|
if ($line['fk_product'] > 0 && !empty($line['metal_surcharge']) && $line['metal_surcharge'] > 0) {
|
|
$this->updateSupplierPriceMetalSurcharge(
|
|
$invoice->socid,
|
|
$line['fk_product'],
|
|
$line['metal_surcharge'],
|
|
$line['supplier_ref']
|
|
);
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Check if a line is a metal surcharge line
|
|
*
|
|
* @param array $line Line data from invoice
|
|
* @return bool
|
|
*/
|
|
public function isMetalSurchargeLine($line)
|
|
{
|
|
$name = strtolower($line['product']['name'] ?? '');
|
|
$description = strtolower($line['product']['description'] ?? '');
|
|
$text = $name . ' ' . $description;
|
|
|
|
// Keywords that indicate metal surcharge
|
|
$keywords = array(
|
|
'metallzuschlag',
|
|
'kupferzuschlag',
|
|
'cu-zuschlag',
|
|
'cuzuschlag',
|
|
'metallnotierung',
|
|
'kupfernotierung',
|
|
'metal surcharge',
|
|
'copper surcharge',
|
|
'metallaufschlag',
|
|
'kupferaufschlag',
|
|
'mez ', // MEZ = Metallzuschlag (with space to avoid false positives)
|
|
' mez',
|
|
);
|
|
|
|
foreach ($keywords as $keyword) {
|
|
if (strpos($text, $keyword) !== false) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Update metal surcharge extrafield on supplier price
|
|
*
|
|
* @param int $supplier_id Supplier ID
|
|
* @param int $product_id Product ID
|
|
* @param float $surcharge Metal surcharge amount per unit
|
|
* @param string $ref_fourn Supplier reference
|
|
* @return int >0 if updated, 0 if no update, <0 if error
|
|
*/
|
|
public function updateSupplierPriceMetalSurcharge($supplier_id, $product_id, $surcharge, $ref_fourn = '')
|
|
{
|
|
global $conf;
|
|
|
|
if ($surcharge <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Find supplier price record
|
|
$sql = "SELECT rowid 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);
|
|
$price_id = $obj->rowid;
|
|
|
|
// Check if extrafield table exists
|
|
$sql_check = "SHOW TABLES LIKE '" . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields'";
|
|
$res_check = $this->db->query($sql_check);
|
|
if (!$res_check || $this->db->num_rows($res_check) == 0) {
|
|
dol_syslog("Extrafield table does not exist, skipping metal surcharge update", LOG_WARNING);
|
|
return 0;
|
|
}
|
|
|
|
// Check if record exists in extrafields
|
|
$sql_exists = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
|
|
$sql_exists .= " WHERE fk_object = " . (int) $price_id;
|
|
|
|
$res_exists = $this->db->query($sql_exists);
|
|
if ($res_exists && $this->db->num_rows($res_exists) > 0) {
|
|
// Update existing record
|
|
$sql_update = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
|
|
$sql_update .= " SET kupferzuschlag = " . (float) $surcharge;
|
|
$sql_update .= " WHERE fk_object = " . (int) $price_id;
|
|
} else {
|
|
// Insert new record
|
|
$sql_update = "INSERT INTO " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields";
|
|
$sql_update .= " (fk_object, kupferzuschlag) VALUES (" . (int) $price_id . ", " . (float) $surcharge . ")";
|
|
}
|
|
|
|
$res = $this->db->query($sql_update);
|
|
if ($res) {
|
|
dol_syslog("Updated metal surcharge for product " . $product_id . " supplier " . $supplier_id . " to " . $surcharge, LOG_INFO);
|
|
return 1;
|
|
} else {
|
|
dol_syslog("Error updating metal surcharge: " . $this->db->lasterror(), LOG_ERR);
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
return 0; // No supplier price record found
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* Hook to add dashboard line for new products
|
|
*
|
|
* @param array $parameters Parameters
|
|
* @param object $object Object
|
|
* @param string $action Action
|
|
* @param HookManager $hookmanager Hook manager
|
|
* @return int 0 = OK, >0 = number of errors
|
|
*/
|
|
public function addOpenElementsDashboardLine($parameters, &$object, &$action, $hookmanager)
|
|
{
|
|
global $langs, $user;
|
|
|
|
if (!$user->hasRight('produit', 'lire')) {
|
|
return 0;
|
|
}
|
|
|
|
require_once DOL_DOCUMENT_ROOT.'/core/class/workboardresponse.class.php';
|
|
$langs->load('importzugferd@importzugferd');
|
|
|
|
$sql = "SELECT COUNT(*) as total FROM " . MAIN_DB_PREFIX . "product";
|
|
$sql .= " WHERE entity IN (" . getEntity('product') . ")";
|
|
$sql .= " AND ref LIKE 'New%'";
|
|
|
|
$resql = $this->db->query($sql);
|
|
if ($resql) {
|
|
$obj = $this->db->fetch_object($resql);
|
|
$count = (int) $obj->total;
|
|
|
|
if ($count > 0) {
|
|
$response = new WorkboardResponse();
|
|
$response->warning_delay = 0;
|
|
$response->label = $langs->trans("NewProductsToReview");
|
|
$response->labelShort = $langs->trans("NewProductsToReview");
|
|
$response->url = dol_buildpath('/importzugferd/new_products.php', 1);
|
|
$response->img = 'product';
|
|
$response->nbtodo = $count;
|
|
$response->nbtodolate = 0;
|
|
|
|
$this->results['importzugferd_newproducts'] = $response;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Hook to add dashboard group for new products
|
|
*
|
|
* @param array $parameters Parameters
|
|
* @param object $object Object
|
|
* @param string $action Action
|
|
* @param HookManager $hookmanager Hook manager
|
|
* @return int 0 = OK, >0 = number of errors
|
|
*/
|
|
public function addOpenElementsDashboardGroup($parameters, &$object, &$action, $hookmanager)
|
|
{
|
|
global $langs;
|
|
|
|
$langs->load('importzugferd@importzugferd');
|
|
|
|
$this->results['importzugferd_newproducts'] = array(
|
|
'groupName' => $langs->trans("NewProductsToReview"),
|
|
'stats' => array('importzugferd_newproducts'),
|
|
);
|
|
|
|
return 0;
|
|
}
|
|
}
|