importzugferd/class/datanorm.class.php

1086 lines
37 KiB
PHP

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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/datanorm.class.php
* \ingroup importzugferd
* \brief Class for Datanorm article database operations
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
/**
* Class Datanorm
* Manages Datanorm articles in database
*/
class Datanorm extends CommonObject
{
/**
* @var string ID to identify managed object
*/
public $element = 'datanorm';
/**
* @var string Name of table without prefix
*/
public $table_element = 'importzugferd_datanorm';
/**
* @var int Does object support multicompany
*/
public $ismultientitymanaged = 1;
/**
* @var int Supplier ID
*/
public $fk_soc;
/**
* @var string Article number
*/
public $article_number;
/**
* @var string Short text 1
*/
public $short_text1;
/**
* @var string Short text 2
*/
public $short_text2;
/**
* @var string Long text
*/
public $long_text;
/**
* @var string EAN/GTIN
*/
public $ean;
/**
* @var string Manufacturer article number
*/
public $manufacturer_ref;
/**
* @var string Manufacturer name
*/
public $manufacturer_name;
/**
* @var string Unit code
*/
public $unit_code;
/**
* @var float Price
*/
public $price = 0;
/**
* @var int Price unit (actual quantity: 1, 10, 100, or 1000)
*/
public $price_unit = 1;
/**
* @var int Price unit code (original Datanorm PE code: 0, 1, 2, or 3)
*/
public $price_unit_code = 0;
/**
* @var int Price type (1=gross/Brutto, 2=net/Netto)
*/
public $price_type = 1;
/**
* @var int VPE - Verpackungseinheit (packaging quantity from B-record)
*/
public $vpe;
/**
* @var float Metal surcharge (Metallzuschlag/Kupferzuschlag) for cables
*/
public $metal_surcharge = 0;
/**
* @var string Discount group
*/
public $discount_group;
/**
* @var string Product group
*/
public $product_group;
/**
* @var string Alternative unit
*/
public $alt_unit;
/**
* @var float Alternative unit factor
*/
public $alt_unit_factor = 1;
/**
* @var float Weight in kg
*/
public $weight;
/**
* @var string Matchcode
*/
public $matchcode;
/**
* @var string Datanorm version
*/
public $datanorm_version;
/**
* @var string Action code (N=New, A=Update, L=Delete)
*/
public $action_code = 'N';
/**
* @var string Import date
*/
public $import_date;
/**
* @var int Active flag
*/
public $active = 1;
/**
* @var string Date creation
*/
public $date_creation;
/**
* @var int User creator
*/
public $fk_user_creat;
/**
* @var int User modifier
*/
public $fk_user_modif;
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Create object into database
*
* @param User $user User that creates
* @return int <0 if KO, Id of created object if OK
*/
public function create($user)
{
global $conf;
$this->entity = $conf->entity;
if (empty($this->date_creation)) {
$this->date_creation = dol_now();
}
if (empty($this->import_date)) {
$this->import_date = dol_now();
}
$this->fk_user_creat = $user->id;
// Set active=0 if action_code is L (deleted article)
if ($this->action_code === 'L') {
$this->active = 0;
}
$sql = "INSERT INTO " . MAIN_DB_PREFIX . $this->table_element . " (";
$sql .= "fk_soc, article_number, short_text1, short_text2, long_text,";
$sql .= "ean, manufacturer_ref, manufacturer_name, unit_code,";
$sql .= "price, price_unit, price_unit_code, price_type, metal_surcharge, vpe, discount_group, product_group,";
$sql .= "alt_unit, alt_unit_factor, weight, matchcode,";
$sql .= "datanorm_version, action_code, import_date, active, date_creation, fk_user_creat, entity";
$sql .= ") VALUES (";
$sql .= (int) $this->fk_soc . ",";
$sql .= "'" . $this->db->escape($this->article_number) . "',";
$sql .= "'" . $this->db->escape($this->short_text1) . "',";
$sql .= "'" . $this->db->escape($this->short_text2) . "',";
$sql .= "'" . $this->db->escape($this->long_text) . "',";
$sql .= "'" . $this->db->escape($this->ean) . "',";
$sql .= "'" . $this->db->escape($this->manufacturer_ref) . "',";
$sql .= "'" . $this->db->escape($this->manufacturer_name) . "',";
$sql .= "'" . $this->db->escape($this->unit_code) . "',";
$sql .= (float) $this->price . ",";
$sql .= (int) $this->price_unit . ",";
$sql .= (int) $this->price_unit_code . ",";
$sql .= (int) $this->price_type . ",";
$sql .= (float) $this->metal_surcharge . ",";
$sql .= ($this->vpe !== null ? (int) $this->vpe : 'NULL') . ",";
$sql .= "'" . $this->db->escape($this->discount_group) . "',";
$sql .= "'" . $this->db->escape($this->product_group) . "',";
$sql .= "'" . $this->db->escape($this->alt_unit) . "',";
$sql .= (float) $this->alt_unit_factor . ",";
$sql .= ($this->weight !== null ? (float) $this->weight : 'NULL') . ",";
$sql .= "'" . $this->db->escape($this->matchcode) . "',";
$sql .= "'" . $this->db->escape($this->datanorm_version) . "',";
$sql .= "'" . $this->db->escape($this->action_code) . "',";
$sql .= "'" . $this->db->escape($this->db->idate($this->import_date)) . "',";
$sql .= (int) $this->active . ",";
$sql .= "'" . $this->db->escape($this->db->idate($this->date_creation)) . "',";
$sql .= (int) $this->fk_user_creat . ",";
$sql .= (int) $this->entity;
$sql .= ")";
dol_syslog(get_class($this) . "::create", LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
$this->id = $this->db->last_insert_id(MAIN_DB_PREFIX . $this->table_element);
return $this->id;
}
/**
* Create or update article (upsert)
*
* @param User $user User that creates/modifies
* @return int <0 if KO, Id of object if OK
*/
public function createOrUpdate($user)
{
// Check if article exists
$existing = $this->fetchByArticleNumber($this->fk_soc, $this->article_number);
if ($existing > 0) {
return $this->update($user);
} else {
return $this->create($user);
}
}
/**
* Load object in memory from database
*
* @param int $id Id object
* @return int <0 if KO, 0 if not found, >0 if OK
*/
public function fetch($id)
{
$sql = "SELECT rowid, fk_soc, article_number, short_text1, short_text2, long_text,";
$sql .= " ean, manufacturer_ref, manufacturer_name, unit_code,";
$sql .= " price, price_unit, price_unit_code, price_type, metal_surcharge, vpe, discount_group, product_group,";
$sql .= " alt_unit, alt_unit_factor, weight, matchcode,";
$sql .= " datanorm_version, action_code, import_date, active, date_creation, tms, fk_user_creat, fk_user_modif, entity";
$sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE rowid = " . (int) $id;
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->setFromObject($obj);
$this->db->free($resql);
return 1;
} else {
$this->db->free($resql);
return 0;
}
} else {
$this->error = $this->db->lasterror();
return -1;
}
}
/**
* Fetch by supplier and article number
*
* @param int $fk_soc Supplier ID
* @param string $article_number Article number
* @return int <0 if KO, 0 if not found, >0 if OK
*/
public function fetchByArticleNumber($fk_soc, $article_number)
{
global $conf;
$sql = "SELECT rowid, fk_soc, article_number, short_text1, short_text2, long_text,";
$sql .= " ean, manufacturer_ref, manufacturer_name, unit_code,";
$sql .= " price, price_unit, price_unit_code, price_type, metal_surcharge, vpe, discount_group, product_group,";
$sql .= " alt_unit, alt_unit_factor, weight, matchcode,";
$sql .= " datanorm_version, action_code, import_date, active, date_creation, tms, fk_user_creat, fk_user_modif, entity";
$sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE fk_soc = " . (int) $fk_soc;
$sql .= " AND article_number = '" . $this->db->escape($article_number) . "'";
$sql .= " AND entity = " . (int) $conf->entity;
dol_syslog(get_class($this) . "::fetchByArticleNumber", LOG_DEBUG);
$resql = $this->db->query($sql);
if ($resql) {
if ($this->db->num_rows($resql)) {
$obj = $this->db->fetch_object($resql);
$this->setFromObject($obj);
$this->db->free($resql);
return 1;
} else {
$this->db->free($resql);
return 0;
}
} else {
$this->error = $this->db->lasterror();
return -1;
}
}
/**
* Set object properties from database object
*
* @param object $obj Database row object
*/
protected function setFromObject($obj)
{
$this->id = $obj->rowid;
$this->fk_soc = $obj->fk_soc;
$this->article_number = $obj->article_number;
$this->short_text1 = $obj->short_text1;
$this->short_text2 = $obj->short_text2;
$this->long_text = $obj->long_text;
$this->ean = $obj->ean;
$this->manufacturer_ref = $obj->manufacturer_ref;
$this->manufacturer_name = $obj->manufacturer_name;
$this->unit_code = $obj->unit_code;
$this->price = $obj->price;
$this->price_unit = $obj->price_unit;
$this->price_unit_code = $obj->price_unit_code ?? 0;
$this->price_type = $obj->price_type ?? 1;
$this->metal_surcharge = $obj->metal_surcharge ?? 0;
$this->vpe = $obj->vpe;
$this->discount_group = $obj->discount_group;
$this->product_group = $obj->product_group;
$this->alt_unit = $obj->alt_unit;
$this->alt_unit_factor = $obj->alt_unit_factor;
$this->weight = $obj->weight;
$this->matchcode = $obj->matchcode;
$this->datanorm_version = $obj->datanorm_version;
$this->action_code = $obj->action_code ?? 'N';
$this->import_date = $this->db->jdate($obj->import_date);
$this->active = $obj->active;
$this->date_creation = $this->db->jdate($obj->date_creation);
$this->tms = $this->db->jdate($obj->tms);
$this->fk_user_creat = $obj->fk_user_creat;
$this->fk_user_modif = $obj->fk_user_modif;
$this->entity = $obj->entity;
}
/**
* Update object in database
*
* @param User $user User that modifies
* @return int <0 if KO, >0 if OK
*/
public function update($user)
{
$this->fk_user_modif = $user->id;
$this->import_date = dol_now();
// Set active=0 if action_code is L (deleted article)
if ($this->action_code === 'L') {
$this->active = 0;
}
$sql = "UPDATE " . MAIN_DB_PREFIX . $this->table_element . " SET";
$sql .= " short_text1 = '" . $this->db->escape($this->short_text1) . "',";
$sql .= " short_text2 = '" . $this->db->escape($this->short_text2) . "',";
$sql .= " long_text = '" . $this->db->escape($this->long_text) . "',";
$sql .= " ean = '" . $this->db->escape($this->ean) . "',";
$sql .= " manufacturer_ref = '" . $this->db->escape($this->manufacturer_ref) . "',";
$sql .= " manufacturer_name = '" . $this->db->escape($this->manufacturer_name) . "',";
$sql .= " unit_code = '" . $this->db->escape($this->unit_code) . "',";
$sql .= " price = " . (float) $this->price . ",";
$sql .= " price_unit = " . (int) $this->price_unit . ",";
$sql .= " price_unit_code = " . (int) $this->price_unit_code . ",";
$sql .= " price_type = " . (int) $this->price_type . ",";
$sql .= " metal_surcharge = " . (float) $this->metal_surcharge . ",";
$sql .= " vpe = " . ($this->vpe !== null ? (int) $this->vpe : 'NULL') . ",";
$sql .= " discount_group = '" . $this->db->escape($this->discount_group) . "',";
$sql .= " product_group = '" . $this->db->escape($this->product_group) . "',";
$sql .= " alt_unit = '" . $this->db->escape($this->alt_unit) . "',";
$sql .= " alt_unit_factor = " . (float) $this->alt_unit_factor . ",";
$sql .= " weight = " . ($this->weight !== null ? (float) $this->weight : 'NULL') . ",";
$sql .= " matchcode = '" . $this->db->escape($this->matchcode) . "',";
$sql .= " datanorm_version = '" . $this->db->escape($this->datanorm_version) . "',";
$sql .= " action_code = '" . $this->db->escape($this->action_code) . "',";
$sql .= " import_date = '" . $this->db->escape($this->db->idate($this->import_date)) . "',";
$sql .= " active = " . (int) $this->active . ",";
$sql .= " fk_user_modif = " . (int) $this->fk_user_modif;
$sql .= " WHERE rowid = " . (int) $this->id;
dol_syslog(get_class($this) . "::update", LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
return 1;
}
/**
* Delete object from database
*
* @param User $user User that deletes
* @return int <0 if KO, >0 if OK
*/
public function delete($user)
{
$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;
}
/**
* Delete all articles for a supplier
*
* @param User $user User that deletes
* @param int $fk_soc Supplier ID
* @return int <0 if KO, number of deleted rows if OK
*/
public function deleteAllBySupplier($user, $fk_soc)
{
global $conf;
$sql = "DELETE FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE fk_soc = " . (int) $fk_soc;
$sql .= " AND entity = " . (int) $conf->entity;
dol_syslog(get_class($this) . "::deleteAllBySupplier", LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$this->error = $this->db->lasterror();
return -1;
}
return $this->db->affected_rows($resql);
}
/**
* Search articles by article number (exact or partial)
*
* @param string $article_number Article number to search
* @param int $fk_soc Supplier ID (0 = all suppliers)
* @param bool $searchAll Search all suppliers if not found in specified
* @param int $limit Maximum results
* @return array Array of matching articles
*/
public function searchByArticleNumber($article_number, $fk_soc = 0, $searchAll = false, $limit = 50)
{
global $conf;
$results = array();
// First try exact match with specified supplier
if ($fk_soc > 0) {
$result = $this->fetchByArticleNumber($fk_soc, $article_number);
if ($result > 0) {
$results[] = $this->toArray();
return $results;
}
}
// Search partial match
$sql = "SELECT rowid, fk_soc, article_number, short_text1, short_text2,";
$sql .= " ean, manufacturer_ref, manufacturer_name, unit_code,";
$sql .= " price, price_unit, discount_group, product_group, matchcode";
$sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE (article_number LIKE '" . $this->db->escape($article_number) . "%'";
$sql .= " OR ean = '" . $this->db->escape($article_number) . "'";
$sql .= " OR manufacturer_ref LIKE '" . $this->db->escape($article_number) . "%')";
if ($fk_soc > 0 && !$searchAll) {
$sql .= " AND fk_soc = " . (int) $fk_soc;
} elseif ($fk_soc > 0 && $searchAll) {
// Order by matching supplier first
$sql .= " ORDER BY CASE WHEN fk_soc = " . (int) $fk_soc . " THEN 0 ELSE 1 END, article_number";
}
$sql .= " AND active = 1";
$sql .= " AND entity = " . (int) $conf->entity;
if ($fk_soc == 0 || !$searchAll) {
$sql .= " ORDER BY article_number";
}
$sql .= " LIMIT " . (int) $limit;
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$results[] = array(
'id' => $obj->rowid,
'fk_soc' => $obj->fk_soc,
'article_number' => $obj->article_number,
'short_text1' => $obj->short_text1,
'short_text2' => $obj->short_text2,
'ean' => $obj->ean,
'manufacturer_ref' => $obj->manufacturer_ref,
'manufacturer_name' => $obj->manufacturer_name,
'unit_code' => $obj->unit_code,
'price' => $obj->price,
'price_unit' => $obj->price_unit,
'discount_group' => $obj->discount_group,
'product_group' => $obj->product_group,
'matchcode' => $obj->matchcode,
);
}
$this->db->free($resql);
}
return $results;
}
/**
* Convert object to array
*
* @return array Object as array
*/
public function toArray()
{
return array(
'id' => $this->id,
'fk_soc' => $this->fk_soc,
'article_number' => $this->article_number,
'short_text1' => $this->short_text1,
'short_text2' => $this->short_text2,
'long_text' => $this->long_text,
'ean' => $this->ean,
'manufacturer_ref' => $this->manufacturer_ref,
'manufacturer_name' => $this->manufacturer_name,
'unit_code' => $this->unit_code,
'price' => $this->price,
'price_unit' => $this->price_unit,
'discount_group' => $this->discount_group,
'product_group' => $this->product_group,
'alt_unit' => $this->alt_unit,
'alt_unit_factor' => $this->alt_unit_factor,
'weight' => $this->weight,
'matchcode' => $this->matchcode,
'datanorm_version' => $this->datanorm_version,
'import_date' => $this->import_date,
'active' => $this->active,
);
}
/**
* Count articles for a supplier
*
* @param int $fk_soc Supplier ID
* @return int Count
*/
public function countBySupplier($fk_soc)
{
global $conf;
$sql = "SELECT COUNT(*) as nb FROM " . MAIN_DB_PREFIX . $this->table_element;
$sql .= " WHERE fk_soc = " . (int) $fk_soc;
$sql .= " AND entity = " . (int) $conf->entity;
$resql = $this->db->query($sql);
if ($resql) {
$obj = $this->db->fetch_object($resql);
return (int) $obj->nb;
}
return 0;
}
/**
* Get all suppliers with Datanorm data
*
* @return array Array of suppliers with article counts
*/
public function getSuppliersWithData()
{
global $conf;
$suppliers = array();
$sql = "SELECT d.fk_soc, s.nom as supplier_name, COUNT(*) as article_count,";
$sql .= " MAX(d.import_date) as last_import";
$sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element . " as d";
$sql .= " LEFT JOIN " . MAIN_DB_PREFIX . "societe as s ON s.rowid = d.fk_soc";
$sql .= " WHERE d.entity = " . (int) $conf->entity;
$sql .= " GROUP BY d.fk_soc, s.nom";
$sql .= " ORDER BY s.nom";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$suppliers[] = array(
'fk_soc' => $obj->fk_soc,
'name' => $obj->supplier_name,
'article_count' => $obj->article_count,
'last_import' => $this->db->jdate($obj->last_import),
);
}
$this->db->free($resql);
}
return $suppliers;
}
/**
* Import articles from parser
*
* @param User $user User that imports
* @param int $fk_soc Supplier ID
* @param DatanormParser $parser Parser with parsed articles
* @param bool $deleteExisting Delete existing articles before import
* @return int Number of imported articles, <0 on error
*/
public function importFromParser($user, $fk_soc, $parser, $deleteExisting = false)
{
$this->db->begin();
// Delete existing if requested
if ($deleteExisting) {
$result = $this->deleteAllBySupplier($user, $fk_soc);
if ($result < 0) {
$this->db->rollback();
return -1;
}
}
$count = 0;
$errors = 0;
foreach ($parser->getArticles() as $articleData) {
$article = new Datanorm($this->db);
$article->fk_soc = $fk_soc;
$article->article_number = $articleData['article_number'];
$article->short_text1 = $articleData['short_text1'] ?? '';
$article->short_text2 = $articleData['short_text2'] ?? '';
$article->long_text = $articleData['long_text'] ?? '';
$article->ean = $articleData['ean'] ?? '';
$article->manufacturer_ref = $articleData['manufacturer_ref'] ?? '';
$article->manufacturer_name = $articleData['manufacturer_name'] ?? '';
$article->unit_code = $articleData['unit_code'] ?? '';
$article->price = $articleData['price'] ?? 0;
$article->price_unit = $articleData['price_unit'] ?? 1;
$article->price_unit_code = $articleData['price_unit_code'] ?? 0;
$article->price_type = $articleData['price_type'] ?? 1;
$article->metal_surcharge = $articleData['metal_surcharge'] ?? 0;
$article->vpe = $articleData['vpe'] ?? null;
$article->discount_group = $articleData['discount_group'] ?? '';
$article->product_group = $articleData['product_group'] ?? '';
$article->matchcode = $articleData['matchcode'] ?? '';
$article->datanorm_version = $parser->version;
$article->action_code = $articleData['action_code'] ?? 'N';
$result = $article->createOrUpdate($user);
if ($result > 0) {
$count++;
} else {
$errors++;
$this->errors[] = 'Error importing ' . $articleData['article_number'] . ': ' . $article->error;
}
}
if ($errors > 0 && $count == 0) {
$this->db->rollback();
$this->error = 'All imports failed';
return -1;
}
$this->db->commit();
return $count;
}
/**
* Import articles from directory using streaming (for large files)
* Uses batch inserts to minimize memory usage
*
* @param User $user User that imports
* @param int $fk_soc Supplier ID
* @param string $directory Directory with Datanorm files
* @param bool $deleteExisting Delete existing articles before import
* @return int Number of imported articles, <0 on error
*/
public function importFromDirectoryStreaming($user, $fk_soc, $directory, $deleteExisting = false)
{
global $conf;
require_once __DIR__ . '/datanormparser.class.php';
// Delete existing if requested
if ($deleteExisting) {
$result = $this->deleteAllBySupplier($user, $fk_soc);
if ($result < 0) {
return -1;
}
}
$db = $this->db;
$importCount = 0;
$version = '';
// Create batch callback that inserts articles directly to database
$batchCallback = function ($articles) use ($db, $user, $fk_soc, &$importCount, &$version, $conf) {
if (empty($articles)) {
return;
}
// Use multi-row INSERT for better performance
$values = array();
$now = $db->idate(dol_now());
foreach ($articles as $articleData) {
$vpe = isset($articleData['vpe']) ? (int)$articleData['vpe'] : 'NULL';
$actionCode = $articleData['action_code'] ?? 'N';
$active = ($actionCode === 'L') ? 0 : 1; // Set active=0 for deleted articles
$values[] = sprintf(
"(%d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', %f, %d, %d, %d, %f, %s, '%s', '%s', '%s', '%s', '%s', %d, '%s', %d, '%s', %d)",
(int) $fk_soc,
$db->escape($articleData['article_number'] ?? ''),
$db->escape($articleData['short_text1'] ?? ''),
$db->escape($articleData['short_text2'] ?? ''),
$db->escape($articleData['long_text'] ?? ''),
$db->escape($articleData['ean'] ?? ''),
$db->escape($articleData['manufacturer_ref'] ?? ''),
$db->escape($articleData['manufacturer_name'] ?? ''),
$db->escape($articleData['unit_code'] ?? ''),
(float) ($articleData['price'] ?? 0),
(int) ($articleData['price_unit'] ?? 1),
(int) ($articleData['price_unit_code'] ?? 0),
(int) ($articleData['price_type'] ?? 1),
(float) ($articleData['metal_surcharge'] ?? 0),
$vpe,
$db->escape($articleData['discount_group'] ?? ''),
$db->escape($articleData['product_group'] ?? ''),
$db->escape($articleData['matchcode'] ?? ''),
$db->escape($version),
$db->escape($actionCode),
$active,
$now,
(int) $user->id,
$now,
(int) $conf->entity
);
}
if (!empty($values)) {
// Use INSERT IGNORE to skip duplicates (for the same supplier + article_number)
$sql = "INSERT INTO " . MAIN_DB_PREFIX . "importzugferd_datanorm ";
$sql .= "(fk_soc, article_number, short_text1, short_text2, long_text, ";
$sql .= "ean, manufacturer_ref, manufacturer_name, unit_code, ";
$sql .= "price, price_unit, price_unit_code, price_type, metal_surcharge, vpe, discount_group, product_group, matchcode, ";
$sql .= "datanorm_version, action_code, active, import_date, fk_user_creat, date_creation, entity) VALUES ";
$sql .= implode(", ", $values);
// For updates of existing articles, use ON DUPLICATE KEY UPDATE
$sql .= " ON DUPLICATE KEY UPDATE ";
$sql .= "short_text1 = VALUES(short_text1), ";
$sql .= "short_text2 = VALUES(short_text2), ";
$sql .= "long_text = VALUES(long_text), ";
$sql .= "ean = VALUES(ean), ";
$sql .= "manufacturer_ref = VALUES(manufacturer_ref), ";
$sql .= "manufacturer_name = VALUES(manufacturer_name), ";
$sql .= "unit_code = VALUES(unit_code), ";
$sql .= "price = VALUES(price), ";
$sql .= "price_unit = VALUES(price_unit), ";
$sql .= "price_unit_code = VALUES(price_unit_code), ";
$sql .= "price_type = VALUES(price_type), ";
$sql .= "metal_surcharge = VALUES(metal_surcharge), ";
$sql .= "vpe = VALUES(vpe), ";
$sql .= "discount_group = VALUES(discount_group), ";
$sql .= "product_group = VALUES(product_group), ";
$sql .= "matchcode = VALUES(matchcode), ";
$sql .= "datanorm_version = VALUES(datanorm_version), ";
$sql .= "action_code = VALUES(action_code), ";
$sql .= "active = VALUES(active), ";
$sql .= "import_date = VALUES(import_date), ";
$sql .= "fk_user_modif = " . (int) $user->id;
$resql = $db->query($sql);
if ($resql) {
$importCount += count($values);
}
}
};
// Parse with streaming enabled
// The parser now loads prices first, then articles
$parser = new DatanormParser();
$parser->enableStreaming($batchCallback, 500);
// Parse directory - prices are loaded first, then articles with streaming
$count = $parser->parseDirectory($directory);
$version = $parser->version;
if ($count < 0) {
$this->error = $parser->error;
return -1;
}
// Second pass: Update prices from DATPREIS files
// Use case-insensitive search for Linux compatibility
$priceFiles = array();
$allFiles = glob($directory . '/*');
foreach ($allFiles as $file) {
$basename = strtoupper(basename($file));
if (preg_match('/^DATPREIS\.\d{3}$/', $basename)) {
$priceFiles[] = $file;
}
}
if (!empty($priceFiles)) {
foreach ($priceFiles as $file) {
$this->updatePricesFromFile($fk_soc, $file);
}
}
return $importCount;
}
/**
* Update prices from DATPREIS file (streaming)
* Processes file line by line and updates database directly
*
* @param int $fk_soc Supplier ID
* @param string $file Path to DATPREIS file
* @return int Number of prices updated
*/
protected function updatePricesFromFile($fk_soc, $file)
{
global $conf;
$handle = fopen($file, 'r');
if ($handle === false) {
return 0;
}
$updated = 0;
$batch = array();
$batchSize = 500;
while (($line = fgets($handle)) !== false) {
$line = rtrim($line, "\r\n");
// Convert encoding if needed
if (!mb_check_encoding($line, 'UTF-8')) {
$line = mb_convert_encoding($line, 'UTF-8', 'ISO-8859-1');
}
if (strlen($line) < 10 || strpos($line, ';') === false) {
continue;
}
$parts = explode(';', $line);
$recordType = trim($parts[0] ?? '');
// P;A format - multiple articles per line
// Format: P;A;ArtNr;PreisKz;Preis;PE;Zuschlag;x;x;x;ArtNr2;...
// PE is the price unit code from DATPREIS (may differ from A-record!)
if ($recordType === 'P' && isset($parts[1]) && $parts[1] === 'A') {
$i = 2;
while ($i < count($parts) - 2) {
$articleNumber = trim($parts[$i] ?? '');
$priceRaw = trim($parts[$i + 2] ?? '0');
$datpreisPeCode = (int)trim($parts[$i + 3] ?? '0'); // PE code from DATPREIS
$metalSurchargeRaw = trim($parts[$i + 4] ?? '0');
$price = (float)$priceRaw / 100; // Convert cents to euros
$metalSurcharge = (float)$metalSurchargeRaw / 100; // Convert cents to euros
if (!empty($articleNumber) && $price > 0) {
$batch[$articleNumber] = array(
'price' => $price,
'metal_surcharge' => $metalSurcharge,
'datpreis_pe_code' => $datpreisPeCode
);
}
$i += 9; // 9 fields per article
}
} elseif ($recordType === 'P' || $recordType === '0') {
// Simple format: P;ArtNr;PreisKz;Preis;PE;...
$articleNumber = trim($parts[1] ?? '');
$priceRaw = trim($parts[3] ?? '0');
$datpreisPeCode = (int)trim($parts[4] ?? '0'); // PE code if available
if (strpos($priceRaw, ',') === false && strpos($priceRaw, '.') === false) {
$price = (float)$priceRaw / 100;
} else {
$priceRaw = str_replace(',', '.', $priceRaw);
$price = (float)$priceRaw;
}
if (!empty($articleNumber) && $price > 0) {
$batch[$articleNumber] = array(
'price' => $price,
'metal_surcharge' => 0,
'datpreis_pe_code' => $datpreisPeCode
);
}
}
// Flush batch when it reaches the limit
if (count($batch) >= $batchSize) {
$updated += $this->flushPriceBatch($fk_soc, $batch);
$batch = array();
}
}
// Flush remaining
if (!empty($batch)) {
$updated += $this->flushPriceBatch($fk_soc, $batch);
}
fclose($handle);
return $updated;
}
/**
* Flush price batch to database
* DATPREIS prices are already given for the A-Satz PE unit - no normalization needed!
*
* @param int $fk_soc Supplier ID
* @param array $prices Array of article_number => array('price' => ..., 'metal_surcharge' => ...)
* @return int Number of rows updated
*/
protected function flushPriceBatch($fk_soc, $prices)
{
global $conf;
if (empty($prices)) {
return 0;
}
$updated = 0;
// Build CASE statements for batch update
// Note: DATPREIS prices are already for the A-Satz PE unit, no normalization needed
$priceCases = array();
$metalCases = array();
$articleNumbers = array();
foreach ($prices as $artNum => $priceData) {
$priceCases[] = "WHEN '" . $this->db->escape($artNum) . "' THEN " . (float)$priceData['price'];
$metalCases[] = "WHEN '" . $this->db->escape($artNum) . "' THEN " . (float)$priceData['metal_surcharge'];
$articleNumbers[] = "'" . $this->db->escape($artNum) . "'";
}
if (!empty($priceCases)) {
$sql = "UPDATE " . MAIN_DB_PREFIX . "importzugferd_datanorm SET ";
$sql .= "price = CASE article_number ";
$sql .= implode(" ", $priceCases);
$sql .= " END, ";
$sql .= "metal_surcharge = CASE article_number ";
$sql .= implode(" ", $metalCases);
$sql .= " END ";
$sql .= "WHERE fk_soc = " . (int)$fk_soc;
$sql .= " AND entity = " . (int)$conf->entity;
$sql .= " AND article_number IN (" . implode(",", $articleNumbers) . ")";
$resql = $this->db->query($sql);
if ($resql) {
$updated = $this->db->affected_rows($resql);
}
}
return $updated;
}
/**
* Get full description for product creation
*
* @return string Full description
*/
public function getFullDescription()
{
$desc = '';
if (!empty($this->short_text1)) {
$desc .= $this->short_text1;
}
if (!empty($this->short_text2)) {
$desc .= ($desc ? "\n" : '') . $this->short_text2;
}
if (!empty($this->long_text)) {
$desc .= ($desc ? "\n\n" : '') . $this->long_text;
}
// Add metadata
$meta = array();
if (!empty($this->manufacturer_name)) {
$meta[] = 'Hersteller: ' . $this->manufacturer_name;
}
if (!empty($this->manufacturer_ref)) {
$meta[] = 'Hersteller-Nr: ' . $this->manufacturer_ref;
}
if (!empty($this->ean)) {
$meta[] = 'EAN: ' . $this->ean;
}
if (!empty($this->product_group)) {
$meta[] = 'Warengruppe: ' . $this->product_group;
}
if (!empty($meta)) {
$desc .= ($desc ? "\n\n" : '') . implode("\n", $meta);
}
return $desc;
}
/**
* Calculate selling price with markup
*
* @param float $markupPercent Markup percentage
* @return float Selling price
*/
public function getSellingPrice($markupPercent = 0)
{
$basePrice = $this->price;
// Adjust for price unit
if ($this->price_unit > 1) {
$basePrice = $basePrice / $this->price_unit;
}
if ($markupPercent > 0) {
return $basePrice * (1 + $markupPercent / 100);
}
return $basePrice;
}
}