645 lines
27 KiB
PHP
645 lines
27 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/zugferdparser.class.php
|
|
* \ingroup importzugferd
|
|
* \brief Parser for ZUGFeRD/Factur-X XML invoices
|
|
*/
|
|
|
|
/**
|
|
* Class ZugferdParser
|
|
* Parses ZUGFeRD XML from PDF attachments
|
|
*/
|
|
class ZugferdParser
|
|
{
|
|
/**
|
|
* @var DoliDB Database handler
|
|
*/
|
|
public $db;
|
|
|
|
/**
|
|
* @var string Error message
|
|
*/
|
|
public $error = '';
|
|
|
|
/**
|
|
* @var array Error messages
|
|
*/
|
|
public $errors = array();
|
|
|
|
/**
|
|
* @var string XML content
|
|
*/
|
|
public $xml_content = '';
|
|
|
|
/**
|
|
* @var SimpleXMLElement Parsed XML
|
|
*/
|
|
public $xml;
|
|
|
|
/**
|
|
* @var array Parsed invoice data
|
|
*/
|
|
public $invoice_data = array();
|
|
|
|
/**
|
|
* @var array Namespace prefixes
|
|
*/
|
|
private $namespaces = array();
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param DoliDB $db Database handler
|
|
*/
|
|
public function __construct($db)
|
|
{
|
|
$this->db = $db;
|
|
}
|
|
|
|
/**
|
|
* Extract XML from PDF file
|
|
*
|
|
* @param string $pdf_path Path to PDF file
|
|
* @return int 1 if OK, -1 if error
|
|
*/
|
|
public function extractFromPdf($pdf_path)
|
|
{
|
|
if (!file_exists($pdf_path)) {
|
|
$this->error = 'File not found: ' . $pdf_path;
|
|
return -1;
|
|
}
|
|
|
|
// Read PDF content
|
|
$pdf_content = file_get_contents($pdf_path);
|
|
if ($pdf_content === false) {
|
|
$this->error = 'Cannot read PDF file';
|
|
return -1;
|
|
}
|
|
|
|
// Try to find embedded XML using different methods
|
|
$xml = $this->extractXmlFromPdfContent($pdf_content);
|
|
|
|
if (empty($xml)) {
|
|
// Try using pdfdetach command
|
|
$xml = $this->extractXmlUsingPdfdetach($pdf_path);
|
|
}
|
|
|
|
if (empty($xml)) {
|
|
$this->error = 'No ZUGFeRD/Factur-X XML found in PDF';
|
|
return -1;
|
|
}
|
|
|
|
$this->xml_content = $xml;
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Extract XML from PDF content by searching for XML patterns
|
|
*
|
|
* @param string $content PDF binary content
|
|
* @return string|null XML content or null
|
|
*/
|
|
private function extractXmlFromPdfContent($content)
|
|
{
|
|
// Look for embedded file streams
|
|
// ZUGFeRD XML typically starts with <?xml and contains CrossIndustryInvoice or CrossIndustryDocument
|
|
|
|
// Method 1: Look for FlateDecode streams and decompress
|
|
$pattern = '/stream\s*(.*?)\s*endstream/s';
|
|
preg_match_all($pattern, $content, $matches);
|
|
|
|
foreach ($matches[1] as $stream) {
|
|
// Try to decompress
|
|
$decompressed = @gzuncompress($stream);
|
|
if ($decompressed === false) {
|
|
$decompressed = @gzinflate($stream);
|
|
}
|
|
if ($decompressed === false) {
|
|
$decompressed = $stream;
|
|
}
|
|
|
|
// Check if it's XML
|
|
if (strpos($decompressed, '<?xml') !== false &&
|
|
(strpos($decompressed, 'CrossIndustryDocument') !== false ||
|
|
strpos($decompressed, 'CrossIndustryInvoice') !== false)) {
|
|
// Extract just the XML part
|
|
$start = strpos($decompressed, '<?xml');
|
|
$xml = substr($decompressed, $start);
|
|
// Find the end
|
|
if (preg_match('/<\/[a-z]+:CrossIndustry(Document|Invoice)>/i', $xml, $endMatch, PREG_OFFSET_CAPTURE)) {
|
|
$xml = substr($xml, 0, $endMatch[0][1] + strlen($endMatch[0][0]));
|
|
return $xml;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract XML using pdfdetach command line tool
|
|
*
|
|
* @param string $pdf_path Path to PDF
|
|
* @return string|null XML content or null
|
|
*/
|
|
private function extractXmlUsingPdfdetach($pdf_path)
|
|
{
|
|
$tmp_file = sys_get_temp_dir() . '/zugferd_' . uniqid() . '.xml';
|
|
|
|
// Try to extract first attachment
|
|
$cmd = 'pdfdetach -save 1 -o ' . escapeshellarg($tmp_file) . ' ' . escapeshellarg($pdf_path) . ' 2>&1';
|
|
exec($cmd, $output, $return_code);
|
|
|
|
if ($return_code === 0 && file_exists($tmp_file)) {
|
|
$xml = file_get_contents($tmp_file);
|
|
unlink($tmp_file);
|
|
|
|
if (strpos($xml, 'CrossIndustryDocument') !== false ||
|
|
strpos($xml, 'CrossIndustryInvoice') !== false) {
|
|
return $xml;
|
|
}
|
|
}
|
|
|
|
// Try listing and extracting by name
|
|
$cmd = 'pdfdetach -list ' . escapeshellarg($pdf_path) . ' 2>&1';
|
|
exec($cmd, $list_output, $return_code);
|
|
|
|
foreach ($list_output as $line) {
|
|
if (preg_match('/(ZUGFeRD|factur-x|xrechnung)/i', $line)) {
|
|
if (preg_match('/(\d+):/', $line, $matches)) {
|
|
$idx = $matches[1];
|
|
$cmd = 'pdfdetach -save ' . $idx . ' -o ' . escapeshellarg($tmp_file) . ' ' . escapeshellarg($pdf_path) . ' 2>&1';
|
|
exec($cmd, $output, $return_code);
|
|
|
|
if ($return_code === 0 && file_exists($tmp_file)) {
|
|
$xml = file_get_contents($tmp_file);
|
|
unlink($tmp_file);
|
|
return $xml;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse the XML content
|
|
*
|
|
* @param string $xml_content Optional XML content, uses $this->xml_content if not provided
|
|
* @return int 1 if OK, -1 if error
|
|
*/
|
|
public function parse($xml_content = null)
|
|
{
|
|
if ($xml_content !== null) {
|
|
$this->xml_content = $xml_content;
|
|
}
|
|
|
|
if (empty($this->xml_content)) {
|
|
$this->error = 'No XML content to parse';
|
|
return -1;
|
|
}
|
|
|
|
libxml_use_internal_errors(true);
|
|
$this->xml = simplexml_load_string($this->xml_content);
|
|
|
|
if ($this->xml === false) {
|
|
$errors = libxml_get_errors();
|
|
$this->error = 'XML parse error: ' . ($errors[0]->message ?? 'Unknown error');
|
|
libxml_clear_errors();
|
|
return -1;
|
|
}
|
|
|
|
// Get namespaces
|
|
$this->namespaces = $this->xml->getNamespaces(true);
|
|
|
|
// Determine ZUGFeRD version and parse accordingly
|
|
if ($this->isZugferdV1()) {
|
|
return $this->parseZugferdV1();
|
|
} elseif ($this->isZugferdV2()) {
|
|
return $this->parseZugferdV2();
|
|
} else {
|
|
$this->error = 'Unknown ZUGFeRD/Factur-X format';
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if ZUGFeRD v1 format
|
|
*
|
|
* @return bool
|
|
*/
|
|
private function isZugferdV1()
|
|
{
|
|
return strpos($this->xml_content, 'CrossIndustryDocument') !== false;
|
|
}
|
|
|
|
/**
|
|
* Check if ZUGFeRD v2 / Factur-X format
|
|
*
|
|
* @return bool
|
|
*/
|
|
private function isZugferdV2()
|
|
{
|
|
return strpos($this->xml_content, 'CrossIndustryInvoice') !== false;
|
|
}
|
|
|
|
/**
|
|
* Parse ZUGFeRD v1 format
|
|
*
|
|
* @return int 1 if OK, -1 if error
|
|
*/
|
|
private function parseZugferdV1()
|
|
{
|
|
$this->xml->registerXPathNamespace('rsm', 'urn:ferd:CrossIndustryDocument:invoice:1p0');
|
|
$this->xml->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:12');
|
|
$this->xml->registerXPathNamespace('udt', 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:15');
|
|
|
|
$data = array();
|
|
|
|
// Header information
|
|
$header = $this->xml->xpath('//rsm:HeaderExchangedDocument');
|
|
if (!empty($header)) {
|
|
$data['invoice_number'] = $this->getXpathValue('//rsm:HeaderExchangedDocument/ram:ID');
|
|
$data['invoice_type'] = $this->getXpathValue('//rsm:HeaderExchangedDocument/ram:TypeCode');
|
|
$data['invoice_name'] = $this->getXpathValue('//rsm:HeaderExchangedDocument/ram:Name');
|
|
|
|
$dateStr = $this->getXpathValue('//rsm:HeaderExchangedDocument/ram:IssueDateTime/udt:DateTimeString');
|
|
$data['invoice_date'] = $this->parseDate($dateStr);
|
|
}
|
|
|
|
// Seller (Lieferant)
|
|
$data['seller'] = array(
|
|
'name' => $this->getXpathValue('//ram:SellerTradeParty/ram:Name'),
|
|
'global_id' => $this->getXpathValue('//ram:SellerTradeParty/ram:GlobalID'),
|
|
'vat_id' => $this->getXpathValue('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]'),
|
|
'address' => array(
|
|
'street' => $this->getXpathValue('//ram:SellerTradeParty/ram:PostalTradeAddress/ram:LineOne'),
|
|
'postcode' => $this->getXpathValue('//ram:SellerTradeParty/ram:PostalTradeAddress/ram:PostcodeCode'),
|
|
'city' => $this->getXpathValue('//ram:SellerTradeParty/ram:PostalTradeAddress/ram:CityName'),
|
|
'country' => $this->getXpathValue('//ram:SellerTradeParty/ram:PostalTradeAddress/ram:CountryID'),
|
|
)
|
|
);
|
|
|
|
// Buyer (Käufer - wir)
|
|
$data['buyer'] = array(
|
|
'id' => $this->getXpathValue('//ram:BuyerTradeParty/ram:ID'),
|
|
'reference' => $this->getXpathValue('//ram:ApplicableSupplyChainTradeAgreement/ram:BuyerReference'),
|
|
'name' => $this->getXpathValue('//ram:BuyerTradeParty/ram:Name'),
|
|
);
|
|
|
|
// Totals
|
|
$data['totals'] = array(
|
|
'net' => (float) $this->getXpathValue('//ram:SpecifiedTradeSettlementMonetarySummation/ram:LineTotalAmount'),
|
|
'tax' => (float) $this->getXpathValue('//ram:SpecifiedTradeSettlementMonetarySummation/ram:TaxTotalAmount'),
|
|
'gross' => (float) $this->getXpathValue('//ram:SpecifiedTradeSettlementMonetarySummation/ram:GrandTotalAmount'),
|
|
'currency' => $this->getXpathValue('//ram:ApplicableSupplyChainTradeSettlement/ram:InvoiceCurrencyCode'),
|
|
);
|
|
|
|
// Due date
|
|
$dueDateStr = $this->getXpathValue('//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString');
|
|
$data['due_date'] = $this->parseDate($dueDateStr);
|
|
|
|
// Line items
|
|
$data['lines'] = array();
|
|
$lines = $this->xml->xpath('//ram:IncludedSupplyChainTradeLineItem');
|
|
|
|
foreach ($lines as $line) {
|
|
$line->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:12');
|
|
|
|
// Get price and basis quantity for correct unit price calculation
|
|
$chargeAmount = (float) $this->getNodeValue($line->xpath('ram:SpecifiedSupplyChainTradeAgreement/ram:NetPriceProductTradePrice/ram:ChargeAmount'));
|
|
$basisQuantity = (float) $this->getNodeValue($line->xpath('ram:SpecifiedSupplyChainTradeAgreement/ram:NetPriceProductTradePrice/ram:BasisQuantity'));
|
|
$basisQuantityUnit = (string) $this->getNodeAttribute($line->xpath('ram:SpecifiedSupplyChainTradeAgreement/ram:NetPriceProductTradePrice/ram:BasisQuantity'), 'unitCode');
|
|
|
|
// Calculate real unit price: if BasisQuantity is e.g. 100 (meters), price is for 100 units
|
|
if ($basisQuantity > 0 && $basisQuantity != 1) {
|
|
$unitPrice = $chargeAmount / $basisQuantity;
|
|
} else {
|
|
$unitPrice = $chargeAmount;
|
|
}
|
|
|
|
// Extract copper surcharge (Kupferzuschlag) from AppliedTradeAllowanceCharge
|
|
$copperSurcharge = null;
|
|
$copperSurchargeBasisQty = null;
|
|
$allowanceCharges = $line->xpath('ram:SpecifiedSupplyChainTradeAgreement/ram:GrossPriceProductTradePrice/ram:AppliedTradeAllowanceCharge');
|
|
foreach ($allowanceCharges as $charge) {
|
|
$charge->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:12');
|
|
$reason = (string) $this->getNodeValue($charge->xpath('ram:Reason'));
|
|
if (stripos($reason, 'Kupfer') !== false || stripos($reason, 'copper') !== false || stripos($reason, 'Metall') !== false) {
|
|
$copperSurcharge = (float) $this->getNodeValue($charge->xpath('ram:ActualAmount'));
|
|
$copperSurchargeBasisQty = (float) $this->getNodeValue($charge->xpath('ram:BasisQuantity'));
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Also check ApplicableProductCharacteristic for copper surcharge
|
|
if ($copperSurcharge === null) {
|
|
$characteristics = $line->xpath('ram:SpecifiedTradeProduct/ram:ApplicableProductCharacteristic');
|
|
foreach ($characteristics as $char) {
|
|
$char->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:12');
|
|
$desc = (string) $this->getNodeValue($char->xpath('ram:Description'));
|
|
if (stripos($desc, 'Kupfer') !== false || stripos($desc, 'copper') !== false || stripos($desc, 'Metall') !== false) {
|
|
$copperSurcharge = (float) $this->getNodeValue($char->xpath('ram:Value'));
|
|
// Usually refers to same basis quantity as the price
|
|
$copperSurchargeBasisQty = $basisQuantity ?: 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate copper surcharge per single unit
|
|
$copperSurchargePerUnit = null;
|
|
if ($copperSurcharge !== null && $copperSurcharge > 0) {
|
|
if ($copperSurchargeBasisQty > 0 && $copperSurchargeBasisQty != 1) {
|
|
$copperSurchargePerUnit = $copperSurcharge / $copperSurchargeBasisQty;
|
|
} else {
|
|
$copperSurchargePerUnit = $copperSurcharge;
|
|
}
|
|
}
|
|
|
|
$lineData = array(
|
|
'line_id' => (string) $this->getNodeValue($line->xpath('ram:AssociatedDocumentLineDocument/ram:LineID')),
|
|
'product' => array(
|
|
'seller_id' => (string) $this->getNodeValue($line->xpath('ram:SpecifiedTradeProduct/ram:SellerAssignedID')),
|
|
'buyer_id' => (string) $this->getNodeValue($line->xpath('ram:SpecifiedTradeProduct/ram:BuyerAssignedID')),
|
|
'global_id' => (string) $this->getNodeValue($line->xpath('ram:SpecifiedTradeProduct/ram:GlobalID')),
|
|
'name' => (string) $this->getNodeValue($line->xpath('ram:SpecifiedTradeProduct/ram:Name')),
|
|
'description' => (string) $this->getNodeValue($line->xpath('ram:SpecifiedTradeProduct/ram:Description')),
|
|
),
|
|
'quantity' => (float) $this->getNodeValue($line->xpath('ram:SpecifiedSupplyChainTradeDelivery/ram:BilledQuantity')),
|
|
'unit_code' => (string) $this->getNodeAttribute($line->xpath('ram:SpecifiedSupplyChainTradeDelivery/ram:BilledQuantity'), 'unitCode'),
|
|
'unit_price' => $unitPrice,
|
|
'unit_price_raw' => $chargeAmount,
|
|
'basis_quantity' => $basisQuantity ?: 1,
|
|
'basis_quantity_unit' => $basisQuantityUnit,
|
|
'line_total' => (float) $this->getNodeValue($line->xpath('ram:SpecifiedSupplyChainTradeSettlement/ram:SpecifiedTradeSettlementMonetarySummation/ram:LineTotalAmount')),
|
|
'tax_percent' => (float) $this->getNodeValue($line->xpath('ram:SpecifiedSupplyChainTradeSettlement/ram:ApplicableTradeTax/ram:ApplicablePercent')),
|
|
// Copper surcharge data
|
|
'copper_surcharge' => $copperSurcharge,
|
|
'copper_surcharge_basis_qty' => $copperSurchargeBasisQty,
|
|
'copper_surcharge_per_unit' => $copperSurchargePerUnit,
|
|
);
|
|
|
|
$data['lines'][] = $lineData;
|
|
}
|
|
|
|
$this->invoice_data = $data;
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Parse ZUGFeRD v2 / Factur-X format
|
|
*
|
|
* @return int 1 if OK, -1 if error
|
|
*/
|
|
private function parseZugferdV2()
|
|
{
|
|
$this->xml->registerXPathNamespace('rsm', 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100');
|
|
$this->xml->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100');
|
|
$this->xml->registerXPathNamespace('qdt', 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100');
|
|
$this->xml->registerXPathNamespace('udt', 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100');
|
|
|
|
$data = array();
|
|
|
|
// Header information
|
|
$data['invoice_number'] = $this->getXpathValue('//rsm:ExchangedDocument/ram:ID');
|
|
$data['invoice_type'] = $this->getXpathValue('//rsm:ExchangedDocument/ram:TypeCode');
|
|
$data['invoice_name'] = $this->getXpathValue('//rsm:ExchangedDocument/ram:Name');
|
|
|
|
$dateStr = $this->getXpathValue('//rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString');
|
|
$data['invoice_date'] = $this->parseDate($dateStr);
|
|
|
|
// Seller (Lieferant)
|
|
$data['seller'] = array(
|
|
'name' => $this->getXpathValue('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:Name'),
|
|
'global_id' => $this->getXpathValue('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:GlobalID'),
|
|
'vat_id' => $this->getXpathValue('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:SpecifiedTaxRegistration/ram:ID'),
|
|
'address' => array(
|
|
'street' => $this->getXpathValue('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:PostalTradeAddress/ram:LineOne'),
|
|
'postcode' => $this->getXpathValue('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:PostalTradeAddress/ram:PostcodeCode'),
|
|
'city' => $this->getXpathValue('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:PostalTradeAddress/ram:CityName'),
|
|
'country' => $this->getXpathValue('//ram:ApplicableHeaderTradeAgreement/ram:SellerTradeParty/ram:PostalTradeAddress/ram:CountryID'),
|
|
)
|
|
);
|
|
|
|
// Buyer (Käufer - wir)
|
|
$data['buyer'] = array(
|
|
'id' => $this->getXpathValue('//ram:ApplicableHeaderTradeAgreement/ram:BuyerTradeParty/ram:ID'),
|
|
'reference' => $this->getXpathValue('//ram:ApplicableHeaderTradeAgreement/ram:BuyerReference'),
|
|
'name' => $this->getXpathValue('//ram:ApplicableHeaderTradeAgreement/ram:BuyerTradeParty/ram:Name'),
|
|
);
|
|
|
|
// Totals
|
|
$data['totals'] = array(
|
|
'net' => (float) $this->getXpathValue('//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:LineTotalAmount'),
|
|
'tax' => (float) $this->getXpathValue('//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TaxTotalAmount'),
|
|
'gross' => (float) $this->getXpathValue('//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:GrandTotalAmount'),
|
|
'currency' => $this->getXpathValue('//ram:ApplicableHeaderTradeSettlement/ram:InvoiceCurrencyCode'),
|
|
);
|
|
|
|
// Due date
|
|
$dueDateStr = $this->getXpathValue('//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString');
|
|
$data['due_date'] = $this->parseDate($dueDateStr);
|
|
|
|
// Line items
|
|
$data['lines'] = array();
|
|
$lines = $this->xml->xpath('//ram:IncludedSupplyChainTradeLineItem');
|
|
|
|
foreach ($lines as $line) {
|
|
$line->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100');
|
|
|
|
// Get price and basis quantity for correct unit price calculation
|
|
$chargeAmount = (float) $this->getNodeValue($line->xpath('ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:ChargeAmount'));
|
|
$basisQuantity = (float) $this->getNodeValue($line->xpath('ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:BasisQuantity'));
|
|
$basisQuantityUnit = (string) $this->getNodeAttribute($line->xpath('ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:BasisQuantity'), 'unitCode');
|
|
|
|
// Calculate real unit price: if BasisQuantity is e.g. 100 (meters), price is for 100 units
|
|
if ($basisQuantity > 0 && $basisQuantity != 1) {
|
|
$unitPrice = $chargeAmount / $basisQuantity;
|
|
} else {
|
|
$unitPrice = $chargeAmount;
|
|
}
|
|
|
|
// Extract copper surcharge (Kupferzuschlag) from AppliedTradeAllowanceCharge (v2)
|
|
$copperSurcharge = null;
|
|
$copperSurchargeBasisQty = null;
|
|
$allowanceCharges = $line->xpath('ram:SpecifiedLineTradeAgreement/ram:GrossPriceProductTradePrice/ram:AppliedTradeAllowanceCharge');
|
|
foreach ($allowanceCharges as $charge) {
|
|
$charge->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100');
|
|
$reason = (string) $this->getNodeValue($charge->xpath('ram:Reason'));
|
|
if (stripos($reason, 'Kupfer') !== false || stripos($reason, 'copper') !== false || stripos($reason, 'Metall') !== false) {
|
|
$copperSurcharge = (float) $this->getNodeValue($charge->xpath('ram:ActualAmount'));
|
|
$copperSurchargeBasisQty = (float) $this->getNodeValue($charge->xpath('ram:BasisQuantity'));
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Also check ApplicableProductCharacteristic for copper surcharge (v2)
|
|
if ($copperSurcharge === null) {
|
|
$characteristics = $line->xpath('ram:SpecifiedTradeProduct/ram:ApplicableProductCharacteristic');
|
|
foreach ($characteristics as $char) {
|
|
$char->registerXPathNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100');
|
|
$desc = (string) $this->getNodeValue($char->xpath('ram:Description'));
|
|
if (stripos($desc, 'Kupfer') !== false || stripos($desc, 'copper') !== false || stripos($desc, 'Metall') !== false) {
|
|
$copperSurcharge = (float) $this->getNodeValue($char->xpath('ram:Value'));
|
|
$copperSurchargeBasisQty = $basisQuantity ?: 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate copper surcharge per single unit
|
|
$copperSurchargePerUnit = null;
|
|
if ($copperSurcharge !== null && $copperSurcharge > 0) {
|
|
if ($copperSurchargeBasisQty > 0 && $copperSurchargeBasisQty != 1) {
|
|
$copperSurchargePerUnit = $copperSurcharge / $copperSurchargeBasisQty;
|
|
} else {
|
|
$copperSurchargePerUnit = $copperSurcharge;
|
|
}
|
|
}
|
|
|
|
$lineData = array(
|
|
'line_id' => (string) $this->getNodeValue($line->xpath('ram:AssociatedDocumentLineDocument/ram:LineID')),
|
|
'product' => array(
|
|
'seller_id' => (string) $this->getNodeValue($line->xpath('ram:SpecifiedTradeProduct/ram:SellerAssignedID')),
|
|
'buyer_id' => (string) $this->getNodeValue($line->xpath('ram:SpecifiedTradeProduct/ram:BuyerAssignedID')),
|
|
'global_id' => (string) $this->getNodeValue($line->xpath('ram:SpecifiedTradeProduct/ram:GlobalID')),
|
|
'name' => (string) $this->getNodeValue($line->xpath('ram:SpecifiedTradeProduct/ram:Name')),
|
|
'description' => (string) $this->getNodeValue($line->xpath('ram:SpecifiedTradeProduct/ram:Description')),
|
|
),
|
|
'quantity' => (float) $this->getNodeValue($line->xpath('ram:SpecifiedLineTradeDelivery/ram:BilledQuantity')),
|
|
'unit_code' => (string) $this->getNodeAttribute($line->xpath('ram:SpecifiedLineTradeDelivery/ram:BilledQuantity'), 'unitCode'),
|
|
'unit_price' => $unitPrice,
|
|
'unit_price_raw' => $chargeAmount,
|
|
'basis_quantity' => $basisQuantity ?: 1,
|
|
'basis_quantity_unit' => $basisQuantityUnit,
|
|
'line_total' => (float) $this->getNodeValue($line->xpath('ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/ram:LineTotalAmount')),
|
|
'tax_percent' => (float) $this->getNodeValue($line->xpath('ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent')),
|
|
// Copper surcharge data
|
|
'copper_surcharge' => $copperSurcharge,
|
|
'copper_surcharge_basis_qty' => $copperSurchargeBasisQty,
|
|
'copper_surcharge_per_unit' => $copperSurchargePerUnit,
|
|
);
|
|
|
|
$data['lines'][] = $lineData;
|
|
}
|
|
|
|
$this->invoice_data = $data;
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* Get value from XPath result
|
|
*
|
|
* @param string $xpath XPath expression
|
|
* @return string
|
|
*/
|
|
private function getXpathValue($xpath)
|
|
{
|
|
$result = $this->xml->xpath($xpath);
|
|
if (!empty($result)) {
|
|
return trim((string) $result[0]);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Get value from node array
|
|
*
|
|
* @param array $nodes XPath result array
|
|
* @return string
|
|
*/
|
|
private function getNodeValue($nodes)
|
|
{
|
|
if (!empty($nodes) && isset($nodes[0])) {
|
|
return trim((string) $nodes[0]);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Get attribute from node
|
|
*
|
|
* @param array $nodes XPath result array
|
|
* @param string $attr Attribute name
|
|
* @return string
|
|
*/
|
|
private function getNodeAttribute($nodes, $attr)
|
|
{
|
|
if (!empty($nodes) && isset($nodes[0])) {
|
|
$attributes = $nodes[0]->attributes();
|
|
if (isset($attributes[$attr])) {
|
|
return (string) $attributes[$attr];
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Parse date string in format YYYYMMDD or ISO
|
|
*
|
|
* @param string $dateStr Date string
|
|
* @return string Date in Y-m-d format
|
|
*/
|
|
private function parseDate($dateStr)
|
|
{
|
|
if (empty($dateStr)) {
|
|
return '';
|
|
}
|
|
|
|
// Format: YYYYMMDD
|
|
if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $dateStr, $matches)) {
|
|
return $matches[1] . '-' . $matches[2] . '-' . $matches[3];
|
|
}
|
|
|
|
// Format: YYYY-MM-DD or ISO
|
|
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $dateStr, $matches)) {
|
|
return $matches[1] . '-' . $matches[2] . '-' . $matches[3];
|
|
}
|
|
|
|
return $dateStr;
|
|
}
|
|
|
|
/**
|
|
* Get file hash for duplicate detection
|
|
*
|
|
* @param string $file_path Path to file
|
|
* @return string SHA256 hash
|
|
*/
|
|
public function getFileHash($file_path)
|
|
{
|
|
if (!file_exists($file_path)) {
|
|
return '';
|
|
}
|
|
return hash_file('sha256', $file_path);
|
|
}
|
|
|
|
/**
|
|
* Get invoice data
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getInvoiceData()
|
|
{
|
|
return $this->invoice_data;
|
|
}
|
|
|
|
/**
|
|
* Get XML content
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getXmlContent()
|
|
{
|
|
return $this->xml_content;
|
|
}
|
|
}
|