commit 77a1781182886b12b0e0c8bec81b2818d87f67b4 Author: data Date: Tue Mar 3 11:38:30 2026 +0100 feat: v5.5 - Kupferzuschlag-Skalierung und alternative Datanorm-Preise ## Behoben - Kupferzuschlag-Skalierung in Massenaktualisierung (Cu/qty * PE) - Steuersatz bei Preisübernahme wird beibehalten - Preise auf 2 Dezimalstellen gerundet ## Hinzugefügt - Filter für Preisrichtung (rauf/runter) - Filter-Persistenz nach Preisübernahme - Alternative Datanorm-Preise erben Mindestmenge, Verpackung, Steuersatz, kaufmenge - Extrafield kaufmenge sichtbar in Formularen ## Geändert - Kupferzuschlag wird NICHT vom Import gesetzt (separates Modul) Co-Authored-By: Claude Opus 4.5 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100755 index 0000000..8c926ed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,135 @@ +# Changelog + +Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert. + +## [5.5] - 2026-03-03 + +### Behoben +- **Kupferzuschlag-Skalierung in Massenaktualisierung**: Kupferzuschlag wird jetzt korrekt skaliert wenn Dolibarr-Mindestmenge von Datanorm-Preiseinheit abweicht + - Problem: Cu für 50m wurde direkt zu Datanorm-Preis für 100m addiert + - Lösung: Cu wird erst auf Stückpreis umgerechnet (`cu_per_unit = Cu / quantity`), dann auf Datanorm-PE skaliert +- **Steuersatz bei Preisübernahme**: `tva_tx` wird jetzt korrekt beibehalten statt auf 0 gesetzt + - Direktes SQL-UPDATE statt `update_buyprice()` um alle Felder zu erhalten +- **Preise auf 2 Dezimalstellen**: Gesamtpreis und Stückpreis werden auf 2 Nachkommastellen gerundet + +### Hinzugefügt +- **Filter für Preisrichtung**: Neue Checkboxen "Preise rauf" und "Preise runter" in Massenaktualisierung + - Ermöglicht gezieltes Filtern nach Preiserhöhungen oder -senkungen +- **Filter-Persistenz**: Alle Filter (inkl. hide_cables, filter_price_up, filter_price_down) bleiben nach Preisübernahme erhalten +- **Alternative Datanorm-Preise verbessert**: Beim Import von Rechnungen mit alternativen Datanorm-Katalogen werden jetzt übernommen: + - Mindestmenge vom vorhandenen/Hauptpreis + - Verpackungseinheit vom vorhandenen/Hauptpreis + - Steuersatz vom vorhandenen/Hauptpreis + - kaufmenge-Extrafield (nur wenn numerisch und > 0) +- **Extrafield kaufmenge sichtbar**: Feld wird jetzt in Formularen angezeigt (`list = 1`) + +### Geändert +- **Kupferzuschlag nicht automatisch gesetzt**: Bei Datanorm-Import wird kupferzuschlag NICHT mehr gesetzt - wird von separatem Modul berechnet + +### Technisch +- Kupferzuschlag-Berechnung: `cu_for_price_unit = (kupferzuschlag / effective_quantity) * price_unit` +- kaufmenge-Validierung: `trim() !== '' && is_numeric() && (int) > 0` + +## [4.2] - 2026-03-02 + +### Behoben +- **PDF-Anhänge**: ZUGFeRD-PDFs werden jetzt korrekt an Lieferantenrechnungen angehängt + - Problem: PDF wurde nur ins Dateisystem kopiert, nicht in ECM-Datenbank registriert + - Lösung: `EcmFiles`-Eintrag wird erstellt für korrekte Verknüpfung mit Rechnung + - Wichtig: Bei Rechnungsvalidierung wird PDF automatisch mitverschoben + +### Hinzugefügt +- **Bezeichnung in Rechnungsliste**: Teuerster Artikel wird als Bezeichnung der Lieferantenrechnung gesetzt + - Erleichtert schnelle Identifikation in der Rechnungsliste + - Spalte "Bezeichnung" muss in Liste aktiviert sein + +## [4.0] - 2026-03-01 + +### Behoben +- **Verschachtelte HTML-Forms**: "Ausgewählte Preise hinzufügen" funktionierte nicht, weil Browser verschachtelte `
`-Elemente nicht unterstützen. Lösung: HTML5 `form`-Attribut +- **Stückpreis-Anzeige**: Einkaufspreise zeigen jetzt den Stückpreis statt Gesamtpreis (z.B. 0,16 statt 16,32 bei 100 Stk.) +- **Preisvergleich**: Fehlende Lieferantenpreise werden korrekt auf Stückpreis-Basis verglichen +- **DATPREIS-Kommentare**: Feld korrekt als Rabattkennzeichen dokumentiert (war fälschlich als PE-Code beschrieben) + +### Verbessert +- **Feedback bei Preishinzufügen**: Zeigt Erfolgs- und Fehlermeldungen nach dem Hinzufügen von Lieferantenpreisen +- **Mengenkontext**: Bei Mengenstaffel wird zusätzlich der Gesamtpreis mit Stückzahl angezeigt (z.B. "0,16 (16,32/100Stk.)") + +### Hinweis +- `uk_product_barcode` UNIQUE KEY auf `product_fournisseur_price` muss entfernt werden falls vorhanden (mehrere Lieferanten dürfen gleichen EAN haben) + +## [3.8] - 2026-02-25 + +### Hinzugefügt +- **Kabel-Preisberechnung**: Zentrale Funktion `calculateCablePricing()` für einheitliche Preislogik +- **Kupfergehalt-Berechnung**: Automatische Berechnung aus Aderanzahl × Querschnitt × 8.9 +- **Ringgröße-Erkennung**: Unterstützt Ri100, Tr500, Fol.25m, "Ring 100m", "Trommel 500m" +- **Extrafield "produktpreis"**: Speichert reinen Materialpreis ohne Kupferzuschlag (nur Kabel) +- **EAN-Auto-Update**: Barcodes aus ZUGFeRD-Rechnungen werden automatisch in Lieferantenpreise übernommen + +### Verbessert +- **Lieferanten-Formate**: Korrekte Unterscheidung zwischen Sonepar (price_unit=1, Ring im Namen) und Kluxen/Witte (price_unit=100) +- **Cross-Catalog-Suche**: Nur noch über EAN, nicht mehr über Artikelnummern (verhindert Fehlzuordnungen) +- **EAN-Barcode-Typ**: Automatische Erkennung (EAN8, EAN13, UPC-A) statt hardcoded EAN13 +- **Error-Handling**: Besseres Logging bei Extrafield-Fehlern + +### Behoben +- Division durch Null bei Preisberechnung abgesichert +- Mindestbestellmenge und Verpackungseinheit werden von existierenden Lieferantenpreisen übernommen + +## [3.7] - 2026-02-23 + +### Hinzugefügt +- **GlobalNotify Integration**: Benachrichtigungen über das zentrale GlobalNotify-Modul + - Import-Fehler: Warnung bei fehlgeschlagenen Importen + - Rechnungen zur Prüfung: Aktion wenn neue Rechnungen warten + - IMAP-Fehler: Warnung wenn E-Mail Postfach nicht erreichbar + - Exception/Fatal: Sofortige Benachrichtigung bei Abstürzen +- **Helper-Funktion**: `notify()` für sichere GlobalNotify-Nutzung mit Fallback + +### Hinweis +GlobalNotify ist optional. Ohne das Modul werden Benachrichtigungen ins Dolibarr-Log geschrieben. + +## [3.6] - 2026-02-23 + +### Behoben +- **Cron-Job Fix**: Fehlendes `require_once` für `admin.lib.php` hinzugefügt - verhinderte das Speichern des letzten Laufzeitpunkts +- Cron-Job lief in Endlosschleife weil `dolibarr_set_const()` nicht gefunden wurde + +### Hinzugefügt +- **Dediziertes Cron-Logging**: Separate Log-Datei unter `/documents/importzugferd/logs/cron_importzugferd.log` +- **Shutdown Handler**: Fängt fatale PHP-Fehler ab und protokolliert sie +- **Detailliertes Logging**: Zeigt jeden Schritt des Import-Prozesses (Ordner-Zugriff, PDF-Scan, IMAP-Status) + +### Verbessert +- Robustere Fehlerbehandlung mit try/catch für Exceptions und Throwables +- IMAP-Import wird nur ausgeführt wenn tatsächlich konfiguriert + +## [3.5] - 2026-02-15 + +### Hinzugefügt +- Automatischer Cron-Import aus Watch-Folder +- IMAP-Mailbox-Unterstützung für E-Mail-Rechnungen +- Konfigurierbare Import-Frequenz (stündlich, täglich, wöchentlich) +- Archiv- und Fehler-Ordner für verarbeitete Dateien + +## [3.0] - 2026-02-01 + +### Hinzugefügt +- ZUGFeRD/Factur-X PDF-Parsing +- Automatische Lieferanten-Erkennung +- Rechnungsvorschau vor Import +- Datanorm-Integration für Artikelpreise + +## [2.0] - 2026-01-15 + +### Hinzugefügt +- Basis-Import von ZUGFeRD-Rechnungen +- Manuelle Datei-Auswahl +- Integration in Lieferantenrechnungen + +## [1.0] - 2026-01-01 + +### Erste Version +- Grundlegende ZUGFeRD-Erkennung +- XML-Extraktion aus PDF diff --git a/COPYING b/COPYING new file mode 100755 index 0000000..94a0453 --- /dev/null +++ b/COPYING @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/ChangeLog.md b/ChangeLog.md new file mode 100755 index 0000000..774beef --- /dev/null +++ b/ChangeLog.md @@ -0,0 +1,107 @@ +# Changelog + +Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert. + +## [4.2] - 2026-03-02 + +### Behoben +- **PDF-Anhänge**: ZUGFeRD-PDFs werden jetzt korrekt an Lieferantenrechnungen angehängt + - Problem: PDF wurde nur ins Dateisystem kopiert, nicht in ECM-Datenbank registriert + - Lösung: `EcmFiles`-Eintrag wird erstellt für korrekte Verknüpfung mit Rechnung + - Wichtig: Bei Rechnungsvalidierung wird PDF automatisch mitverschoben + +### Hinzugefügt +- **Bezeichnung in Rechnungsliste**: Teuerster Artikel wird als Bezeichnung der Lieferantenrechnung gesetzt + - Erleichtert schnelle Identifikation in der Rechnungsliste + - Spalte "Bezeichnung" muss in Liste aktiviert sein + +## [4.0] - 2026-03-01 + +### Behoben +- **Verschachtelte HTML-Forms**: "Ausgewählte Preise hinzufügen" funktionierte nicht, weil Browser verschachtelte ``-Elemente nicht unterstützen. Lösung: HTML5 `form`-Attribut +- **Stückpreis-Anzeige**: Einkaufspreise zeigen jetzt den Stückpreis statt Gesamtpreis (z.B. 0,16 statt 16,32 bei 100 Stk.) +- **Preisvergleich**: Fehlende Lieferantenpreise werden korrekt auf Stückpreis-Basis verglichen +- **DATPREIS-Kommentare**: Feld korrekt als Rabattkennzeichen dokumentiert (war fälschlich als PE-Code beschrieben) + +### Verbessert +- **Feedback bei Preishinzufügen**: Zeigt Erfolgs- und Fehlermeldungen nach dem Hinzufügen von Lieferantenpreisen +- **Mengenkontext**: Bei Mengenstaffel wird zusätzlich der Gesamtpreis mit Stückzahl angezeigt (z.B. "0,16 (16,32/100Stk.)") + +### Hinweis +- `uk_product_barcode` UNIQUE KEY auf `product_fournisseur_price` muss entfernt werden falls vorhanden (mehrere Lieferanten dürfen gleichen EAN haben) + +## [3.8] - 2026-02-25 + +### Hinzugefügt +- **Kabel-Preisberechnung**: Zentrale Funktion `calculateCablePricing()` für einheitliche Preislogik +- **Kupfergehalt-Berechnung**: Automatische Berechnung aus Aderanzahl × Querschnitt × 8.9 +- **Ringgröße-Erkennung**: Unterstützt Ri100, Tr500, Fol.25m, "Ring 100m", "Trommel 500m" +- **Extrafield "produktpreis"**: Speichert reinen Materialpreis ohne Kupferzuschlag (nur Kabel) +- **EAN-Auto-Update**: Barcodes aus ZUGFeRD-Rechnungen werden automatisch in Lieferantenpreise übernommen + +### Verbessert +- **Lieferanten-Formate**: Korrekte Unterscheidung zwischen Sonepar (price_unit=1, Ring im Namen) und Kluxen/Witte (price_unit=100) +- **Cross-Catalog-Suche**: Nur noch über EAN, nicht mehr über Artikelnummern (verhindert Fehlzuordnungen) +- **EAN-Barcode-Typ**: Automatische Erkennung (EAN8, EAN13, UPC-A) statt hardcoded EAN13 +- **Error-Handling**: Besseres Logging bei Extrafield-Fehlern + +### Behoben +- Division durch Null bei Preisberechnung abgesichert +- Mindestbestellmenge und Verpackungseinheit werden von existierenden Lieferantenpreisen übernommen + +## [3.7] - 2026-02-23 + +### Hinzugefügt +- **GlobalNotify Integration**: Benachrichtigungen über das zentrale GlobalNotify-Modul + - Import-Fehler: Warnung bei fehlgeschlagenen Importen + - Rechnungen zur Prüfung: Aktion wenn neue Rechnungen warten + - IMAP-Fehler: Warnung wenn E-Mail Postfach nicht erreichbar + - Exception/Fatal: Sofortige Benachrichtigung bei Abstürzen +- **Helper-Funktion**: `notify()` für sichere GlobalNotify-Nutzung mit Fallback + +### Hinweis +GlobalNotify ist optional. Ohne das Modul werden Benachrichtigungen ins Dolibarr-Log geschrieben. + +## [3.6] - 2026-02-23 + +### Behoben +- **Cron-Job Fix**: Fehlendes `require_once` für `admin.lib.php` hinzugefügt - verhinderte das Speichern des letzten Laufzeitpunkts +- Cron-Job lief in Endlosschleife weil `dolibarr_set_const()` nicht gefunden wurde + +### Hinzugefügt +- **Dediziertes Cron-Logging**: Separate Log-Datei unter `/documents/importzugferd/logs/cron_importzugferd.log` +- **Shutdown Handler**: Fängt fatale PHP-Fehler ab und protokolliert sie +- **Detailliertes Logging**: Zeigt jeden Schritt des Import-Prozesses (Ordner-Zugriff, PDF-Scan, IMAP-Status) + +### Verbessert +- Robustere Fehlerbehandlung mit try/catch für Exceptions und Throwables +- IMAP-Import wird nur ausgeführt wenn tatsächlich konfiguriert + +## [3.5] - 2026-02-15 + +### Hinzugefügt +- Automatischer Cron-Import aus Watch-Folder +- IMAP-Mailbox-Unterstützung für E-Mail-Rechnungen +- Konfigurierbare Import-Frequenz (stündlich, täglich, wöchentlich) +- Archiv- und Fehler-Ordner für verarbeitete Dateien + +## [3.0] - 2026-02-01 + +### Hinzugefügt +- ZUGFeRD/Factur-X PDF-Parsing +- Automatische Lieferanten-Erkennung +- Rechnungsvorschau vor Import +- Datanorm-Integration für Artikelpreise + +## [2.0] - 2026-01-15 + +### Hinzugefügt +- Basis-Import von ZUGFeRD-Rechnungen +- Manuelle Datei-Auswahl +- Integration in Lieferantenrechnungen + +## [1.0] - 2026-01-01 + +### Erste Version +- Grundlegende ZUGFeRD-Erkennung +- XML-Extraktion aus PDF diff --git a/README.md b/README.md new file mode 100755 index 0000000..dcf8b5f --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# ZUGFeRD Import for [Dolibarr ERP & CRM](https://www.dolibarr.org) + +Import ZUGFeRD/Factur-X electronic invoices as supplier invoices in Dolibarr. + +## Features + +### Core Functionality +- **PDF Import**: Upload ZUGFeRD/Factur-X PDF invoices with embedded XML data +- **XML Parsing**: Automatic extraction and parsing of invoice data from embedded XML +- **Duplicate Detection**: SHA256 hash-based detection prevents importing the same invoice twice +- **Supplier Detection**: Automatic supplier matching via VAT ID or customer reference number + +### Product Matching +- **Multi-Method Matching**: Products are matched via: + - Article mapping (supplier article number → your product) + - EAN/GTIN barcode + - Product reference + - Manufacturer reference +- **Manual Assignment**: Assign products manually when automatic matching fails +- **Product Creation**: Create new products directly from import data +- **Product Templates**: Duplicate existing products with ZUGFeRD data pre-filled +- **EAN Auto-Update**: Automatically updates product barcodes from invoice data + +### Workflow +- **Persistent Import Records**: Imports are saved to database immediately +- **Status Tracking**: + - `Imported` - Ready for invoice creation + - `Pending` - Manual intervention required (missing products/supplier) + - `Processed` - Supplier invoice created + - `Error` - Import failed +- **Resume Anytime**: Continue editing imports later +- **Sum Validation**: Validates totals between ZUGFeRD data and created invoice + +### Batch Import +- **Folder Monitoring**: Import from a local folder (watch folder) +- **IMAP Import**: Import from email mailbox +- **Automatic Archiving**: Successfully imported files are moved to archive + +### Unit Code Translation +- Translates UN/ECE unit codes (C62, MTR, LTR, etc.) to readable labels (Stk., m, l) + +## Requirements + +- Dolibarr 19.0 or higher +- PHP 7.1 or higher +- PHP IMAP extension (for email import functionality) + +## Installation + +1. Copy the `importzugferd` folder to your Dolibarr `custom` directory +2. Enable the module in **Setup > Modules > ZUGFeRD Import** +3. Configure settings in **ZUGFeRD Import > Setup** + +## Configuration + +### IMAP Settings (for email import) +- IMAP Server hostname +- Port (993 for SSL, 143 for STARTTLS) +- Username and password +- Mailbox folder to monitor +- Use **Test Connection** to verify settings and select folder + +### Folder Settings (for folder import) +- **Watch Folder**: Local path for incoming invoices +- **Archive Folder**: Local path for processed invoices +- **IMAP Archive Folder**: Email folder for processed emails + +### Import Settings +- **Auto-create invoices**: Automatically create supplier invoices during batch import + +## Usage + +### Manual Import +1. Go to **ZUGFeRD Import > Import Invoice** +2. Upload a ZUGFeRD/Factur-X PDF file +3. Review invoice data and line items +4. Assign missing products if needed +5. Select supplier (if not auto-detected) +6. Click **Create Supplier Invoice** + +### Batch Import +1. Go to **ZUGFeRD Import > Batch Import** +2. Select source (Folder or Email) +3. Click **Start Import** +4. Review results + +### Product Mapping +1. Go to **ZUGFeRD Import > Product Mapping** +2. Select supplier +3. Add mappings: Supplier article number → Your product + +## Extrafields + +The module adds a custom field to third parties: +- **Customer No. at Supplier**: Your customer number at this supplier (used for automatic supplier detection via buyer reference) + +## Translations + +Available in: +- German (de_DE) +- English (en_US) + +## Version History + +See [CHANGELOG.md](CHANGELOG.md) for detailed version history. + +### 5.5 (Current) +- Fixed copper surcharge scaling in mass update (different quantities between Dolibarr and Datanorm) +- Fixed VAT rate preservation when updating prices +- New filters for price direction (up/down) in mass update +- Alternative Datanorm prices inherit min quantity, packaging, VAT, and kaufmenge from existing prices +- Copper surcharge is NOT set by import - calculated by separate module + +### 4.2 +- PDF attachments properly linked to supplier invoices via ECM +- Most expensive item shown as invoice description + +### 3.8 +- Improved cable pricing for different supplier formats (Sonepar vs Kluxen/Witte) +- Automatic ring size detection from product names (Ri100, Tr500, etc.) +- EAN auto-update from ZUGFeRD invoices with automatic barcode type detection + +### 3.7 +- GlobalNotify integration for import notifications + +### 3.5 +- Automatic cron import from watch folder and IMAP + +### 3.0 +- Datanorm integration for article prices + +### 1.0 +- Initial release + +## License + +GPLv3 or (at your option) any later version. See file COPYING for more information. + +## Author + +Eduard Wisch - [data IT solution](https://data-it-solution.de) diff --git a/admin/about.php b/admin/about.php new file mode 100755 index 0000000..1a01181 --- /dev/null +++ b/admin/about.php @@ -0,0 +1,118 @@ + + * Copyright (C) 2026 Eduard Wisch + * Copyright (C) 2024 Frédéric France + * + * 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. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file importzugferd/admin/about.php + * \ingroup importzugferd + * \brief About page of module ImportZugferd. + */ + +// Load Dolibarr environment +$res = 0; +// Try main.inc.php into web root known defined into CONTEXT_DOCUMENT_ROOT (not always defined) +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +// Try main.inc.php into web root detected using web root calculated from SCRIPT_FILENAME +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +// Try main.inc.php using relative path +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +// Libraries +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php'; +require_once '../lib/importzugferd.lib.php'; + +/** + * @var Conf $conf + * @var DoliDB $db + * @var HookManager $hookmanager + * @var Translate $langs + * @var User $user + */ + +// Translations +$langs->loadLangs(array("errors", "admin", "importzugferd@importzugferd")); + +// Access control +if (!$user->admin) { + accessforbidden(); +} + +// Parameters +$action = GETPOST('action', 'aZ09'); +$backtopage = GETPOST('backtopage', 'alpha'); + + +/* + * Actions + */ + +// None + + +/* + * View + */ + +$form = new Form($db); + +$help_url = ''; +$title = "ImportZugferdSetup"; + +llxHeader('', $langs->trans($title), $help_url, '', 0, 0, '', '', '', 'mod-importzugferd page-admin_about'); + +// Subheader +$linkback = ''.$langs->trans("BackToModuleList").''; + +print load_fiche_titre($langs->trans($title), $linkback, 'title_setup'); + +// Configuration header +$head = importzugferdAdminPrepareHead(); +print dol_get_fiche_head($head, 'about', $langs->trans($title), 0, 'importzugferd@importzugferd'); + +dol_include_once('/importzugferd/core/modules/modImportZugferd.class.php'); +$tmpmodule = new modImportZugferd($db); +print $tmpmodule->getDescLong(); + +// Page end +print dol_get_fiche_end(); +llxFooter(); +$db->close(); diff --git a/admin/setup.php b/admin/setup.php new file mode 100755 index 0000000..05b9a55 --- /dev/null +++ b/admin/setup.php @@ -0,0 +1,870 @@ + + * + * 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 importzugferd/admin/setup.php + * \ingroup importzugferd + * \brief ImportZugferd setup page. + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +// Libraries +require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php"; +require_once '../lib/importzugferd.lib.php'; + +// Translations +$langs->loadLangs(array("admin", "importzugferd@importzugferd")); + +// Parameters +$action = GETPOST('action', 'aZ09'); +$backtopage = GETPOST('backtopage', 'alpha'); + +// Access control +if (!$user->admin) { + accessforbidden(); +} + +// Form setup using FormSetup class +if (!class_exists('FormSetup')) { + require_once DOL_DOCUMENT_ROOT.'/core/class/html.formsetup.class.php'; +} +$formSetup = new FormSetup($db); + +/* + * Setup configuration items + */ + +// IMAP Settings Section +$formSetup->newItem('IMAPSettings')->setAsTitle(); + +$item = $formSetup->newItem('IMPORTZUGFERD_IMAP_HOST'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth300'; +$item->fieldAttr['placeholder'] = 'imap.example.com'; + +$item = $formSetup->newItem('IMPORTZUGFERD_IMAP_PORT'); +$item->defaultFieldValue = '993'; +$item->cssClass = 'width100'; + +$item = $formSetup->newItem('IMPORTZUGFERD_IMAP_USER'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth300'; +$item->fieldAttr['placeholder'] = 'invoices@example.com'; + +$item = $formSetup->newItem('IMPORTZUGFERD_IMAP_PASSWORD'); +$item->cssClass = 'minwidth300'; +$item->fieldAttr['type'] = 'password'; + +$item = $formSetup->newItem('IMPORTZUGFERD_IMAP_FOLDER'); +$item->defaultFieldValue = 'INBOX'; +$item->cssClass = 'minwidth200'; + +$formSetup->newItem('IMPORTZUGFERD_IMAP_SSL')->setAsYesNo(); + +// Import Settings Section +$formSetup->newItem('ImportSettings')->setAsTitle(); + +$formSetup->newItem('IMPORTZUGFERD_AUTO_CREATE_INVOICE')->setAsYesNo(); + +// Email Notification Settings Section +$formSetup->newItem('NotificationSettings')->setAsTitle(); + +$formSetup->newItem('IMPORTZUGFERD_NOTIFY_ENABLED')->setAsYesNo(); + +$item = $formSetup->newItem('IMPORTZUGFERD_NOTIFY_EMAIL'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth300'; +$item->fieldAttr['placeholder'] = 'admin@example.com'; + +$formSetup->newItem('IMPORTZUGFERD_NOTIFY_MANUAL')->setAsYesNo(); +$formSetup->newItem('IMPORTZUGFERD_NOTIFY_ERROR')->setAsYesNo(); +$formSetup->newItem('IMPORTZUGFERD_NOTIFY_PRICE_DIFF')->setAsYesNo(); + +$item = $formSetup->newItem('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD'); +$item->defaultFieldValue = '10'; +$item->cssClass = 'width75'; +$item->fieldAttr['type'] = 'number'; +$item->fieldAttr['min'] = '0'; +$item->fieldAttr['max'] = '100'; +$item->fieldAttr['step'] = '1'; + +// Scheduling Settings Section +$formSetup->newItem('SchedulingSettings')->setAsTitle(); + +$item = $formSetup->newItem('IMPORTZUGFERD_IMPORT_FREQUENCY'); +$item->setAsSelect(array( + 'manual' => $langs->trans('FrequencyManual'), + 'hourly' => $langs->trans('FrequencyHourly'), + 'daily' => $langs->trans('FrequencyDaily'), + 'weekly' => $langs->trans('FrequencyWeekly') +)); +$item->defaultFieldValue = 'manual'; + +// Folder Import Settings Section +$formSetup->newItem('FolderImportSettings')->setAsTitle(); + +$item = $formSetup->newItem('IMPORTZUGFERD_WATCH_FOLDER'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth400'; +$item->fieldAttr['placeholder'] = '/path/to/invoices'; + +$item = $formSetup->newItem('IMPORTZUGFERD_ARCHIVE_FOLDER'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth400'; +$item->fieldAttr['placeholder'] = '/path/to/archive'; + +$item = $formSetup->newItem('IMPORTZUGFERD_ERROR_FOLDER'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth400'; +$item->fieldAttr['placeholder'] = '/path/to/errors'; + +$item = $formSetup->newItem('IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER'); +$item->defaultFieldValue = 'Archive'; +$item->cssClass = 'minwidth200'; + +// Datanorm Settings Section +$formSetup->newItem('DatanormSettings')->setAsTitle(); + +$item = $formSetup->newItem('IMPORTZUGFERD_DATANORM_MARKUP'); +$item->defaultFieldValue = '30'; +$item->cssClass = 'width100'; +$item->fieldAttr['placeholder'] = '30'; + +$formSetup->newItem('IMPORTZUGFERD_DATANORM_SEARCH_ALL')->setAsYesNo(); + +// Accounting Codes Section (Standard-Konten für neue Produkte) +$formSetup->newItem('AccountingSettings')->setAsTitle(); + +$item = $formSetup->newItem('IMPORTZUGFERD_ACCOUNTING_CODE_SELL'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth200'; +$item->fieldAttr['placeholder'] = '700000'; + +$item = $formSetup->newItem('IMPORTZUGFERD_ACCOUNTING_CODE_SELL_INTRA'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth200'; +$item->fieldAttr['placeholder'] = '700100'; + +$item = $formSetup->newItem('IMPORTZUGFERD_ACCOUNTING_CODE_SELL_EXPORT'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth200'; +$item->fieldAttr['placeholder'] = '700200'; + +$item = $formSetup->newItem('IMPORTZUGFERD_ACCOUNTING_CODE_BUY'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth200'; +$item->fieldAttr['placeholder'] = '400000'; + +$item = $formSetup->newItem('IMPORTZUGFERD_ACCOUNTING_CODE_BUY_INTRA'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth200'; +$item->fieldAttr['placeholder'] = '400100'; + +$item = $formSetup->newItem('IMPORTZUGFERD_ACCOUNTING_CODE_BUY_EXPORT'); +$item->defaultFieldValue = ''; +$item->cssClass = 'minwidth200'; +$item->fieldAttr['placeholder'] = '400200'; + +/* + * Actions + */ + +if (versioncompare(explode('.', DOL_VERSION), array(15)) < 0 && $action == 'update' && !empty($user->admin)) { + $formSetup->saveConfFromPost(); +} + +include DOL_DOCUMENT_ROOT.'/core/actions_setmoduleoptions.inc.php'; + +// AJAX action for folder browsing +if ($action == 'browse_folders') { + $path = GETPOST('path', 'alpha'); + $target = GETPOST('target', 'alpha'); + + // Sanitize path - default to /home for easier navigation + if (empty($path)) { + $path = '/home'; + } + $path = realpath($path); + if ($path === false) { + $path = '/home'; + if (!is_dir($path)) { + $path = '/'; + } + } + + // Get directories + $dirs = array(); + if (is_dir($path) && is_readable($path)) { + $entries = @scandir($path); + if ($entries) { + foreach ($entries as $entry) { + if ($entry == '.') continue; + $fullPath = $path . '/' . $entry; + if (is_dir($fullPath) && is_readable($fullPath)) { + $dirs[] = array( + 'name' => $entry, + 'path' => $fullPath + ); + } + } + } + } + + // Return JSON + header('Content-Type: application/json'); + echo json_encode(array( + 'current' => $path, + 'parent' => dirname($path), + 'dirs' => $dirs, + 'target' => $target + )); + exit; +} + +// Save folder from browser +if ($action == 'set_folder') { + $target = GETPOST('target', 'alpha'); + $folder_path = GETPOST('folder_path', 'alpha'); + + if (in_array($target, array('IMPORTZUGFERD_WATCH_FOLDER', 'IMPORTZUGFERD_ARCHIVE_FOLDER'))) { + if (is_dir($folder_path)) { + dolibarr_set_const($db, $target, $folder_path, 'chaine', 0, '', $conf->entity); + setEventMessages($langs->trans('FolderSelected').': '.$folder_path, null, 'mesgs'); + } else { + setEventMessages($langs->trans('ErrorFolderNotFound'), null, 'errors'); + } + } + header('Location: '.$_SERVER['PHP_SELF']); + exit; +} + +/* + * View + */ + +$form = new Form($db); + +$title = "ImportZugferdSetup"; +llxHeader('', $langs->trans($title), '', '', 0, 0, '', '', '', 'mod-importzugferd page-admin'); + +// Subheader +$linkback = ''.$langs->trans("BackToModuleList").''; + +print load_fiche_titre($langs->trans($title), $linkback, 'title_setup'); + +// Configuration header +$head = importzugferdAdminPrepareHead(); +print dol_get_fiche_head($head, 'settings', $langs->trans($title), -1, "importzugferd@importzugferd"); + +// Setup page description +print ''.$langs->trans("ImportZugferdSetupPage").'

'; + +// Display the form +print $formSetup->generateOutput(true); + +// Build folder validation data for JavaScript +$folderValidation = array(); + +// Watch folder - only needs to be readable +$watchFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER'); +if (!empty($watchFolder)) { + $watchExists = is_dir($watchFolder); + $watchReadable = $watchExists && is_readable($watchFolder); + if (!$watchExists) { + $folderValidation['IMPORTZUGFERD_WATCH_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotFound')); + } elseif (!$watchReadable) { + $folderValidation['IMPORTZUGFERD_WATCH_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotReadable')); + } else { + $files = glob($watchFolder.'/*.pdf'); + $files = array_merge($files ?: [], glob($watchFolder.'/*.PDF') ?: []); + $folderValidation['IMPORTZUGFERD_WATCH_FOLDER'] = array('ok' => true, 'msg' => $langs->trans('FolderOK').' ('.count($files).' PDF)'); + } +} + +// Archive folder - needs to be writable +$archiveFolder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER'); +if (!empty($archiveFolder)) { + $archiveExists = is_dir($archiveFolder); + $archiveWritable = $archiveExists && is_writable($archiveFolder); + if (!$archiveExists) { + $folderValidation['IMPORTZUGFERD_ARCHIVE_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotFound')); + } elseif (!$archiveWritable) { + $folderValidation['IMPORTZUGFERD_ARCHIVE_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotWritable')); + } else { + $folderValidation['IMPORTZUGFERD_ARCHIVE_FOLDER'] = array('ok' => true, 'msg' => $langs->trans('FolderOK')); + } +} + +// Error folder - needs to be writable +$errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER'); +if (!empty($errorFolder)) { + $errorExists = is_dir($errorFolder); + $errorWritable = $errorExists && is_writable($errorFolder); + if (!$errorExists) { + $folderValidation['IMPORTZUGFERD_ERROR_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotFound')); + } elseif (!$errorWritable) { + $folderValidation['IMPORTZUGFERD_ERROR_FOLDER'] = array('ok' => false, 'msg' => $langs->trans('FolderNotWritable')); + } else { + $folderValidation['IMPORTZUGFERD_ERROR_FOLDER'] = array('ok' => true, 'msg' => $langs->trans('FolderOK')); + } +} + +// Folder Browser Modal +print ' + + + +'; + +// Email Notification Test Section +if (getDolGlobalString('IMPORTZUGFERD_NOTIFY_ENABLED') && getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL')) { + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + + // Handle test email action + if ($action == 'test_email') { + dol_include_once('/importzugferd/class/importnotification.class.php'); + $notification = new ImportNotification($db); + $result = $notification->sendTestNotification(); + + if ($result > 0) { + setEventMessages($langs->trans('TestEmailSent', getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL')), null, 'mesgs'); + } else { + setEventMessages($langs->trans('TestEmailFailed').': '.$notification->error, null, 'errors'); + } + } + + print ''; + print ''; + print ''; + print ''; + + print '
'.$langs->trans('TestEmailNotification').'
'.$langs->trans('SendTestEmail').''; + print ''; + print ''.$langs->trans('SendTestEmail'); + print ''; + print ' '.$langs->trans('SendTo').': '.getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL').''; + print '
'; + print '
'; +} + +// Test IMAP connection button and folder selection +if (getDolGlobalString('IMPORTZUGFERD_IMAP_HOST')) { + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + + // Check if IMAP extension is available + $imap_available = function_exists('imap_open'); + + // Test connection action + $imap_folders = array(); + $connection_ok = false; + + if (!$imap_available) { + print ''; + print ''; + print ''; + } elseif ($action == 'test_imap' || $action == 'select_folder') { + $host = getDolGlobalString('IMPORTZUGFERD_IMAP_HOST'); + $port = getDolGlobalString('IMPORTZUGFERD_IMAP_PORT', '993'); + $imap_user = getDolGlobalString('IMPORTZUGFERD_IMAP_USER'); + $password = getDolGlobalString('IMPORTZUGFERD_IMAP_PASSWORD'); + $ssl = getDolGlobalString('IMPORTZUGFERD_IMAP_SSL'); + + $mailbox_base = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}'; + $mailbox = $mailbox_base . 'INBOX'; + + $connection = @imap_open($mailbox, $imap_user, $password); + + if ($connection) { + $connection_ok = true; + setEventMessages($langs->trans('ConnectionSuccessful'), null, 'mesgs'); + + // Get list of folders + $folders_raw = imap_list($connection, $mailbox_base, '*'); + if ($folders_raw) { + foreach ($folders_raw as $folder) { + // Remove the mailbox base from folder name + $folder_name = str_replace($mailbox_base, '', $folder); + // Decode folder name (IMAP uses modified UTF-7) + $folder_name_decoded = imap_utf7_decode($folder_name); + $imap_folders[$folder_name] = $folder_name_decoded; + } + } + + imap_close($connection); + } else { + setEventMessages($langs->trans('ConnectionFailed') . ': ' . imap_last_error(), null, 'errors'); + } + } + + // Save selected folder + if ($action == 'select_folder' && GETPOST('imap_folder', 'alpha')) { + $selected_folder = GETPOST('imap_folder', 'alpha'); + dolibarr_set_const($db, 'IMPORTZUGFERD_IMAP_FOLDER', $selected_folder, 'chaine', 0, '', $conf->entity); + setEventMessages($langs->trans('FolderSelected').': '.$selected_folder, null, 'mesgs'); + } + + // Only show status and folder selection if IMAP is available + if ($imap_available) { + print ''; + print ''; + print ''; + print ''; + } + + // Show folder selection if connection was successful + if ($imap_available && $connection_ok && !empty($imap_folders)) { + print ''; + print ''; + print ''; + print ''; + print ''; + } + + // Only show test button if IMAP extension is available + if ($imap_available) { + print ''; + print ''; + print ''; + } + + print '
'.$langs->trans('TestConnection').'
'; + print ''; + print $langs->trans('IMAPExtensionNotInstalled'); + print '
'; + print ''.$langs->trans('IMAPExtensionHelp').''; + print '
'.$langs->trans('Status').''; + if ($action == 'test_imap' || $action == 'select_folder') { + if ($connection_ok) { + print ''.$langs->trans('ConnectionSuccessful').''; + } else { + print ''.$langs->trans('ConnectionFailed').''; + } + } else { + print ''.$langs->trans('ClickTestToCheck').''; + } + print '
'.$langs->trans('SelectFolder').''; + print ''; + print ''; + print ''; + + $current_folder = getDolGlobalString('IMPORTZUGFERD_IMAP_FOLDER', 'INBOX'); + print ''; + print ' '; + print ''; + print ''; + print ''.$langs->trans('FoundFolders').': '.count($imap_folders).''; + print '
'; + print ''; + print ''.$langs->trans('TestConnection'); + print ''; + print '
'; + print '
'; +} + +// Manual Import Trigger Section +$hasFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER') && is_dir(getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER')); +$hasImap = getDolGlobalString('IMPORTZUGFERD_IMAP_HOST') && getDolGlobalString('IMPORTZUGFERD_IMAP_USER'); + +if ($hasFolder || $hasImap) { + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + + // Handle manual import action + if ($action == 'run_import') { + $source = GETPOST('import_source', 'alpha'); + dol_include_once('/importzugferd/class/zugferdimport.class.php'); + dol_include_once('/importzugferd/class/zugferdparser.class.php'); + + $successCount = 0; + $errorCount = 0; + $skippedCount = 0; + + if ($source == 'folder' && $hasFolder) { + $watchFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER'); + $archiveFolder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER'); + $errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER'); + $autoCreate = getDolGlobalInt('IMPORTZUGFERD_AUTO_CREATE_INVOICE'); + + $files = glob($watchFolder.'/*.pdf'); + $files = array_merge($files, glob($watchFolder.'/*.PDF')); + + // Create archive folder if configured but doesn't exist + if (!empty($archiveFolder) && !is_dir($archiveFolder)) { + dol_mkdir($archiveFolder); + } + // Create error folder if configured but doesn't exist + if (!empty($errorFolder) && !is_dir($errorFolder)) { + dol_mkdir($errorFolder); + } + + // Helper function for moving files with fallback + $moveFile = function($file, $targetFolder, $prefix) { + if (empty($targetFolder)) { + return false; + } + if (!is_dir($targetFolder)) { + dol_mkdir($targetFolder); + } + if (!is_dir($targetFolder) || !is_writable($targetFolder)) { + dol_syslog("ImportZugferd: Target folder not accessible: ".$targetFolder, LOG_WARNING); + return false; + } + $destFile = $targetFolder.'/'.$prefix.date('Y-m-d_His').'_'.basename($file); + if (@rename($file, $destFile)) { + dol_syslog("ImportZugferd: Moved file to: ".$destFile, LOG_INFO); + return true; + } + // Fallback: copy + delete (for cross-filesystem moves) + if (@copy($file, $destFile)) { + @unlink($file); + dol_syslog("ImportZugferd: Copied file to: ".$destFile, LOG_INFO); + return true; + } + dol_syslog("ImportZugferd: Failed to move file: ".$file." to ".$destFile, LOG_ERR); + return false; + }; + + foreach ($files as $file) { + $import = new ZugferdImport($db); + $result = $import->importFromFile($user, $file, $autoCreate); + + if ($result > 0) { + $successCount++; + $moveFile($file, $archiveFolder, 'imported_'); + } elseif ($result == -2) { + // Duplicate - move to archive + $skippedCount++; + if (!$moveFile($file, $archiveFolder, 'duplicate_')) { + @unlink($file); + } + } else { + // Error - move to error folder, fallback to archive + $errorCount++; + if (!$moveFile($file, $errorFolder, 'error_')) { + if (!$moveFile($file, $archiveFolder, 'error_')) { + dol_syslog("ImportZugferd: File stays in watch folder: ".$file, LOG_WARNING); + } + } + } + } + } elseif ($source == 'imap' && $hasImap && function_exists('imap_open')) { + $host = getDolGlobalString('IMPORTZUGFERD_IMAP_HOST'); + $port = getDolGlobalString('IMPORTZUGFERD_IMAP_PORT', '993'); + $imap_user = getDolGlobalString('IMPORTZUGFERD_IMAP_USER'); + $password = getDolGlobalString('IMPORTZUGFERD_IMAP_PASSWORD'); + $ssl = getDolGlobalString('IMPORTZUGFERD_IMAP_SSL'); + $folder = getDolGlobalString('IMPORTZUGFERD_IMAP_FOLDER', 'INBOX'); + $archiveFolder = getDolGlobalString('IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER'); + $autoCreate = getDolGlobalInt('IMPORTZUGFERD_AUTO_CREATE_INVOICE'); + + $mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder; + $connection = @imap_open($mailbox, $imap_user, $password); + + if ($connection) { + $emails = imap_search($connection, 'UNSEEN'); + if ($emails) { + foreach ($emails as $email_number) { + $structure = imap_fetchstructure($connection, $email_number); + + // Find PDF attachments + if (isset($structure->parts)) { + foreach ($structure->parts as $partIndex => $part) { + $filename = ''; + if ($part->ifdparameters) { + foreach ($part->dparameters as $param) { + if (strtolower($param->attribute) == 'filename') { + $filename = $param->value; + } + } + } + if (empty($filename) && $part->ifparameters) { + foreach ($part->parameters as $param) { + if (strtolower($param->attribute) == 'name') { + $filename = $param->value; + } + } + } + + if (!empty($filename) && preg_match('/\.pdf$/i', $filename)) { + $attachment = imap_fetchbody($connection, $email_number, $partIndex + 1); + if ($part->encoding == 3) { // BASE64 + $attachment = base64_decode($attachment); + } elseif ($part->encoding == 4) { // QUOTED-PRINTABLE + $attachment = quoted_printable_decode($attachment); + } + + // Save to temp file + $tempFile = $conf->importzugferd->dir_temp.'/'.uniqid().'_'.$filename; + if (!is_dir($conf->importzugferd->dir_temp)) { + dol_mkdir($conf->importzugferd->dir_temp); + } + file_put_contents($tempFile, $attachment); + + // Import + $import = new ZugferdImport($db); + $result = $import->importFromFile($user, $tempFile, $autoCreate); + + if ($result > 0) { + $successCount++; + } elseif ($result == -2) { + $skippedCount++; + } else { + $errorCount++; + } + + unlink($tempFile); + } + } + } + } + + // Archive processed emails + if (!empty($archiveFolder) && $successCount > 0) { + foreach ($emails as $email_number) { + imap_mail_move($connection, $email_number, $archiveFolder); + } + imap_expunge($connection); + } + } + imap_close($connection); + } else { + setEventMessages($langs->trans('ConnectionFailed').': '.imap_last_error(), null, 'errors'); + } + } + + if ($successCount > 0 || $errorCount > 0 || $skippedCount > 0) { + setEventMessages($langs->trans('BatchImportComplete', $successCount, $errorCount, $skippedCount), null, 'mesgs'); + } else { + setEventMessages($langs->trans('NoFilesFound'), null, 'warnings'); + } + } + + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + + print '
'.$langs->trans('ManualImportTrigger').'
'.$langs->trans('ImportFromFolder').''; + if ($hasFolder) { + print ''; + print ''.$langs->trans('StartImport'); + print ''; + print ' '.getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER').''; + } else { + print ''.$langs->trans('ErrorWatchFolderNotConfigured').''; + } + print '
'.$langs->trans('ImportFromIMAP').''; + if ($hasImap && function_exists('imap_open')) { + print ''; + print ''.$langs->trans('StartImport'); + print ''; + print ' '.getDolGlobalString('IMPORTZUGFERD_IMAP_USER').''; + } elseif (!function_exists('imap_open')) { + print ''.$langs->trans('IMAPExtensionNotInstalled').''; + } else { + print ''.$langs->trans('ErrorIMAPNotConfigured').''; + } + print '
'; + print '
'; +} + +print '
'; + +// Page end +print dol_get_fiche_end(); + +llxFooter(); +$db->close(); diff --git a/batch.php b/batch.php new file mode 100755 index 0000000..52cb220 --- /dev/null +++ b/batch.php @@ -0,0 +1,444 @@ + 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; + +dol_include_once('/importzugferd/class/actions_importzugferd.class.php'); +dol_include_once('/importzugferd/lib/importzugferd.lib.php'); + +// Load translation files +$langs->loadLangs(array("importzugferd@importzugferd", "bills", "products")); + +// Security check +if (!$user->hasRight('importzugferd', 'import', 'write')) { + accessforbidden(); +} + +// Get parameters +$action = GETPOST('action', 'aZ09'); +$source = GETPOST('source', 'alpha'); + +// Initialize objects +$actions = new ActionsImportZugferd($db); + +$import_results = array(); +$error = 0; + +/* + * Actions + */ + +// Process batch import +if ($action == 'process') { + $auto_create = getDolGlobalString('IMPORTZUGFERD_AUTO_CREATE_INVOICE'); + + if ($source == 'folder') { + // Import from local folder + $watch_folder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER'); + $archive_folder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER'); + $error_folder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER'); + + if (empty($watch_folder) || !is_dir($watch_folder)) { + setEventMessages($langs->trans('ErrorWatchFolderNotConfigured'), null, 'errors'); + $error++; + } else { + // Create archive folder if needed + if (!empty($archive_folder) && !is_dir($archive_folder)) { + dol_mkdir($archive_folder); + } + // Create error folder if needed + if (!empty($error_folder) && !is_dir($error_folder)) { + dol_mkdir($error_folder); + } + + // Get PDF files from watch folder + $files = glob($watch_folder . '/*.pdf'); + if (empty($files)) { + $files = glob($watch_folder . '/*.PDF'); + } + + if (!empty($files)) { + foreach ($files as $pdf_path) { + $result = array( + 'file' => basename($pdf_path), + 'status' => 'error', + 'message' => '', + 'invoice_id' => 0, + ); + + $res = $actions->processPdf($pdf_path, $user, $auto_create); + + if ($res > 0) { + $result['status'] = 'success'; + $result['message'] = $langs->trans('ImportSuccessful'); + $import_data = $actions->getResult(); + $result['invoice_id'] = $import_data['invoice_id']; + + // Move to archive + if (!empty($archive_folder) && is_dir($archive_folder)) { + $archive_path = $archive_folder . '/success_' . date('Y-m-d_His') . '_' . basename($pdf_path); + if (@rename($pdf_path, $archive_path) || (@copy($pdf_path, $archive_path) && @unlink($pdf_path))) { + $result['archived'] = true; + } + } + } elseif ($res == -3) { + // Duplicate - move to archive (already imported) + $result['status'] = 'skipped'; + $result['message'] = $langs->trans('ErrorDuplicateInvoice'); + + if (!empty($archive_folder) && is_dir($archive_folder)) { + $archive_path = $archive_folder . '/duplicate_' . date('Y-m-d_His') . '_' . basename($pdf_path); + if (@rename($pdf_path, $archive_path) || (@copy($pdf_path, $archive_path) && @unlink($pdf_path))) { + $result['archived'] = true; + } + } + } else { + // Error - move to error folder + $result['message'] = $actions->error; + + if (!empty($error_folder) && is_dir($error_folder)) { + $error_path = $error_folder . '/error_' . date('Y-m-d_His') . '_' . basename($pdf_path); + if (@rename($pdf_path, $error_path) || (@copy($pdf_path, $error_path) && @unlink($pdf_path))) { + $result['moved_to_error'] = true; + } + } + } + + $import_results[] = $result; + } + } else { + setEventMessages($langs->trans('NoFilesFound'), null, 'warnings'); + } + } + } elseif ($source == 'imap') { + // Import from IMAP + if (!function_exists('imap_open')) { + setEventMessages($langs->trans('IMAPExtensionNotInstalled'), null, 'errors'); + $error++; + } else { + $host = getDolGlobalString('IMPORTZUGFERD_IMAP_HOST'); + $port = getDolGlobalString('IMPORTZUGFERD_IMAP_PORT', '993'); + $imap_user = getDolGlobalString('IMPORTZUGFERD_IMAP_USER'); + $password = getDolGlobalString('IMPORTZUGFERD_IMAP_PASSWORD'); + $ssl = getDolGlobalString('IMPORTZUGFERD_IMAP_SSL'); + $folder = getDolGlobalString('IMPORTZUGFERD_IMAP_FOLDER', 'INBOX'); + $archive_folder = getDolGlobalString('IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER', 'Archive'); + + if (empty($host) || empty($imap_user)) { + setEventMessages($langs->trans('ErrorIMAPNotConfigured'), null, 'errors'); + $error++; + } else { + $mailbox_base = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}'; + $mailbox = $mailbox_base . $folder; + + $connection = @imap_open($mailbox, $imap_user, $password); + + if ($connection) { + // Search for emails with PDF attachments + $emails = imap_search($connection, 'ALL'); + + if ($emails) { + // Create temp directory for attachments + $temp_dir = $conf->importzugferd->dir_output . '/temp'; + if (!is_dir($temp_dir)) { + dol_mkdir($temp_dir); + } + + foreach ($emails as $email_num) { + $structure = imap_fetchstructure($connection, $email_num); + $attachments = array(); + + // Find PDF attachments + if (isset($structure->parts)) { + foreach ($structure->parts as $part_num => $part) { + $filename = ''; + if ($part->ifdparameters) { + foreach ($part->dparameters as $param) { + if (strtolower($param->attribute) == 'filename') { + $filename = $param->value; + } + } + } + if (empty($filename) && $part->ifparameters) { + foreach ($part->parameters as $param) { + if (strtolower($param->attribute) == 'name') { + $filename = $param->value; + } + } + } + + // Check if PDF + if (!empty($filename) && preg_match('/\.pdf$/i', $filename)) { + $attachments[] = array( + 'filename' => $filename, + 'part_num' => $part_num + 1, + 'encoding' => $part->encoding, + ); + } + } + } + + // Process each PDF attachment + $email_processed = false; + foreach ($attachments as $attachment) { + $data = imap_fetchbody($connection, $email_num, $attachment['part_num']); + + // Decode attachment + if ($attachment['encoding'] == 3) { // BASE64 + $data = base64_decode($data); + } elseif ($attachment['encoding'] == 4) { // QUOTED-PRINTABLE + $data = quoted_printable_decode($data); + } + + // Save to temp file + $temp_file = $temp_dir . '/' . uniqid() . '_' . $attachment['filename']; + file_put_contents($temp_file, $data); + + $result = array( + 'file' => $attachment['filename'], + 'status' => 'error', + 'message' => '', + 'invoice_id' => 0, + ); + + // Process the PDF + $res = $actions->processPdf($temp_file, $user, $auto_create); + + if ($res > 0) { + $result['status'] = 'success'; + $result['message'] = $langs->trans('ImportSuccessful'); + $import_data = $actions->getResult(); + $result['invoice_id'] = $import_data['invoice_id']; + $email_processed = true; + } elseif ($res == -3) { + $result['status'] = 'skipped'; + $result['message'] = $langs->trans('ErrorDuplicateInvoice'); + } else { + $result['message'] = $actions->error; + } + + // Clean up temp file + @unlink($temp_file); + + $import_results[] = $result; + } + + // Move email to archive folder if successfully processed + if ($email_processed && !empty($archive_folder)) { + $archive_mailbox = $mailbox_base . $archive_folder; + @imap_mail_move($connection, $email_num, $archive_folder); + } + } + + // Expunge to apply moves + imap_expunge($connection); + } else { + setEventMessages($langs->trans('NoEmailsFound'), null, 'warnings'); + } + + imap_close($connection); + } else { + setEventMessages($langs->trans('ConnectionFailed') . ': ' . imap_last_error(), null, 'errors'); + $error++; + } + } + } + } + + if (!empty($import_results)) { + $success_count = 0; + $error_count = 0; + $skipped_count = 0; + foreach ($import_results as $r) { + if ($r['status'] == 'success') $success_count++; + elseif ($r['status'] == 'skipped') $skipped_count++; + else $error_count++; + } + setEventMessages($langs->trans('BatchImportComplete', $success_count, $error_count, $skipped_count), null, 'mesgs'); + } +} + +/* + * View + */ + +$form = new Form($db); + +$title = $langs->trans('BatchImport'); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-batch'); + +print load_fiche_titre($title, '', 'fa-file-import'); + +// Check configuration +$watch_folder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER'); +$imap_host = getDolGlobalString('IMPORTZUGFERD_IMAP_HOST'); + +if (empty($watch_folder) && empty($imap_host)) { + print '
'.$langs->trans('BatchImportNotConfigured').'
'; + print '
'.$langs->trans('ConfigureModule').''; +} else { + // Source selection + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + + // Folder option + if (!empty($watch_folder)) { + print ''; + print ''; + print ''; + } + + // IMAP option + if (!empty($imap_host)) { + print ''; + print ''; + print ''; + } + + print '
'.$langs->trans('SelectSource').'
'; + print '
'; + print ''; + print ''; + print ''; + + print '
'; + print ''; + print '
'; + print '
'; + print ''.$langs->trans('ImportFromFolder').'
'; + print ''.$watch_folder.''; + + // Count files + $files = glob($watch_folder . '/*.pdf'); + if (empty($files)) $files = glob($watch_folder . '/*.PDF'); + $file_count = !empty($files) ? count($files) : 0; + print '
'.$file_count.' '.$langs->trans('Files').''; + + print '
'; + print '
'; + print ''; + print '
'; + + print '
'; + print '
'; + print '
'; + print ''; + print ''; + print ''; + + print '
'; + print ''; + print '
'; + print '
'; + print ''.$langs->trans('ImportFromIMAP').'
'; + print ''.$imap_host.' / '.getDolGlobalString('IMPORTZUGFERD_IMAP_FOLDER', 'INBOX').''; + print '
'; + print '
'; + if (function_exists('imap_open')) { + print ''; + } else { + print ''.$langs->trans('IMAPExtensionNotInstalled').''; + } + print '
'; + + print '
'; + print '
'; + print '
'; + print '
'; + + // Show results + if (!empty($import_results)) { + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($import_results as $result) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + + print '
'.$langs->trans('File').''.$langs->trans('Status').''.$langs->trans('Message').''.$langs->trans('SupplierInvoice').'
'.dol_escape_htmltag($result['file']).''; + if ($result['status'] == 'success') { + print ''.$langs->trans('Success').''; + if (!empty($result['archived'])) { + print ' '; + } + } elseif ($result['status'] == 'skipped') { + print ''.$langs->trans('Skipped').''; + } else { + print ''.$langs->trans('Error').''; + } + print ''.dol_escape_htmltag($result['message']).''; + if ($result['invoice_id'] > 0) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $invoice = new FactureFournisseur($db); + $invoice->fetch($result['invoice_id']); + print $invoice->getNomUrl(1); + } else { + print '-'; + } + print '
'; + print '
'; + } +} + +llxFooter(); +$db->close(); diff --git a/bin/module_importzugferd-4.4.zip b/bin/module_importzugferd-4.4.zip new file mode 100755 index 0000000..f5dcd04 Binary files /dev/null and b/bin/module_importzugferd-4.4.zip differ diff --git a/bin/module_importzugferd-4.5.zip b/bin/module_importzugferd-4.5.zip new file mode 100755 index 0000000..5f5ec43 Binary files /dev/null and b/bin/module_importzugferd-4.5.zip differ diff --git a/bin/module_importzugferd-4.6.zip b/bin/module_importzugferd-4.6.zip new file mode 100755 index 0000000..d397445 Binary files /dev/null and b/bin/module_importzugferd-4.6.zip differ diff --git a/bin/module_importzugferd-4.8.zip b/bin/module_importzugferd-4.8.zip new file mode 100755 index 0000000..b1fd19c Binary files /dev/null and b/bin/module_importzugferd-4.8.zip differ diff --git a/bin/module_importzugferd-4.9.zip b/bin/module_importzugferd-4.9.zip new file mode 100755 index 0000000..9ec87bb Binary files /dev/null and b/bin/module_importzugferd-4.9.zip differ diff --git a/bin/module_importzugferd-5.0.zip b/bin/module_importzugferd-5.0.zip new file mode 100755 index 0000000..64ee13d Binary files /dev/null and b/bin/module_importzugferd-5.0.zip differ diff --git a/bin/module_importzugferd-5.1.zip b/bin/module_importzugferd-5.1.zip new file mode 100755 index 0000000..1c7a038 Binary files /dev/null and b/bin/module_importzugferd-5.1.zip differ diff --git a/bin/module_importzugferd-5.2.zip b/bin/module_importzugferd-5.2.zip new file mode 100755 index 0000000..ff3a2d5 Binary files /dev/null and b/bin/module_importzugferd-5.2.zip differ diff --git a/bin/module_importzugferd-5.3.zip b/bin/module_importzugferd-5.3.zip new file mode 100755 index 0000000..bc8f5a1 Binary files /dev/null and b/bin/module_importzugferd-5.3.zip differ diff --git a/bin/module_importzugferd-5.4.zip b/bin/module_importzugferd-5.4.zip new file mode 100755 index 0000000..cb1eea5 Binary files /dev/null and b/bin/module_importzugferd-5.4.zip differ diff --git a/build/buildzip.php b/build/buildzip.php new file mode 100755 index 0000000..3508bbb --- /dev/null +++ b/build/buildzip.php @@ -0,0 +1,316 @@ +#!/usr/bin/env php -d memory_limit=256M + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* + The goal of that php CLI script is to make zip package of your module + as an alternative to web "build zip" or "perl script makepack" +*/ + +// ============================================= configuration + +/** + * list of files & dirs of your module + * + * @var string[] + */ +$listOfModuleContent = [ + 'admin', + 'ajax', + 'backport', + 'class', + 'css', + 'COPYING', + 'core', + 'img', + 'js', + 'langs', + 'lib', + 'sql', + 'tpl', + '*.md', + '*.json', + '*.php', + 'modulebuilder.txt', +]; + +/** + * if you want to exclude some files from your zip + * + * @var string[] + */ +$exclude_list = [ + '/^.git$/', + '/.*js.map/', + '/DEV.md/' +]; + +// ============================================= end of configuration + +/** + * auto detect module name and version from file name + * + * @return (string|string)[] module name and module version + */ +function detectModule() +{ + $name = $version = ""; + $tab = glob("core/modules/mod*.class.php"); + if (count($tab) == 0) { + echo "[fail] Error on auto detect data : there is no mod*.class.php file into core/modules dir\n"; + exit(-1); + } + if (count($tab) == 1) { + $file = $tab[0]; + $pattern = "/.*mod(?.*)\.class\.php/"; + if (preg_match_all($pattern, $file, $matches)) { + $name = strtolower(reset($matches['mod'])); + } + + echo "extract data from $file\n"; + if (!file_exists($file) || $name == "") { + echo "[fail] Error on auto detect data\n"; + exit(-2); + } + } else { + echo "[fail] Error there is more than one mod*.class.php file into core/modules dir\n"; + exit(-3); + } + + //extract version from file + $contents = file_get_contents($file); + $pattern = "/^.*this->version\s*=\s*'(?.*)'\s*;.*\$/m"; + + // search, and store all matching occurrences in $matches + if (preg_match_all($pattern, $contents, $matches)) { + $version = reset($matches['version']); + } + + if (version_compare($version, '0.0.1', '>=') != 1) { + echo "[fail] Error auto extract version fail\n"; + exit(-4); + } + + echo "module name = $name, version = $version\n"; + return [(string) $name, (string) $version]; +} + +/** + * delete recursively a directory + * + * @param string $dir dir path to delete + * + * @return bool true on success or false on failure. + */ +function delTree($dir) +{ + $files = array_diff(scandir($dir), array('.', '..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? delTree("$dir/$file") : secureUnlink("$dir/$file"); + } + return rmdir($dir); +} + + +/** + * do a secure delete file/dir with double check + * (don't trust unlink return) + * + * @param string $path full path to delete + * + * @return bool true on success ($path does not exists at the end of process), else exit + */ +function secureUnlink($path) +{ + if (file_exists($path)) { + if (unlink($path)) { + //then check if really deleted + clearstatcache(); + if (file_exists($path)) { // @phpstan-ignore-line + echo "[fail] unlink of $path fail !\n"; + exit(-5); + } + } else { + echo "[fail] unlink of $path fail !\n"; + exit(-6); + } + } + return true; +} + +/** + * create a directory and check if dir exists + * + * @param string $path path to make + * + * @return bool true on success ($path exists at the end of process), else exit + */ +function mkdirAndCheck($path) +{ + if (mkdir($path)) { + clearstatcache(); + if (is_dir($path)) { + return true; + } + } + echo "[fail] Error on $path (mkdir)\n"; + exit(7); +} + +/** + * check if that filename is concerned by exclude filter + * + * @param string $filename file name to check + * + * @return bool true if file is in excluded list + */ +function is_excluded($filename) +{ + global $exclude_list; + $count = 0; + $notused = preg_filter($exclude_list, '1', $filename, -1, $count); + if ($count > 0) { + echo " - exclude $filename\n"; + return true; + } + return false; +} + +/** + * recursive copy files & dirs + * + * @param string $src source dir + * @param string $dst target dir + * + * @return bool true on success or false on failure. + */ +function rcopy($src, $dst) +{ + if (is_dir($src)) { + // Make the destination directory if not exist + mkdirAndCheck($dst); + // open the source directory + $dir = opendir($src); + + // Loop through the files in source directory + while ($file = readdir($dir)) { + if (($file != '.') && ($file != '..')) { + if (is_dir($src . '/' . $file)) { + // Recursively calling custom copy function + // for sub directory + if (!rcopy($src . '/' . $file, $dst . '/' . $file)) { + return false; + } + } else { + if (!is_excluded($file)) { + if (!copy($src . '/' . $file, $dst . '/' . $file)) { + return false; + } + } + } + } + } + closedir($dir); + } elseif (is_file($src)) { + if (!is_excluded($src)) { + if (!copy($src, $dst)) { + return false; + } + } + } + return true; +} + +/** + * build a zip file with only php code and no external depends + * on "zip" exec for example + * + * @param string $folder folder to use as zip root + * @param ZipArchive $zip zip object (ZipArchive) + * @param string $root relative root path into the zip + * + * @return bool true on success or false on failure. + */ +function zipDir($folder, &$zip, $root = "") +{ + foreach (new \DirectoryIterator($folder) as $f) { + if ($f->isDot()) { + continue; + } //skip . .. + $src = $folder . '/' . $f; + $dst = substr($f->getPathname(), strlen($root)); + if ($f->isDir()) { + if ($zip->addEmptyDir($dst)) { + if (zipDir($src, $zip, $root)) { + continue; + } else { + return false; + } + } else { + return false; + } + } + if ($f->isFile()) { + if (! $zip->addFile($src, $dst)) { + return false; + } + } + } + return true; +} + +/** + * main part of script + */ + +list($mod, $version) = detectModule(); +$outzip = sys_get_temp_dir() . "/module_" . $mod . "-" . $version . ".zip"; +if (file_exists($outzip)) { + secureUnlink($outzip); +} + +//copy all sources into system temp directory +$tmpdir = tempnam(sys_get_temp_dir(), $mod . "-module"); +secureUnlink($tmpdir); +mkdirAndCheck($tmpdir); +$dst = $tmpdir . "/" . $mod; +mkdirAndCheck($dst); + +foreach ($listOfModuleContent as $moduleContent) { + foreach (glob($moduleContent) as $entry) { + if (!rcopy($entry, $dst . '/' . $entry)) { + echo "[fail] Error on copy " . $entry . " to " . $dst . "/" . $entry . "\n"; + echo "Please take time to analyze the problem and fix the bug\n"; + exit(-8); + } + } +} + +$z = new ZipArchive(); +$z->open($outzip, ZIPARCHIVE::CREATE); +zipDir($tmpdir, $z, $tmpdir . '/'); +$z->close(); +delTree($tmpdir); +if (file_exists($outzip)) { + echo "[success] module archive is ready : $outzip ...\n"; +} else { + echo "[fail] build zip error\n"; + exit(-9); +} diff --git a/build/makepack-importzugferd.conf b/build/makepack-importzugferd.conf new file mode 100755 index 0000000..16dc1e7 --- /dev/null +++ b/build/makepack-importzugferd.conf @@ -0,0 +1,11 @@ +# Your module name here +# +# Goal: Goal of module +# Version: +# Author: Copyright - +# License: GPLv3 +# Install: Just unpack content of module package in Dolibarr directory. +# Setup: Go on Dolibarr setup - modules to enable module. +# +# Files in module +mymodule/ \ No newline at end of file diff --git a/card.php b/card.php new file mode 100755 index 0000000..2279d9c --- /dev/null +++ b/card.php @@ -0,0 +1,275 @@ + 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + +dol_include_once('/importzugferd/class/zugferdimport.class.php'); +dol_include_once('/importzugferd/lib/importzugferd.lib.php'); + +// Load translation files +$langs->loadLangs(array("importzugferd@importzugferd", "bills", "companies")); + +// Get parameters +$id = GETPOST('id', 'int'); +$ref = GETPOST('ref', 'alpha'); +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); + +// Initialize object +$object = new ZugferdImport($db); + +// Load object +if ($id > 0 || !empty($ref)) { + $result = $object->fetch($id, $ref); + if ($result <= 0) { + setEventMessages($langs->trans('RecordNotFound'), null, 'errors'); + header('Location: '.dol_buildpath('/importzugferd/list.php', 1)); + exit; + } +} + +// Security check +if (!$user->hasRight('importzugferd', 'import', 'read')) { + accessforbidden(); +} + +$permissiontodelete = $user->hasRight('importzugferd', 'import', 'delete'); + +/* + * Actions + */ + +// Delete confirmation +if ($action == 'delete' && $confirm == 'yes' && $permissiontodelete) { + $result = $object->delete($user); + if ($result > 0) { + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + header('Location: '.dol_buildpath('/importzugferd/list.php', 1)); + exit; + } else { + setEventMessages($object->error, $object->errors, 'errors'); + } +} + +/* + * View + */ + +$form = new Form($db); + +$title = $langs->trans('ImportRecord').' - '.$object->ref; +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-card'); + +// Confirmation dialog for delete +if ($action == 'delete') { + print $form->formconfirm( + $_SERVER["PHP_SELF"].'?id='.$object->id, + $langs->trans('DeleteImportRecord'), + $langs->trans('ConfirmDeleteImportRecord', $object->ref), + 'delete', + '', + 0, + 1 + ); +} + +// Header +print '
'; +print '
'; + +print ''; + +// Ref +print ''; +print ''; +print ''; +print ''; + +// Invoice number +print ''; +print ''; +print ''; +print ''; + +// Invoice date +print ''; +print ''; +print ''; +print ''; + +// Seller +print ''; +print ''; +print ''; +print ''; + +// VAT ID +print ''; +print ''; +print ''; +print ''; + +// Buyer reference +print ''; +print ''; +print ''; +print ''; + +// Total HT +print ''; +print ''; +print ''; +print ''; + +// Total TTC +print ''; +print ''; +print ''; +print ''; + +// Supplier invoice +print ''; +print ''; +print ''; +print ''; + +// Status +print ''; +print ''; +print ''; +print ''; + +// Error message +if ($object->status == ZugferdImport::STATUS_ERROR && !empty($object->error_message)) { + print ''; + print ''; + print ''; + print ''; +} + +// PDF filename +print ''; +print ''; +print ''; +print ''; + +// Date creation +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans('Ref').''.$object->ref.'
'.$langs->trans('InvoiceNumber').''.dol_escape_htmltag($object->invoice_number).'
'.$langs->trans('InvoiceDate').''.dol_print_date($object->invoice_date, 'day').'
'.$langs->trans('Supplier').''; +if ($object->fk_soc > 0) { + $supplier = new Societe($db); + $supplier->fetch($object->fk_soc); + print $supplier->getNomUrl(1); + print ' ('.dol_escape_htmltag($object->seller_name).')'; +} else { + print dol_escape_htmltag($object->seller_name); +} +print '
'.$langs->trans('VATIntra').''.dol_escape_htmltag($object->seller_vat).'
'.$langs->trans('BuyerReference').''.dol_escape_htmltag($object->buyer_reference).'
'.$langs->trans('TotalHT').''.price($object->total_ht).' '.$object->currency.'
'.$langs->trans('TotalTTC').''.price($object->total_ttc).' '.$object->currency.'
'.$langs->trans('SupplierInvoice').''; +if ($object->fk_facture_fourn > 0) { + $invoice = new FactureFournisseur($db); + $invoice->fetch($object->fk_facture_fourn); + print $invoice->getNomUrl(1); +} else { + print '-'; +} +print '
'.$langs->trans('Status').''.$object->getLibStatut(1).'
'.$langs->trans('ErrorMessage').''.dol_escape_htmltag($object->error_message).'
'.$langs->trans('File').''.dol_escape_htmltag($object->pdf_filename).'
'.$langs->trans('DateCreation').''.dol_print_date($object->date_creation, 'dayhour').'
'; + +print '
'; + +// Action buttons +print '
'; + +// Reimport button - link to import page +print ''.$langs->trans('ImportAnother').''; + +// Delete button +if ($permissiontodelete) { + print ''.$langs->trans('Delete').''; +} + +print '
'; + +// Show XML content (collapsed) +if (!empty($object->xml_content)) { + // Format XML for better readability using class method + $formattedXml = ZugferdImport::formatXmlForDisplay($object->xml_content); + + // XML Syntax-Highlighting + $highlightedXml = dol_escape_htmltag($formattedXml); + // Tag-Namen (oeffnend und schliessend) + $highlightedXml = preg_replace('/(<\/?)([\w:.-]+)/', '$1$2', $highlightedXml); + // Attribut-Namen und -Werte + $highlightedXml = preg_replace('/ ([\w:.-]+)(=)(")(.*?)(")/', ' $1$2$3$4$5', $highlightedXml); + + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print '
'.$langs->trans('XMLContent').'
'; + print ''.$langs->trans('ClickToExpand').''; + print ''; + print '
'; + print '
'; +} + +llxFooter(); +$db->close(); diff --git a/class/actions_importzugferd.class.php b/class/actions_importzugferd.class.php new file mode 100755 index 0000000..fb77881 --- /dev/null +++ b/class/actions_importzugferd.class.php @@ -0,0 +1,1067 @@ +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; + } + + // Update supplier price with EAN from invoice if empty + $invoiceEan = !empty($line['product']['global_id']) ? trim($line['product']['global_id']) : ''; + $supplierRef = !empty($line['product']['seller_id']) ? $line['product']['seller_id'] : ''; + if (!empty($invoiceEan) && !empty($supplierRef) && ctype_digit($invoiceEan)) { + // Barcode-Typ basierend auf Länge bestimmen + $eanLen = strlen($invoiceEan); + if ($eanLen == 13) { + $barcodeType = 2; // EAN13 + } elseif ($eanLen == 8) { + $barcodeType = 1; // EAN8 + } elseif ($eanLen == 12) { + $barcodeType = 3; // UPC-A + } else { + $barcodeType = 0; // Unbekannt + } + + $sqlEan = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlEan .= " SET barcode = '" . $this->db->escape($invoiceEan) . "'"; + $sqlEan .= ", fk_barcode_type = " . (int)$barcodeType; + $sqlEan .= " WHERE fk_product = " . (int)$match['fk_product']; + $sqlEan .= " AND fk_soc = " . (int)$supplier_id; + $sqlEan .= " AND ref_fourn = '" . $this->db->escape($supplierRef) . "'"; + $sqlEan .= " AND (barcode IS NULL OR barcode = '')"; + $this->db->query($sqlEan); + } + } 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'; + + // Set invoice label to the most expensive item's name + $invoice->libelle = $this->getMostExpensiveItemName($this->result['lines']); + + $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 the name of the most expensive item from invoice lines + * + * @param array $lines Processed invoice lines + * @return string Name of the most expensive item + */ + public function getMostExpensiveItemName($lines) + { + $maxTotal = 0; + $itemName = ''; + + foreach ($lines as $line) { + // Skip metal surcharge lines + if (!empty($line['is_metal_surcharge'])) { + continue; + } + + $lineTotal = (float) $line['line_total']; + if ($lineTotal > $maxTotal) { + $maxTotal = $lineTotal; + // Use product label if available, otherwise use name from invoice + $itemName = !empty($line['product_label']) ? $line['product_label'] : $line['name']; + } + } + + return $itemName; + } + + /** + * 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; + } + + /** + * Hook to add info box on products/services dashboard page + * + * @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 dashboardProductsServices($parameters, &$object, &$action, $hookmanager) + { + global $langs, $user, $conf; + + if (!$user->hasRight('produit', 'lire')) { + return 0; + } + + $langs->load('importzugferd@importzugferd'); + + // Count products with ref starting with "New" + $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) { + $url = dol_buildpath('/importzugferd/new_products.php', 1); + + $this->resprints = ' +
+
+
+ + + +
+ ' . $langs->trans("NewProductsToReview") . ' + + ' . $count . ' ' . $langs->trans("Products") . ' + +
+
+
+
'; + } + } + + return 0; + } +} diff --git a/class/cron_importzugferd.class.php b/class/cron_importzugferd.class.php new file mode 100755 index 0000000..0a4b90c --- /dev/null +++ b/class/cron_importzugferd.class.php @@ -0,0 +1,791 @@ +getMessage(), LOG_ERR); + return false; + } + } + + /** + * Constructor + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + global $conf; + $this->db = $db; + + // Set up dedicated log file for cron jobs + $logDir = $conf->importzugferd->dir_output.'/logs'; + if (!is_dir($logDir)) { + dol_mkdir($logDir); + } + $this->cronLogFile = $logDir.'/cron_importzugferd.log'; + } + + /** + * Write to dedicated cron log file + * + * @param string $message Log message + * @param string $level Log level (INFO, WARNING, ERROR, DEBUG) + * @return void + */ + private function cronLog($message, $level = 'INFO') + { + $timestamp = date('Y-m-d H:i:s'); + $elapsed = $this->startTime > 0 ? round(microtime(true) - $this->startTime, 2).'s' : '0s'; + $logLine = "[{$timestamp}] [{$level}] [{$elapsed}] {$message}\n"; + + @file_put_contents($this->cronLogFile, $logLine, FILE_APPEND | LOCK_EX); + dol_syslog("CronImportZugferd: ".$message, $level === 'ERROR' ? LOG_ERR : ($level === 'WARNING' ? LOG_WARNING : LOG_INFO)); + } + + /** + * Shutdown handler to catch fatal errors + */ + public function handleShutdown() + { + $error = error_get_last(); + if ($error !== null && in_array($error['type'], array(E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE))) { + $message = "FATAL SHUTDOWN: {$error['message']} in {$error['file']}:{$error['line']}"; + $this->cronLog($message, 'ERROR'); + $this->cronLog("========== CRON END (fatal shutdown) =========="); + } + } + + /** + * Check if import should run based on configured frequency + * + * @return bool True if import should run + */ + protected function shouldRunImport() + { + global $conf; + + $frequency = getDolGlobalString('IMPORTZUGFERD_IMPORT_FREQUENCY', 'manual'); + + if ($frequency === 'manual') { + return false; + } + + // Get last run timestamp + $lastRun = getDolGlobalInt('IMPORTZUGFERD_LAST_IMPORT_RUN', 0); + $now = dol_now(); + + // Calculate minimum interval based on frequency + $interval = 0; + switch ($frequency) { + case 'hourly': + $interval = 3600; // 1 hour + break; + case 'daily': + $interval = 86400; // 24 hours + break; + case 'weekly': + $interval = 604800; // 7 days + break; + } + + // Check if enough time has passed + if ($now - $lastRun < $interval) { + return false; + } + + return true; + } + + /** + * Update last run timestamp + */ + protected function updateLastRunTime() + { + global $conf; + dolibarr_set_const($this->db, 'IMPORTZUGFERD_LAST_IMPORT_RUN', dol_now(), 'chaine', 0, '', $conf->entity); + } + + /** + * Main import method - imports from both folder and mailbox + * + * @return int 0 if OK, <0 if error + */ + public function runScheduledImport() + { + global $langs; + + // Initialize timing and shutdown handler + $this->startTime = microtime(true); + register_shutdown_function(array($this, 'handleShutdown')); + + $langs->load('importzugferd@importzugferd'); + + $this->cronLog("========== CRON START =========="); + $this->cronLog("PHP Version: ".PHP_VERSION.", Memory Limit: ".ini_get('memory_limit')); + + // Reset counters + $this->imported_count = 0; + $this->skipped_count = 0; + $this->error_count = 0; + $this->errors = array(); + + try { + $this->cronLog("Starting folder import..."); + $this->importFromFolder(); + $this->cronLog("Folder import completed"); + + // IMAP nur wenn konfiguriert + if (!empty(getDolGlobalString('IMPORTZUGFERD_IMAP_HOST'))) { + $this->cronLog("Starting IMAP import..."); + $this->fetchFromMailbox(); + $this->cronLog("IMAP import completed"); + } else { + $this->cronLog("IMAP not configured - skipping"); + } + + // Update last run time + $this->updateLastRunTime(); + + // Build combined output + $this->output = sprintf( + "Scheduled import complete. Imported: %d, Skipped: %d, Errors: %d", + $this->imported_count, + $this->skipped_count, + $this->error_count + ); + + if ($this->error_count > 0 && !empty($this->errors)) { + $this->output .= "\nErrors: " . implode(", ", array_slice($this->errors, 0, 5)); + } + + $duration = round(microtime(true) - $this->startTime, 2); + $this->cronLog("Completed: imported={$this->imported_count}, skipped={$this->skipped_count}, errors={$this->error_count}, duration={$duration}s"); + $this->cronLog("========== CRON END (success) =========="); + + // Send GlobalNotify notifications + $this->sendImportNotifications(); + + return ($this->error_count > 0) ? -1 : 0; + + } catch (Exception $e) { + $this->error = 'Exception: '.$e->getMessage(); + $this->cronLog("EXCEPTION: ".$e->getMessage()."\n".$e->getTraceAsString(), 'ERROR'); + $this->cronLog("========== CRON END (exception) =========="); + $this->notify( + 'error', + 'ZUGFeRD Import fehlgeschlagen', + 'Exception: '.$e->getMessage(), + dol_buildpath('/importzugferd/admin/setup.php', 1), + 'Einstellungen prüfen' + ); + return -1; + } catch (Throwable $t) { + $this->error = 'Fatal: '.$t->getMessage(); + $this->cronLog("FATAL: ".$t->getMessage()."\n".$t->getTraceAsString(), 'ERROR'); + $this->cronLog("========== CRON END (fatal) =========="); + $this->notify( + 'error', + 'ZUGFeRD Import Absturz', + 'Fatal: '.$t->getMessage(), + dol_buildpath('/importzugferd/admin/setup.php', 1), + 'Einstellungen prüfen' + ); + return -1; + } + } + + /** + * Import ZUGFeRD invoices from watch folder + * + * @return int 0 if OK, <0 if error + */ + public function importFromFolder() + { + global $langs; + + $langs->load('importzugferd@importzugferd'); + + $watchFolder = getDolGlobalString('IMPORTZUGFERD_WATCH_FOLDER'); + $archiveFolder = getDolGlobalString('IMPORTZUGFERD_ARCHIVE_FOLDER'); + $errorFolder = getDolGlobalString('IMPORTZUGFERD_ERROR_FOLDER'); + $autoCreate = getDolGlobalString('IMPORTZUGFERD_AUTO_CREATE_INVOICE'); + + $this->cronLog("Watch folder: {$watchFolder}"); + + // Validate settings + if (empty($watchFolder)) { + $this->cronLog("Watch folder not configured - skipping"); + $this->output = 'Watch folder not configured'; + return 0; + } + + if (!is_dir($watchFolder)) { + $this->cronLog("Watch folder not accessible: {$watchFolder}", 'WARNING'); + $this->output = 'Watch folder not accessible'; + return 0; + } + + $this->cronLog("Watch folder accessible, scanning for PDFs..."); + + // Load admin user for import actions + $admin_user = new User($this->db); + $admin_user->fetch(1); + + // Find PDF files + $this->cronLog("Running glob for *.pdf..."); + $files = glob($watchFolder . '/*.pdf'); + $this->cronLog("Found ".count($files)." .pdf files"); + + $this->cronLog("Running glob for *.PDF..."); + $filesUpper = glob($watchFolder . '/*.PDF'); + $this->cronLog("Found ".count($filesUpper)." .PDF files"); + + $files = array_merge($files, $filesUpper); + + if (empty($files)) { + $this->cronLog("No PDF files found in watch folder"); + return 0; + } + + $this->cronLog("Total ".count($files)." PDF files to process"); + + // Ensure archive folder exists if configured + if (!empty($archiveFolder) && !is_dir($archiveFolder)) { + dol_mkdir($archiveFolder); + } + + // Ensure error folder exists if configured + if (!empty($errorFolder) && !is_dir($errorFolder)) { + dol_mkdir($errorFolder); + } + + foreach ($files as $file) { + $this->cronLog("Processing: ".basename($file)); + + // Use ZugferdImport::importFromFile for consistent handling + $import = new ZugferdImport($this->db); + $result = $import->importFromFile($admin_user, $file, !empty($autoCreate)); + + if ($result > 0) { + $this->imported_count++; + $this->cronLog("Imported: ".basename($file)." -> ID {$result}"); + + // Archive the file + $this->moveFile($file, $archiveFolder, 'imported_'); + } elseif ($result == -2) { + // Duplicate - already imported + $this->skipped_count++; + $this->cronLog("Skipped (duplicate): ".basename($file)); + + // Archive duplicates - delete if no archive folder + if (!$this->moveFile($file, $archiveFolder, 'duplicate_')) { + @unlink($file); + } + } else { + $this->error_count++; + $this->errors[] = basename($file) . ': ' . $import->error; + $this->cronLog("Error importing ".basename($file).": ".$import->error, 'ERROR'); + + // Try error folder first, fall back to archive folder + if (!$this->moveFile($file, $errorFolder, 'error_')) { + // Use archive folder as fallback for errors + $this->moveFile($file, $archiveFolder, 'error_'); + } + } + } + + $this->cronLog("Folder import finished: imported={$this->imported_count}, skipped={$this->skipped_count}, errors={$this->error_count}"); + return 0; + } + + /** + * Fetch ZUGFeRD invoices from configured IMAP mailbox + * + * @return int 0 if OK, <0 if error + */ + public function fetchFromMailbox() + { + global $conf, $user, $langs; + + $langs->load('importzugferd@importzugferd'); + + // Get IMAP settings + $host = getDolGlobalString('IMPORTZUGFERD_IMAP_HOST'); + $port = getDolGlobalString('IMPORTZUGFERD_IMAP_PORT', '993'); + $imap_user = getDolGlobalString('IMPORTZUGFERD_IMAP_USER'); + $password = getDolGlobalString('IMPORTZUGFERD_IMAP_PASSWORD'); + $folder = getDolGlobalString('IMPORTZUGFERD_IMAP_FOLDER', 'INBOX'); + $ssl = getDolGlobalString('IMPORTZUGFERD_IMAP_SSL'); + $auto_create = getDolGlobalString('IMPORTZUGFERD_AUTO_CREATE_INVOICE'); + + // Validate settings + if (empty($host) || empty($imap_user) || empty($password)) { + $this->error = 'IMAP settings not configured'; + $this->output = $this->error; + return -1; + } + + // Build mailbox string + $mailbox = '{' . $host . ':' . $port . '/imap'; + if ($ssl) { + $mailbox .= '/ssl'; + } + $mailbox .= '/novalidate-cert}' . $folder; + + // Connect to IMAP + $connection = @imap_open($mailbox, $imap_user, $password); + + if (!$connection) { + $this->error = 'IMAP connection failed: ' . imap_last_error(); + $this->output = $this->error; + return -2; + } + + // Search for unread messages with attachments + $messages = imap_search($connection, 'UNSEEN'); + + if ($messages === false) { + $this->output = 'No new messages found'; + imap_close($connection); + return 0; + } + + $temp_dir = $conf->importzugferd->dir_output . '/temp'; + if (!is_dir($temp_dir)) { + dol_mkdir($temp_dir); + } + + // Load admin user for import actions + $admin_user = new User($this->db); + $admin_user->fetch(1); // Fetch admin user + + $actions = new ActionsImportZugferd($this->db); + + foreach ($messages as $msg_num) { + $structure = imap_fetchstructure($connection, $msg_num); + + // Check for attachments + $attachments = $this->getAttachments($connection, $msg_num, $structure); + + foreach ($attachments as $attachment) { + // Check if it's a PDF + if (strtolower($attachment['type']) !== 'pdf') { + continue; + } + + // Save attachment temporarily + $temp_file = $temp_dir . '/' . uniqid('zugferd_') . '.pdf'; + file_put_contents($temp_file, $attachment['data']); + + // Check if it's a ZUGFeRD PDF + $parser = new ZugferdParser($this->db); + $result = $parser->extractFromPdf($temp_file); + + if ($result > 0) { + // It's a ZUGFeRD invoice, try to import + $result = $actions->processPdf($temp_file, $admin_user, $auto_create); + + if ($result > 0) { + $this->imported_count++; + dol_syslog("CronImportZugferd: Imported invoice from email, ID: " . $result, LOG_INFO); + } elseif ($result == -3) { + // Duplicate + $this->skipped_count++; + dol_syslog("CronImportZugferd: Skipped duplicate invoice", LOG_INFO); + } else { + $this->error_count++; + $this->errors[] = $actions->error; + dol_syslog("CronImportZugferd: Error importing invoice: " . $actions->error, LOG_WARNING); + } + } + + // Clean up temp file + if (file_exists($temp_file)) { + unlink($temp_file); + } + } + + // Mark message as read + imap_setflag_full($connection, (string)$msg_num, '\\Seen'); + } + + imap_close($connection); + + // Build output message + $this->output = sprintf( + "Processed %d messages. Imported: %d, Skipped (duplicates): %d, Errors: %d", + count($messages), + $this->imported_count, + $this->skipped_count, + $this->error_count + ); + + if ($this->error_count > 0) { + $this->output .= "\nErrors: " . implode(", ", $this->errors); + } + + return 0; + } + + /** + * Extract attachments from email + * + * @param resource $connection IMAP connection + * @param int $msg_num Message number + * @param object $structure Message structure + * @param string $part_num Part number for nested parts + * @return array Attachments + */ + private function getAttachments($connection, $msg_num, $structure, $part_num = '') + { + $attachments = array(); + + // Check if it's a multipart message + if (isset($structure->parts) && count($structure->parts)) { + foreach ($structure->parts as $key => $part) { + $attachments = array_merge( + $attachments, + $this->getAttachments($connection, $msg_num, $part, ($part_num ? $part_num . '.' : '') . ($key + 1)) + ); + } + } else { + // Check if this part is an attachment + $attachment = $this->extractAttachment($connection, $msg_num, $structure, $part_num); + if ($attachment) { + $attachments[] = $attachment; + } + } + + return $attachments; + } + + /** + * Extract a single attachment + * + * @param resource $connection IMAP connection + * @param int $msg_num Message number + * @param object $part Part structure + * @param string $part_num Part number + * @return array|null Attachment data or null + */ + private function extractAttachment($connection, $msg_num, $part, $part_num) + { + $filename = ''; + + // Get filename from parameters + if (isset($part->dparameters)) { + foreach ($part->dparameters as $param) { + if (strtolower($param->attribute) === 'filename') { + $filename = $param->value; + break; + } + } + } + + if (empty($filename) && isset($part->parameters)) { + foreach ($part->parameters as $param) { + if (strtolower($param->attribute) === 'name') { + $filename = $param->value; + break; + } + } + } + + // Check if it's a PDF attachment + if (empty($filename) || !preg_match('/\.pdf$/i', $filename)) { + return null; + } + + // Get attachment data + if ($part_num) { + $data = imap_fetchbody($connection, $msg_num, $part_num); + } else { + $data = imap_body($connection, $msg_num); + } + + // Decode based on encoding + if (isset($part->encoding)) { + switch ($part->encoding) { + case 3: // BASE64 + $data = base64_decode($data); + break; + case 4: // QUOTED-PRINTABLE + $data = quoted_printable_decode($data); + break; + } + } + + // Get file extension + $ext = pathinfo($filename, PATHINFO_EXTENSION); + + return array( + 'filename' => $filename, + 'type' => strtolower($ext), + 'data' => $data + ); + } + + /** + * Move file to target folder with proper error handling + * + * @param string $file Source file path + * @param string $targetFolder Target folder path + * @param string $prefix Filename prefix (e.g., 'imported_', 'duplicate_', 'error_') + * @return bool True if moved/deleted, false on failure + */ + protected function moveFile($file, $targetFolder, $prefix = '') + { + if (!file_exists($file)) { + dol_syslog("CronImportZugferd: File not found: " . $file, LOG_WARNING); + return false; + } + + // If target folder is configured and exists/writable + if (!empty($targetFolder)) { + // Create folder if it doesn't exist + if (!is_dir($targetFolder)) { + $result = dol_mkdir($targetFolder); + if ($result < 0) { + dol_syslog("CronImportZugferd: Failed to create folder: " . $targetFolder, LOG_WARNING); + } + } + + if (is_dir($targetFolder) && is_writable($targetFolder)) { + // Originalen Dateinamen beibehalten, bei Namenskollision Zaehler anhaengen + $baseName = basename($file); + $targetPath = $targetFolder . '/' . $baseName; + if (file_exists($targetPath)) { + $pathInfo = pathinfo($baseName); + $counter = 1; + do { + $targetPath = $targetFolder . '/' . $pathInfo['filename'] . '_' . $counter . '.' . $pathInfo['extension']; + $counter++; + } while (file_exists($targetPath)); + } + + if (@rename($file, $targetPath)) { + dol_syslog("CronImportZugferd: Moved file to: " . $targetPath, LOG_INFO); + return true; + } else { + // Try copy + delete as fallback (for cross-filesystem moves) + if (@copy($file, $targetPath)) { + @unlink($file); + dol_syslog("CronImportZugferd: Copied file to: " . $targetPath, LOG_INFO); + return true; + } else { + dol_syslog("CronImportZugferd: Failed to move/copy file to: " . $targetPath, LOG_ERR); + return false; + } + } + } else { + dol_syslog("CronImportZugferd: Target folder not writable: " . $targetFolder, LOG_WARNING); + } + } + + // No target folder configured or not writable - delete file from watch folder + // to prevent re-processing (except for errors without error folder) + if ($prefix !== 'error_') { + if (@unlink($file)) { + dol_syslog("CronImportZugferd: Deleted processed file: " . $file, LOG_INFO); + return true; + } else { + dol_syslog("CronImportZugferd: Failed to delete file: " . $file, LOG_ERR); + return false; + } + } + + // Error files stay in watch folder if no error folder configured + dol_syslog("CronImportZugferd: Keeping error file in watch folder: " . $file, LOG_INFO); + return true; + } + + /** + * Send notifications based on import results + * + * @return void + */ + protected function sendImportNotifications() + { + // Check for errors + if ($this->error_count > 0) { + $errorSummary = count($this->errors) > 0 ? implode(', ', array_slice($this->errors, 0, 3)) : 'Siehe Log'; + $this->notify( + 'warning', + $this->error_count.' ZUGFeRD Import-Fehler', + $errorSummary, + dol_buildpath('/importzugferd/list.php?status=error', 1), + 'Fehler anzeigen' + ); + } + + // Check for imported invoices that need review + if ($this->imported_count > 0) { + // Count pending invoices (drafts needing approval) + $pendingCount = $this->countPendingInvoices(); + + if ($pendingCount > 0) { + $this->notify( + 'action', + $this->imported_count.' ZUGFeRD Rechnungen importiert', + "{$pendingCount} Lieferantenrechnungen warten auf Prüfung und Freigabe", + dol_buildpath('/fourn/facture/list.php?search_status=0', 1), + 'Rechnungen prüfen' + ); + } else { + // All auto-created and validated + $this->notify( + 'info', + $this->imported_count.' ZUGFeRD Rechnungen importiert', + 'Alle Rechnungen wurden erfolgreich verarbeitet', + dol_buildpath('/fourn/facture/list.php', 1), + 'Anzeigen' + ); + } + } + + // IMAP connection issues + if (strpos($this->error, 'IMAP connection failed') !== false) { + $this->notify( + 'error', + 'IMAP Verbindung fehlgeschlagen', + 'E-Mail Postfach für ZUGFeRD-Import nicht erreichbar', + dol_buildpath('/importzugferd/admin/setup.php', 1), + 'IMAP prüfen' + ); + } + } + + /** + * Count pending (draft) supplier invoices + * + * @return int Number of draft supplier invoices + */ + protected function countPendingInvoices() + { + $sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."facture_fourn"; + $sql .= " WHERE fk_statut = 0"; // Draft status + $sql .= " AND entity IN (".getEntity('facture_fourn').")"; + + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + return (int) $obj->cnt; + } + return 0; + } +} diff --git a/class/datanorm.class.php b/class/datanorm.class.php new file mode 100755 index 0000000..c224eb0 --- /dev/null +++ b/class/datanorm.class.php @@ -0,0 +1,1150 @@ + + * + * 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(); + $foundEan = ''; + $foundManufacturerRef = ''; + $foundIds = array(); // Track found IDs to avoid duplicates + + // First try exact match with specified supplier + if ($fk_soc > 0) { + $result = $this->fetchByArticleNumber($fk_soc, $article_number); + if ($result > 0) { + $results[] = $this->toArray(); + $foundIds[$this->id] = true; + // Store EAN from Datanorm + $foundEan = $this->ean; + + // If Datanorm has no EAN, try to get it from supplier price (barcode field) + if (empty($foundEan)) { + $sqlEan = "SELECT barcode FROM " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlEan .= " WHERE fk_soc = " . (int)$fk_soc; + $sqlEan .= " AND ref_fourn = '" . $this->db->escape($article_number) . "'"; + $sqlEan .= " AND barcode IS NOT NULL AND barcode != ''"; + $sqlEan .= " LIMIT 1"; + $resEan = $this->db->query($sqlEan); + if ($resEan && $this->db->num_rows($resEan) > 0) { + $objEan = $this->db->fetch_object($resEan); + $foundEan = $objEan->barcode; + } + } + + // If not searching all catalogs, return immediately + if (!$searchAll) { + return $results; + } + } + } + + // If searchAll is enabled and we found article with EAN, + // search other catalogs using EAN ONLY (cross-catalog search) + // Note: Artikelnummern-Vergleich macht keinen Sinn über Kataloge hinweg + if ($searchAll && $fk_soc > 0 && !empty($foundEan)) { + $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 ean = '" . $this->db->escape($foundEan) . "'"; + $sql .= " AND active = 1"; + $sql .= " AND entity = " . (int) $conf->entity; + $sql .= " AND fk_soc != " . (int) $fk_soc; // Exclude already found supplier + + $sql .= " ORDER BY price ASC"; // Show cheapest alternatives first + $sql .= " LIMIT " . (int) ($limit - count($results)); + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + if (!isset($foundIds[$obj->rowid])) { + $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, + ); + $foundIds[$obj->rowid] = true; + } + } + $this->db->free($resql); + } + + // If we found results via cross-catalog search, return them + if (!empty($results)) { + return $results; + } + } + + // Fallback: Search by EXACT article number match for the specified supplier only + // No LIKE search - cross-catalog comparisons only work via EAN + if ($fk_soc > 0 && empty($results)) { + $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 = '" . $this->db->escape($article_number) . "'"; + $sql .= " AND fk_soc = " . (int) $fk_soc; + $sql .= " AND active = 1"; + $sql .= " AND entity = " . (int) $conf->entity; + $sql .= " LIMIT 1"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + if (!isset($foundIds[$obj->rowid])) { + $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, + ); + $foundIds[$obj->rowid] = true; + } + } + $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;... + // Rabattkennzeichen aus DATPREIS (wird gespeichert aber nicht fuer price_unit verwendet) + 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'); // Rabattkennzeichen (nicht PE!) + $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'); // Rabattkennzeichen (nicht PE!) + + 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; + } +} diff --git a/class/datanormparser.class.php b/class/datanormparser.class.php new file mode 100755 index 0000000..d066fd8 --- /dev/null +++ b/class/datanormparser.class.php @@ -0,0 +1,1016 @@ + + * + * 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/datanormparser.class.php + * \ingroup importzugferd + * \brief Parser for Datanorm 4.0 and 5.0 catalog files + */ + +/** + * Class DatanormParser + * Parses Datanorm catalog files (Version 4.0 and 5.0) + * + * Datanorm Price Unit (PE) Codes: + * 0 or empty = per 1 piece + * 1 = per 10 pieces + * 2 = per 100 pieces + * 3 = per 1000 pieces + * + * The price in Datanorm is given for the quantity specified by the PE code. + * To get the unit price: divide price by PE quantity. + */ +class DatanormParser +{ + /** + * Price unit code mapping + * Datanorm uses codes 0-3 to represent price units + */ + const PRICE_UNIT_CODES = array( + 0 => 1, + 1 => 10, + 2 => 100, + 3 => 1000, + ); + + /** + * Convert Datanorm PE code to actual quantity + * + * @param int|string $peCode The PE code from Datanorm (0, 1, 2, or 3) + * @return int The actual quantity (1, 10, 100, or 1000) + */ + public static function convertPriceUnitCode($peCode) + { + $code = (int)$peCode; + return self::PRICE_UNIT_CODES[$code] ?? 1; + } + + /** + * @var string Detected Datanorm version + */ + public $version = ''; + + /** + * @var array Parsed articles (only used for small imports) + */ + public $articles = array(); + + /** + * @var array Parsed price information + */ + public $prices = array(); + + /** + * @var array Product groups/categories + */ + public $groups = array(); + + /** + * @var string Error message + */ + public $error = ''; + + /** + * @var array Error messages + */ + public $errors = array(); + + /** + * @var callable Callback for batch processing articles + */ + protected $batchCallback = null; + + /** + * @var int Batch size for database inserts + */ + protected $batchSize = 1000; + + /** + * @var array Current batch of articles + */ + protected $batchArticles = array(); + + /** + * @var bool Whether to use streaming mode (for large files) + */ + protected $streamingMode = false; + + /** + * Enable streaming mode for large files + * In streaming mode, articles are processed in batches via callback + * + * @param callable $callback Function to call with batch of articles + * @param int $batchSize Number of articles per batch + */ + public function enableStreaming($callback, $batchSize = 1000) + { + $this->streamingMode = true; + $this->batchCallback = $callback; + $this->batchSize = $batchSize; + $this->batchArticles = array(); + } + + /** + * Disable streaming mode + */ + public function disableStreaming() + { + $this->streamingMode = false; + $this->batchCallback = null; + $this->batchArticles = array(); + } + + /** + * Add article to batch (streaming mode) or to articles array + * + * @param array $article Article data + */ + protected function addArticle($article) + { + if ($this->streamingMode && $this->batchCallback) { + $this->batchArticles[$article['article_number']] = $article; + + if (count($this->batchArticles) >= $this->batchSize) { + $this->flushBatch(); + } + } else { + $this->articles[$article['article_number']] = $article; + } + } + + /** + * Flush current batch to callback + */ + protected function flushBatch() + { + if (!empty($this->batchArticles) && $this->batchCallback) { + // Merge prices into batch articles before flushing + foreach ($this->batchArticles as $artNum => &$article) { + if (isset($this->prices[$artNum])) { + $article['price'] = $this->prices[$artNum]['price']; + if (!empty($this->prices[$artNum]['metal_surcharge'])) { + $article['metal_surcharge'] = $this->prices[$artNum]['metal_surcharge']; + } + unset($this->prices[$artNum]); // Free memory + } + } + unset($article); + + call_user_func($this->batchCallback, $this->batchArticles); + $this->batchArticles = array(); + } + } + + /** + * Parse a Datanorm file or directory + * + * @param string $path Path to file or directory + * @return int Number of articles parsed, -1 on error + */ + public function parse($path) + { + if (is_dir($path)) { + return $this->parseDirectory($path); + } else { + return $this->parseFile($path); + } + } + + /** + * Parse all Datanorm files in a directory + * + * @param string $dir Directory path + * @return int Number of articles parsed, -1 on error + */ + public function parseDirectory($dir) + { + $totalArticles = 0; + + // Use case-insensitive search for Linux compatibility + $allFiles = glob($dir . '/*'); + + // For non-streaming mode, load prices first into memory + // For streaming mode, prices are updated via second pass directly to DB + if (!$this->streamingMode) { + $priceFiles = array(); + foreach ($allFiles as $file) { + $basename = strtoupper(basename($file)); + if (preg_match('/^DATPREIS\.\d{3}$/', $basename)) { + $priceFiles[] = $file; + } + } + if (!empty($priceFiles)) { + $this->version = '4.0'; + foreach ($priceFiles as $file) { + $this->parseDatapreis4File($file); + } + } + } + + // Look for Datanorm 4.0 files (DATANORM.xxx) - case-insensitive + $datanormFiles = array(); + $wrgFiles = array(); + $rabFiles = array(); + foreach ($allFiles as $file) { + $basename = strtoupper(basename($file)); + if (preg_match('/^DATANORM\.\d{3}$/', $basename)) { + $datanormFiles[] = $file; + } elseif ($basename === 'DATANORM.WRG') { + $wrgFiles[] = $file; + } elseif ($basename === 'DATANORM.RAB') { + $rabFiles[] = $file; + } + } + if (!empty($datanormFiles)) { + $this->version = '4.0'; + foreach ($datanormFiles as $file) { + $count = $this->parseDatanorm4File($file); + if ($count > 0) { + $totalArticles += $count; + } + } + foreach ($wrgFiles as $file) { + $this->parseDatanorm4Groups($file); + } + foreach ($rabFiles as $file) { + $this->parseDatanorm4Discounts($file); + } + } + + // Merge prices into articles (non-streaming mode only) + // In streaming mode, prices are merged in flushBatch() + if (!$this->streamingMode && !empty($this->prices)) { + $this->mergePricesIntoArticles(); + } + + // Look for Datanorm 5.0 files (*.xml) + $xmlFiles = glob($dir . '/*.xml'); + foreach ($xmlFiles as $file) { + if ($this->isDatanorm5File($file)) { + $this->version = '5.0'; + $count = $this->parseDatanorm5File($file); + if ($count > 0) { + $totalArticles += $count; + } + } + } + + return $totalArticles; + } + + /** + * Parse a single file (auto-detect format) + * + * @param string $file File path + * @return int Number of articles parsed, -1 on error + */ + public function parseFile($file) + { + if (!file_exists($file)) { + $this->error = 'File not found: ' . $file; + return -1; + } + + // Check if XML (Datanorm 5.0) + $content = file_get_contents($file, false, null, 0, 1000); + if (strpos($content, 'version = '5.0'; + return $this->parseDatanorm5File($file); + } + + // Assume Datanorm 4.0 + $this->version = '4.0'; + return $this->parseDatanorm4File($file); + } + + /** + * Parse Datanorm 4.0 file (fixed-width format) + * Uses streaming to handle large files + * + * @param string $file File path + * @return int Number of articles parsed + */ + protected function parseDatanorm4File($file) + { + $handle = fopen($file, 'r'); + if ($handle === false) { + $this->error = 'Cannot read file: ' . $file; + return -1; + } + + $count = 0; + $currentArticle = null; + + while (($line = fgets($handle)) !== false) { + $line = rtrim($line, "\r\n"); + + // Convert encoding if needed (Datanorm 4 often uses ISO-8859-1 or CP850) + if (!mb_check_encoding($line, 'UTF-8')) { + $line = mb_convert_encoding($line, 'UTF-8', 'ISO-8859-1'); + } + + if (strlen($line) < 2) { + continue; + } + + $recordType = substr($line, 0, 1); + + switch ($recordType) { + case 'A': + // Article master record + $article = $this->parseDatanorm4TypeA($line); + if ($article) { + $this->addArticle($article); + $currentArticle = $article['article_number']; + $count++; + } + break; + + case 'B': + // Article info/long text + if ($currentArticle) { + $this->parseDatanorm4TypeB($line, $currentArticle); + } + break; + + case 'P': + // Price record + $this->parseDatanorm4TypeP($line); + break; + } + } + + fclose($handle); + + // Flush any remaining batch in streaming mode + if ($this->streamingMode) { + $this->flushBatch(); + } else { + // Merge prices into articles (only in non-streaming mode) + $this->mergePricesIntoArticles(); + } + + return $count; + } + + /** + * Parse Datanorm 4.0 Type A record (Article master) + * Field positions based on Datanorm 4.0 specification + * + * @param string $line Record line + * @return array|null Article data + */ + protected function parseDatanorm4TypeA($line) + { + // Minimum length check + if (strlen($line) < 50) { + return null; + } + + // Datanorm 4.0 Type A field layout (semicolon-separated in newer versions) + if (strpos($line, ';') !== false) { + return $this->parseDatanorm4TypeASemicolon($line); + } + + // Fixed-width format (classic Datanorm 3.0/4.0) + // PE code is at position 112-116 and is a CODE (0=1, 1=10, 2=100, 3=1000) + $peCode = (int)trim(substr($line, 111, 5)); + $priceUnit = self::convertPriceUnitCode($peCode); + + $article = array( + 'article_number' => trim(substr($line, 1, 15)), // Pos 2-16: Artikelnummer + 'action_code' => 'N', // Fixed-width format has no action code + 'matchcode' => trim(substr($line, 16, 12)), // Pos 17-28: Matchcode + 'short_text1' => trim(substr($line, 28, 40)), // Pos 29-68: Kurztext 1 + 'short_text2' => trim(substr($line, 68, 40)), // Pos 69-108: Kurztext 2 + 'unit_code' => trim(substr($line, 108, 3)), // Pos 109-111: Mengeneinheit + 'price_unit' => $priceUnit, // Converted from PE code + 'price_unit_code' => $peCode, // Original PE code + 'discount_group' => trim(substr($line, 116, 4)), // Pos 117-120: Rabattgruppe + 'product_group' => trim(substr($line, 120, 7)), // Pos 121-127: Warengruppe + 'manufacturer_ref' => trim(substr($line, 127, 15)), // Pos 128-142: Hersteller-Artikelnummer + 'manufacturer_name' => trim(substr($line, 142, 20)), // Pos 143-162: Herstellername + 'ean' => '', + 'long_text' => '', + 'price' => 0, + ); + + // EAN if available (extended format) + if (strlen($line) >= 175) { + $article['ean'] = trim(substr($line, 162, 13)); + } + + if (empty($article['article_number'])) { + return null; + } + + return $article; + } + + /** + * Parse Datanorm 4.0 Type A record (semicolon-separated format) + * + * @param string $line Record line + * @return array|null Article data + */ + protected function parseDatanorm4TypeASemicolon($line) + { + $parts = explode(';', $line); + + if (count($parts) < 6) { + return null; + } + + // Detect format variant + // Sonepar format: A;N;ArtNr;TextKz;Kurztext1;Kurztext2;PreisKz;PE;ME;Preis;RabGrp;WG;... + // Index: 0 1 2 3 4 5 6 7 8 9 10 11 + // Standard format: A;ArtNr;Matchcode;Kurztext1;Kurztext2;ME;PE;RabGrp;WG;... + + $firstField = trim($parts[0] ?? ''); + + if ($firstField === 'A' && isset($parts[1]) && strlen(trim($parts[1])) <= 2) { + // Sonepar/Kluxen format with action code (N=New, L=Delete, A=Update) + // A;N;ArtNr;TextKz;Kurztext1;Kurztext2;PreisKz;PE;ME;Preis;RabGrp;WG;LangTextKey + // PE is at index 7 and is a CODE (0=1, 1=10, 2=100, 3=1000) + // Preis is at index 9 - in CENTS (e.g., 27800 = 278,00 €) + $actionCode = strtoupper(trim($parts[1] ?? 'N')); + $peCode = (int)trim($parts[7] ?? '0'); + $priceUnit = self::convertPriceUnitCode($peCode); + + // Price from A-record (for formats without separate DATPREIS file like Kluxen) + // Price is in cents, convert to euros + $priceRaw = trim($parts[9] ?? '0'); + $price = 0.0; + if (!empty($priceRaw) && is_numeric($priceRaw)) { + // Price is in cents (integer without decimal), convert to euros + $price = (float)$priceRaw / 100; + } + + $article = array( + 'article_number' => trim($parts[2] ?? ''), + 'action_code' => $actionCode, // N=New, A=Update, L=Delete + 'matchcode' => '', // Will be set from B record + 'short_text1' => trim($parts[4] ?? ''), + 'short_text2' => trim($parts[5] ?? ''), + 'unit_code' => trim($parts[8] ?? ''), // ME (Mengeneinheit) at index 8 + 'price_unit' => $priceUnit, // Converted from PE code at index 7 + 'price_unit_code' => $peCode, // Original PE code for reference + 'discount_group' => trim($parts[10] ?? ''), // Rabattgruppe at index 10 + 'product_group' => trim($parts[11] ?? ''), // Warengruppe at index 11 + 'price_type' => trim($parts[6] ?? ''), // Preiskennzeichen (1=Brutto, 2=Netto) + 'manufacturer_ref' => '', + 'manufacturer_name' => '', + 'ean' => '', + 'long_text' => '', + 'price' => $price, // Price from A-record (in euros) + ); + } else { + // Standard format: A;ArtNr;Matchcode;Kurztext1;Kurztext2;ME;PE;RabGrp;WG;... + // PE at index 6 is a CODE (0=1, 1=10, 2=100, 3=1000) + $peCode = (int)trim($parts[6] ?? '0'); + $priceUnit = self::convertPriceUnitCode($peCode); + + $article = array( + 'article_number' => trim($parts[1] ?? ''), + 'action_code' => 'N', // Default to New for standard format + 'matchcode' => trim($parts[2] ?? ''), + 'short_text1' => trim($parts[3] ?? ''), + 'short_text2' => trim($parts[4] ?? ''), + 'unit_code' => trim($parts[5] ?? ''), + 'price_unit' => $priceUnit, + 'price_unit_code' => $peCode, + 'discount_group' => trim($parts[7] ?? ''), + 'product_group' => trim($parts[8] ?? ''), + 'manufacturer_ref' => trim($parts[14] ?? ''), + 'manufacturer_name' => trim($parts[15] ?? ''), + 'ean' => trim($parts[16] ?? ''), + 'long_text' => '', + 'price' => 0, + ); + } + + if (empty($article['article_number'])) { + return null; + } + + return $article; + } + + /** + * Get article reference for modification (handles both streaming and non-streaming mode) + * + * @param string $articleNumber Article number + * @return array|null Reference to article or null + */ + protected function &getArticleRef($articleNumber) + { + $null = null; + if ($this->streamingMode) { + if (isset($this->batchArticles[$articleNumber])) { + return $this->batchArticles[$articleNumber]; + } + } else { + if (isset($this->articles[$articleNumber])) { + return $this->articles[$articleNumber]; + } + } + return $null; + } + + /** + * Parse Datanorm 4.0 Type B record (Article info/long text) + * Sonepar format: B;N;ArtNr;Matchcode; ; ;;;;EAN; ; ;0;VPE;;; + * + * @param string $line Record line + * @param string $articleNumber Current article number + */ + protected function parseDatanorm4TypeB($line, $articleNumber) + { + $article = &$this->getArticleRef($articleNumber); + if ($article === null) { + return; + } + + if (strpos($line, ';') !== false) { + $parts = explode(';', $line); + + // Sonepar format: B;N;ArtNr;Matchcode; ; ;...;EAN; ; ;0;VPE;;; + // Field positions can vary, so we search for EAN and VPE + if (isset($parts[1]) && strlen(trim($parts[1])) <= 2) { + // Get article number from B record to verify + $bArticleNumber = trim($parts[2] ?? ''); + if ($bArticleNumber === $articleNumber) { + // Matchcode is at position 3 + $matchcode = trim($parts[3] ?? ''); + if (!empty($matchcode) && empty($article['matchcode'])) { + $article['matchcode'] = $matchcode; + } + + // Search for EAN (13-digit numeric code) in any field + if (empty($article['ean'])) { + foreach ($parts as $part) { + $part = trim($part); + if (preg_match('/^\d{13}$/', $part)) { + $article['ean'] = $part; + break; + } + } + } + + // VPE (Verpackungseinheit) in B record is the packaging quantity + // This is informational - the price unit from A record PE code is authoritative + // We store VPE separately for reference but don't override price_unit + for ($i = 12; $i <= min(15, count($parts) - 1); $i++) { + $vpe = (int)trim($parts[$i] ?? '0'); + if ($vpe > 1) { + $article['vpe'] = $vpe; // Store as separate field + break; + } + } + } + } else { + // Standard format: text at position 2 + $text = trim($parts[2] ?? ''); + if (!empty($text)) { + if (!empty($article['long_text'])) { + $article['long_text'] .= "\n"; + } + $article['long_text'] .= $text; + } + } + } else { + $text = trim(substr($line, 16)); + if (!empty($text)) { + if (!empty($article['long_text'])) { + $article['long_text'] .= "\n"; + } + $article['long_text'] .= $text; + } + } + } + + /** + * Parse Datanorm 4.0 Type P record (Price) + * + * @param string $line Record line + */ + protected function parseDatanorm4TypeP($line) + { + if (strpos($line, ';') !== false) { + $parts = explode(';', $line); + $articleNumber = trim($parts[1] ?? ''); + $priceType = trim($parts[2] ?? ''); + $price = $this->parsePrice(trim($parts[3] ?? '0')); + } else { + $articleNumber = trim(substr($line, 1, 15)); + $priceType = trim(substr($line, 16, 1)); + $price = $this->parsePrice(trim(substr($line, 17, 12))); + } + + if (!empty($articleNumber) && $price > 0) { + $this->prices[$articleNumber] = array( + 'price' => $price, + 'price_type' => $priceType, + ); + } + } + + /** + * Parse Datanorm 4.0 product groups file (DATANORM.WRG) + * + * @param string $file File path + */ + protected function parseDatanorm4Groups($file) + { + $content = file_get_contents($file); + if ($content === false) { + return; + } + + if (!mb_check_encoding($content, 'UTF-8')) { + $content = mb_convert_encoding($content, 'UTF-8', 'ISO-8859-1'); + } + + $lines = explode("\n", $content); + foreach ($lines as $line) { + $line = rtrim($line, "\r\n"); + if (strlen($line) < 10) { + continue; + } + + if (strpos($line, ';') !== false) { + $parts = explode(';', $line); + $code = trim($parts[0] ?? ''); + $name = trim($parts[1] ?? ''); + } else { + $code = trim(substr($line, 0, 7)); + $name = trim(substr($line, 7)); + } + + if (!empty($code)) { + $this->groups[$code] = $name; + } + } + } + + /** + * Parse Datanorm 4.0 discount groups file (DATANORM.RAB) + * + * @param string $file File path + */ + protected function parseDatanorm4Discounts($file) + { + // Discount parsing - can be extended if needed + } + + /** + * Parse DATPREIS.xxx price file + * Uses streaming to handle large files + * + * @param string $file File path + */ + protected function parseDatapreis4File($file) + { + $handle = fopen($file, 'r'); + if ($handle === false) { + return; + } + + 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) { + continue; + } + + // DATPREIS format - semicolon separated + if (strpos($line, ';') !== false) { + $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;PreisKz2;Preis2;... + // For cables: Preis = Materialpreis, Zuschlag = Metallzuschlag (copper surcharge) + // PE code from DATPREIS may differ from A-record - used for price normalization + if ($recordType === 'P' && isset($parts[1]) && $parts[1] === 'A') { + // Parse multiple price entries per line + // Each entry is: ArtNr;PreisKz;Preis;PE;Zuschlag;x;x;x;x + $i = 2; // Start after P;A + while ($i < count($parts) - 2) { + $articleNumber = trim($parts[$i] ?? ''); + $priceType = trim($parts[$i + 1] ?? ''); + $priceRaw = trim($parts[$i + 2] ?? '0'); + $datpreisPeCode = (int)trim($parts[$i + 3] ?? '0'); // PE code from DATPREIS + $metalSurchargeRaw = trim($parts[$i + 4] ?? '0'); + + // Price is in cents, convert to euros + $price = (float)$priceRaw / 100; + $metalSurcharge = (float)$metalSurchargeRaw / 100; + + if (!empty($articleNumber) && $price > 0) { + $this->prices[$articleNumber] = array( + 'price' => $price, + 'price_type' => $priceType, + 'metal_surcharge' => $metalSurcharge, + 'datpreis_pe_code' => $datpreisPeCode, + ); + } + + // Move to next article (9 fields per article: ArtNr;Kz;Preis;PE;Zuschlag;x;x;x;x) + $i += 9; + } + } elseif ($recordType === 'P' || $recordType === '0') { + // Simple format: P;ArtNr;PreisKz;Preis + $articleNumber = trim($parts[1] ?? ''); + $priceType = trim($parts[2] ?? ''); + $priceRaw = trim($parts[3] ?? '0'); + + // Check if price is in cents (no decimal point) + if (strpos($priceRaw, ',') === false && strpos($priceRaw, '.') === false) { + $price = (float)$priceRaw / 100; + } else { + $price = $this->parsePrice($priceRaw); + } + + if (!empty($articleNumber) && $price > 0) { + $this->prices[$articleNumber] = array( + 'price' => $price, + 'price_type' => $priceType, + ); + } + } + } else { + // Fixed width format + $recordType = substr($line, 0, 1); + + if ($recordType === 'P' || $recordType === '0') { + $articleNumber = trim(substr($line, 1, 15)); + $priceType = trim(substr($line, 16, 1)); + $priceRaw = trim(substr($line, 17, 12)); + + // Check if price is in cents + if (strpos($priceRaw, ',') === false && strpos($priceRaw, '.') === false) { + $price = (float)$priceRaw / 100; + } else { + $price = $this->parsePrice($priceRaw); + } + + if (!empty($articleNumber) && $price > 0) { + $this->prices[$articleNumber] = array( + 'price' => $price, + 'price_type' => $priceType, + ); + } + } + } + } + + fclose($handle); + } + + /** + * Merge prices into articles + * DATPREIS prices are already for the A-Satz PE unit - no normalization needed! + */ + protected function mergePricesIntoArticles() + { + foreach ($this->prices as $articleNumber => $priceData) { + if (isset($this->articles[$articleNumber])) { + $this->articles[$articleNumber]['price'] = $priceData['price']; + if (!empty($priceData['metal_surcharge'])) { + $this->articles[$articleNumber]['metal_surcharge'] = $priceData['metal_surcharge']; + } + } + } + } + + /** + * Check if file is Datanorm 5.0 format + * + * @param string $file File path + * @return bool + */ + protected function isDatanorm5File($file) + { + $content = file_get_contents($file, false, null, 0, 2000); + return (strpos($content, 'error = 'XML parse error: ' . ($errors[0]->message ?? 'Unknown error'); + libxml_clear_errors(); + return -1; + } + + $count = 0; + + // Register namespaces if present + $namespaces = $xml->getNamespaces(true); + + // Find article nodes (various possible node names) + $articleNodes = $xml->xpath('//Artikel') ?: $xml->xpath('//Article') ?: $xml->xpath('//article') ?: array(); + + foreach ($articleNodes as $node) { + $article = $this->parseDatanorm5Article($node); + if ($article) { + $this->articles[$article['article_number']] = $article; + $count++; + } + } + + return $count; + } + + /** + * Parse Datanorm 5.0 article node + * + * @param SimpleXMLElement $node Article XML node + * @return array|null Article data + */ + protected function parseDatanorm5Article($node) + { + $article = array( + 'article_number' => $this->getXmlValue($node, array('Artikelnummer', 'ArticleNumber', 'ArtNr', 'artNr')), + 'matchcode' => $this->getXmlValue($node, array('Matchcode', 'matchcode')), + 'short_text1' => $this->getXmlValue($node, array('Kurztext1', 'Kurztext', 'ShortText1', 'ShortText', 'Bezeichnung', 'Name')), + 'short_text2' => $this->getXmlValue($node, array('Kurztext2', 'ShortText2')), + 'long_text' => $this->getXmlValue($node, array('Langtext', 'LongText', 'Beschreibung', 'Description')), + 'unit_code' => $this->getXmlValue($node, array('Mengeneinheit', 'Unit', 'ME')), + 'price_unit' => (int)$this->getXmlValue($node, array('Preiseinheit', 'PriceUnit', 'PE')) ?: 1, + 'price' => $this->parsePrice($this->getXmlValue($node, array('Preis', 'Price', 'Listenpreis', 'ListPrice'))), + 'discount_group' => $this->getXmlValue($node, array('Rabattgruppe', 'DiscountGroup', 'RG')), + 'product_group' => $this->getXmlValue($node, array('Warengruppe', 'ProductGroup', 'WG')), + 'manufacturer_ref' => $this->getXmlValue($node, array('HerstellerArtNr', 'ManufacturerArticleNumber')), + 'manufacturer_name' => $this->getXmlValue($node, array('Hersteller', 'Manufacturer')), + 'ean' => $this->getXmlValue($node, array('EAN', 'GTIN', 'Barcode')), + ); + + if (empty($article['article_number'])) { + return null; + } + + return $article; + } + + /** + * Get value from XML node trying multiple possible element names + * + * @param SimpleXMLElement $node XML node + * @param array $names Possible element names + * @return string Value or empty string + */ + protected function getXmlValue($node, $names) + { + foreach ($names as $name) { + // Try as child element + if (isset($node->$name)) { + return trim((string)$node->$name); + } + // Try as attribute + if (isset($node[$name])) { + return trim((string)$node[$name]); + } + } + return ''; + } + + /** + * Parse price string to float + * + * @param string $priceStr Price string + * @return float Price value + */ + protected function parsePrice($priceStr) + { + if (empty($priceStr)) { + return 0.0; + } + + // Remove currency symbols and whitespace + $priceStr = preg_replace('/[^\d,.\-]/', '', $priceStr); + + // Handle German number format (1.234,56) + if (preg_match('/^\d{1,3}(\.\d{3})*,\d{2}$/', $priceStr)) { + $priceStr = str_replace('.', '', $priceStr); + $priceStr = str_replace(',', '.', $priceStr); + } elseif (strpos($priceStr, ',') !== false && strpos($priceStr, '.') === false) { + // Simple comma as decimal separator + $priceStr = str_replace(',', '.', $priceStr); + } + + return (float)$priceStr; + } + + /** + * Convert Datanorm unit code to UN/ECE code + * + * @param string $datanormUnit Datanorm unit code + * @return string UN/ECE unit code + */ + public static function convertUnitCode($datanormUnit) + { + $mapping = array( + 'ST' => 'C62', // Stück + 'STK' => 'C62', // Stück + 'PCE' => 'C62', // Piece + 'M' => 'MTR', // Meter + 'MTR' => 'MTR', // Meter + 'CM' => 'CMT', // Zentimeter + 'MM' => 'MMT', // Millimeter + 'L' => 'LTR', // Liter + 'LTR' => 'LTR', // Liter + 'KG' => 'KGM', // Kilogramm + 'G' => 'GRM', // Gramm + 'M2' => 'MTK', // Quadratmeter + 'M3' => 'MTQ', // Kubikmeter + 'PAK' => 'PK', // Packung + 'PAC' => 'PK', // Package + 'SET' => 'SET', // Set + 'ROL' => 'RL', // Rolle + 'RLL' => 'RL', // Roll + 'BDL' => 'BE', // Bündel + 'KRT' => 'CT', // Karton + 'CTN' => 'CT', // Carton + ); + + $unit = strtoupper(trim($datanormUnit)); + return $mapping[$unit] ?? 'C62'; // Default to piece + } + + /** + * Get all parsed articles + * + * @return array Articles + */ + public function getArticles() + { + return $this->articles; + } + + /** + * Find article by number + * + * @param string $articleNumber Article number to find + * @return array|null Article data or null + */ + public function findArticle($articleNumber) + { + return $this->articles[$articleNumber] ?? null; + } + + /** + * Search articles by text + * + * @param string $searchText Search text + * @param int $limit Maximum results + * @return array Matching articles + */ + public function searchArticles($searchText, $limit = 50) + { + $results = array(); + $searchText = strtolower($searchText); + + foreach ($this->articles as $article) { + $searchFields = strtolower( + $article['article_number'] . ' ' . + $article['matchcode'] . ' ' . + $article['short_text1'] . ' ' . + $article['short_text2'] . ' ' . + $article['ean'] . ' ' . + $article['manufacturer_ref'] + ); + + if (strpos($searchFields, $searchText) !== false) { + $results[] = $article; + if (count($results) >= $limit) { + break; + } + } + } + + return $results; + } +} diff --git a/class/importline.class.php b/class/importline.class.php new file mode 100755 index 0000000..6932a35 --- /dev/null +++ b/class/importline.class.php @@ -0,0 +1,431 @@ +db = $db; + } + + /** + * Create line in database + * + * @param User $user User creating the line + * @return int >0 if OK, <0 if KO + */ + public function create($user) + { + $this->date_creation = dol_now(); + + $sql = "INSERT INTO " . MAIN_DB_PREFIX . $this->table_element . " ("; + $sql .= "fk_import, line_id, supplier_ref, product_name, description,"; + $sql .= "quantity, unit_code, unit_price, unit_price_raw, basis_quantity, basis_quantity_unit,"; + $sql .= "line_total, tax_percent, ean, copper_surcharge, copper_surcharge_basis_qty,"; + $sql .= "fk_product, match_method, date_creation"; + $sql .= ") VALUES ("; + $sql .= ((int) $this->fk_import) . ","; + $sql .= "'" . $this->db->escape($this->line_id) . "',"; + $sql .= "'" . $this->db->escape($this->supplier_ref) . "',"; + $sql .= "'" . $this->db->escape($this->product_name) . "',"; + $sql .= "'" . $this->db->escape($this->description) . "',"; + $sql .= ((float) $this->quantity) . ","; + $sql .= "'" . $this->db->escape($this->unit_code) . "',"; + $sql .= ((float) $this->unit_price) . ","; + $sql .= ((float) $this->unit_price_raw) . ","; + $sql .= ((float) $this->basis_quantity) . ","; + $sql .= "'" . $this->db->escape($this->basis_quantity_unit) . "',"; + $sql .= ((float) $this->line_total) . ","; + $sql .= ((float) $this->tax_percent) . ","; + $sql .= "'" . $this->db->escape($this->ean) . "',"; + $sql .= ($this->copper_surcharge !== null ? ((float) $this->copper_surcharge) : "NULL") . ","; + $sql .= ($this->copper_surcharge_basis_qty !== null ? ((float) $this->copper_surcharge_basis_qty) : "NULL") . ","; + $sql .= ($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL") . ","; + $sql .= "'" . $this->db->escape($this->match_method) . "',"; + $sql .= "'" . $this->db->idate($this->date_creation) . "'"; + $sql .= ")"; + + $resql = $this->db->query($sql); + if ($resql) { + $this->id = $this->db->last_insert_id(MAIN_DB_PREFIX . $this->table_element); + $this->rowid = $this->id; + return $this->id; + } else { + $this->error = $this->db->lasterror(); + return -1; + } + } + + /** + * Fetch line from database + * + * @param int $id Line ID + * @return int >0 if OK, <0 if KO + */ + public function fetch($id) + { + // Use SELECT * to be compatible with older database versions without fk_datanorm column + $sql = "SELECT * FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE rowid = " . ((int) $id); + + $resql = $this->db->query($sql); + if ($resql) { + if ($this->db->num_rows($resql)) { + $obj = $this->db->fetch_object($resql); + $this->id = $obj->rowid; + $this->rowid = $obj->rowid; + $this->fk_import = $obj->fk_import; + $this->line_id = $obj->line_id; + $this->supplier_ref = $obj->supplier_ref; + $this->product_name = $obj->product_name; + $this->description = $obj->description; + $this->quantity = $obj->quantity; + $this->unit_code = $obj->unit_code; + $this->unit_price = $obj->unit_price; + $this->unit_price_raw = $obj->unit_price_raw; + $this->basis_quantity = $obj->basis_quantity; + $this->basis_quantity_unit = $obj->basis_quantity_unit; + $this->line_total = $obj->line_total; + $this->tax_percent = $obj->tax_percent; + $this->ean = $obj->ean; + $this->copper_surcharge = isset($obj->copper_surcharge) ? $obj->copper_surcharge : null; + $this->copper_surcharge_basis_qty = isset($obj->copper_surcharge_basis_qty) ? $obj->copper_surcharge_basis_qty : null; + $this->fk_product = $obj->fk_product; + $this->fk_datanorm = isset($obj->fk_datanorm) ? $obj->fk_datanorm : null; + $this->match_method = $obj->match_method; + $this->date_creation = $this->db->jdate($obj->date_creation); + return 1; + } + return 0; + } + $this->error = $this->db->lasterror(); + return -1; + } + + /** + * Update line in database + * + * @param User $user User making the update + * @return int >0 if OK, <0 if KO + */ + public function update($user) + { + $sql = "UPDATE " . MAIN_DB_PREFIX . $this->table_element . " SET"; + $sql .= " fk_product = " . ($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL") . ","; + $sql .= " fk_datanorm = " . ($this->fk_datanorm > 0 ? ((int) $this->fk_datanorm) : "NULL") . ","; + $sql .= " match_method = '" . $this->db->escape($this->match_method) . "'"; + $sql .= " WHERE rowid = " . ((int) $this->id); + + $resql = $this->db->query($sql); + if ($resql) { + return 1; + } + $this->error = $this->db->lasterror(); + return -1; + } + + /** + * Delete line from database + * + * @param User $user User deleting the line + * @return int >0 if OK, <0 if KO + */ + public function delete($user) + { + $sql = "DELETE FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE rowid = " . ((int) $this->id); + + $resql = $this->db->query($sql); + if ($resql) { + return 1; + } + $this->error = $this->db->lasterror(); + return -1; + } + + /** + * Fetch all lines for an import + * + * @param int $fk_import Import ID + * @return array|int Array of ImportLine objects or <0 if error + */ + public function fetchAllByImport($fk_import) + { + $lines = array(); + + $sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE fk_import = " . ((int) $fk_import); + $sql .= " ORDER BY rowid ASC"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $line = new ImportLine($this->db); + $line->fetch($obj->rowid); + $lines[] = $line; + } + return $lines; + } + $this->error = $this->db->lasterror(); + return -1; + } + + /** + * Delete all lines for an import + * + * @param int $fk_import Import ID + * @return int >0 if OK, <0 if KO + */ + public function deleteAllByImport($fk_import) + { + $sql = "DELETE FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE fk_import = " . ((int) $fk_import); + + $resql = $this->db->query($sql); + if ($resql) { + return 1; + } + $this->error = $this->db->lasterror(); + return -1; + } + + /** + * Check if all lines for an import have products assigned + * + * @param int $fk_import Import ID + * @return bool True if all lines have products, false otherwise + */ + public function allLinesHaveProducts($fk_import) + { + $sql = "SELECT COUNT(*) as total, SUM(CASE WHEN fk_product IS NOT NULL AND fk_product > 0 THEN 1 ELSE 0 END) as with_product"; + $sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE fk_import = " . ((int) $fk_import); + + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + return ($obj->total > 0 && $obj->total == $obj->with_product); + } + return false; + } + + /** + * Count lines without product assignment + * + * @param int $fk_import Import ID + * @return int Number of lines without product + */ + public function countLinesWithoutProduct($fk_import) + { + $sql = "SELECT COUNT(*) as cnt FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE fk_import = " . ((int) $fk_import); + $sql .= " AND (fk_product IS NULL OR fk_product = 0)"; + + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + return (int) $obj->cnt; + } + return 0; + } + + /** + * Set product for this line + * + * @param int $fk_product Product ID + * @param string $match_method How product was assigned + * @param User $user User making the change + * @return int >0 if OK, <0 if KO + */ + public function setProduct($fk_product, $match_method, $user) + { + $this->fk_product = $fk_product; + $this->match_method = $match_method; + return $this->update($user); + } + + /** + * Set Datanorm reference for this line + * + * @param int $fk_datanorm Datanorm article ID + * @param User $user User making the change + * @return int >0 if OK, <0 if KO + */ + public function setDatanorm($fk_datanorm, $user) + { + $this->fk_datanorm = $fk_datanorm; + $this->match_method = 'datanorm_assigned'; + return $this->update($user); + } + + /** + * Count lines with Datanorm assignment + * + * @param int $fk_import Import ID + * @return int Number of lines with Datanorm + */ + public function countLinesWithDatanorm($fk_import) + { + $sql = "SELECT COUNT(*) as cnt FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE fk_import = " . ((int) $fk_import); + $sql .= " AND fk_datanorm IS NOT NULL AND fk_datanorm > 0"; + + $resql = $this->db->query($sql); + if ($resql) { + $obj = $this->db->fetch_object($resql); + return (int) $obj->cnt; + } + return 0; + } +} diff --git a/class/importnotification.class.php b/class/importnotification.class.php new file mode 100755 index 0000000..8fedf26 --- /dev/null +++ b/class/importnotification.class.php @@ -0,0 +1,389 @@ +db = $db; + } + + /** + * Check if notifications are enabled + * + * @return bool True if enabled + */ + public function isEnabled() + { + return getDolGlobalString('IMPORTZUGFERD_NOTIFY_ENABLED') && getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL'); + } + + /** + * Get notification email address + * + * @return string Email address + */ + public function getNotifyEmail() + { + return getDolGlobalString('IMPORTZUGFERD_NOTIFY_EMAIL'); + } + + /** + * Send notification for manual intervention required + * + * @param ZugferdImport $import Import object + * @param array $lines Import lines + * @return int 1 if sent, 0 if not needed, -1 on error + */ + public function sendManualInterventionNotification($import, $lines = array()) + { + global $conf, $langs; + + if (!$this->isEnabled() || !getDolGlobalString('IMPORTZUGFERD_NOTIFY_MANUAL')) { + return 0; + } + + $langs->load('importzugferd@importzugferd'); + + $subject = $langs->trans('NotifySubjectManualIntervention', $import->invoice_number); + + $body = $langs->trans('NotifyBodyManualIntervention', $import->invoice_number, $import->seller_name); + $body .= "\n\n"; + $body .= $langs->trans('InvoiceNumber').': '.$import->invoice_number."\n"; + $body .= $langs->trans('Supplier').': '.$import->seller_name."\n"; + $body .= $langs->trans('InvoiceDate').': '.dol_print_date($import->invoice_date, 'day')."\n"; + $body .= $langs->trans('TotalTTC').': '.price($import->total_ttc).' '.$import->currency."\n"; + $body .= "\n"; + + // List issues + $missingProducts = 0; + $missingSupplier = ($import->fk_soc <= 0); + + if (!empty($lines)) { + foreach ($lines as $line) { + if ($line->fk_product <= 0) { + $missingProducts++; + } + } + } + + if ($missingSupplier) { + $body .= "- ".$langs->trans('SupplierNotAssigned')."\n"; + } + if ($missingProducts > 0) { + $body .= "- ".$missingProducts." ".$langs->trans('ProductsNotAssigned')."\n"; + } + + $body .= "\n"; + $body .= $langs->trans('NotifyLinkToImport').': '.dol_buildpath('/importzugferd/import.php', 2).'?action=edit&id='.$import->id; + + return $this->sendEmail($subject, $body); + } + + /** + * Send notification for import error + * + * @param ZugferdImport $import Import object (may be partial) + * @param string $errorMessage Error message + * @param string $filename Original filename + * @return int 1 if sent, 0 if not needed, -1 on error + */ + public function sendErrorNotification($import, $errorMessage, $filename = '') + { + global $conf, $langs; + + if (!$this->isEnabled() || !getDolGlobalString('IMPORTZUGFERD_NOTIFY_ERROR')) { + return 0; + } + + $langs->load('importzugferd@importzugferd'); + + $invoiceNum = !empty($import->invoice_number) ? $import->invoice_number : $filename; + $subject = $langs->trans('NotifySubjectError', $invoiceNum); + + $body = $langs->trans('NotifyBodyError', $invoiceNum); + $body .= "\n\n"; + + if (!empty($import->invoice_number)) { + $body .= $langs->trans('InvoiceNumber').': '.$import->invoice_number."\n"; + } + if (!empty($import->seller_name)) { + $body .= $langs->trans('Supplier').': '.$import->seller_name."\n"; + } + if (!empty($filename)) { + $body .= $langs->trans('File').': '.$filename."\n"; + } + + $body .= "\n"; + $body .= $langs->trans('ErrorMessage').":\n"; + $body .= $errorMessage."\n"; + + if ($import->id > 0) { + $body .= "\n"; + $body .= $langs->trans('NotifyLinkToImport').': '.dol_buildpath('/importzugferd/import.php', 2).'?action=edit&id='.$import->id; + } + + return $this->sendEmail($subject, $body); + } + + /** + * Send notification for significant price differences + * + * @param ZugferdImport $import Import object + * @param array $priceDiffs Array of price differences: array of ['line' => ImportLine, 'product' => Product, 'old_price' => float, 'new_price' => float, 'diff_percent' => float] + * @return int 1 if sent, 0 if not needed, -1 on error + */ + public function sendPriceDifferenceNotification($import, $priceDiffs) + { + global $conf, $langs; + + if (!$this->isEnabled() || !getDolGlobalString('IMPORTZUGFERD_NOTIFY_PRICE_DIFF')) { + return 0; + } + + if (empty($priceDiffs)) { + return 0; + } + + $langs->load('importzugferd@importzugferd'); + + $threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10); + $subject = $langs->trans('NotifySubjectPriceDiff', $import->invoice_number, count($priceDiffs)); + + $body = $langs->trans('NotifyBodyPriceDiff', $import->invoice_number, $import->seller_name, $threshold); + $body .= "\n\n"; + $body .= $langs->trans('InvoiceNumber').': '.$import->invoice_number."\n"; + $body .= $langs->trans('Supplier').': '.$import->seller_name."\n"; + $body .= $langs->trans('InvoiceDate').': '.dol_print_date($import->invoice_date, 'day')."\n"; + $body .= "\n"; + + // Table header + $body .= str_pad($langs->trans('Product'), 40)." | "; + $body .= str_pad($langs->trans('OldPrice'), 12)." | "; + $body .= str_pad($langs->trans('NewPrice'), 12)." | "; + $body .= str_pad($langs->trans('Difference'), 10)."\n"; + $body .= str_repeat('-', 80)."\n"; + + // List products with price differences + foreach ($priceDiffs as $diff) { + $productName = $diff['product']->ref.' - '.$diff['product']->label; + if (strlen($productName) > 38) { + $productName = substr($productName, 0, 35).'...'; + } + + $oldPrice = price($diff['old_price']).' '.$import->currency; + $newPrice = price($diff['new_price']).' '.$import->currency; + $diffPercent = ($diff['diff_percent'] > 0 ? '+' : '').number_format($diff['diff_percent'], 1).'%'; + + $body .= str_pad($productName, 40)." | "; + $body .= str_pad($oldPrice, 12)." | "; + $body .= str_pad($newPrice, 12)." | "; + $body .= str_pad($diffPercent, 10)."\n"; + } + + $body .= "\n"; + $body .= $langs->trans('NotifyLinkToImport').': '.dol_buildpath('/importzugferd/import.php', 2).'?action=edit&id='.$import->id; + + return $this->sendEmail($subject, $body); + } + + /** + * Check for price differences and send notification if needed + * + * @param ZugferdImport $import Import object + * @param array $lines Import lines with fk_product set + * @return int 1 if notification sent, 0 if not needed, -1 on error + */ + public function checkAndNotifyPriceDifferences($import, $lines) + { + global $conf; + + if (!$this->isEnabled() || !getDolGlobalString('IMPORTZUGFERD_NOTIFY_PRICE_DIFF')) { + return 0; + } + + $threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10); + $priceDiffs = array(); + + require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php'; + + foreach ($lines as $line) { + if ($line->fk_product <= 0) { + continue; + } + + // Get current supplier price + $productFourn = new ProductFournisseur($this->db); + $result = $productFourn->find_min_price_product_fournisseur($line->fk_product, 1, $import->fk_soc); + + if ($result > 0 && $productFourn->fourn_price > 0) { + $oldPrice = $productFourn->fourn_price; + $newPrice = $line->unit_price; + + // Calculate percentage difference + $diffPercent = (($newPrice - $oldPrice) / $oldPrice) * 100; + + if (abs($diffPercent) >= $threshold) { + $product = new Product($this->db); + $product->fetch($line->fk_product); + + $priceDiffs[] = array( + 'line' => $line, + 'product' => $product, + 'old_price' => $oldPrice, + 'new_price' => $newPrice, + 'diff_percent' => $diffPercent + ); + } + } + } + + if (!empty($priceDiffs)) { + return $this->sendPriceDifferenceNotification($import, $priceDiffs); + } + + return 0; + } + + /** + * Send test notification email + * + * @return int 1 if sent, -1 on error + */ + public function sendTestNotification() + { + global $conf, $langs; + + if (!$this->isEnabled()) { + $this->error = $langs->trans('NotificationsNotEnabled'); + return -1; + } + + $langs->load('importzugferd@importzugferd'); + + $subject = $langs->trans('NotifySubjectTest'); + + $body = $langs->trans('NotifyBodyTest'); + $body .= "\n\n"; + $body .= $langs->trans('NotifyTestInfo')."\n\n"; + + // Show current notification settings + $body .= $langs->trans('CurrentSettings').":\n"; + $body .= "- ".$langs->trans('NotifyEmail').": ".$this->getNotifyEmail()."\n"; + $body .= "- ".$langs->trans('IMPORTZUGFERD_NOTIFY_MANUAL').": ".(getDolGlobalString('IMPORTZUGFERD_NOTIFY_MANUAL') ? $langs->trans('Yes') : $langs->trans('No'))."\n"; + $body .= "- ".$langs->trans('IMPORTZUGFERD_NOTIFY_ERROR').": ".(getDolGlobalString('IMPORTZUGFERD_NOTIFY_ERROR') ? $langs->trans('Yes') : $langs->trans('No'))."\n"; + $body .= "- ".$langs->trans('IMPORTZUGFERD_NOTIFY_PRICE_DIFF').": ".(getDolGlobalString('IMPORTZUGFERD_NOTIFY_PRICE_DIFF') ? $langs->trans('Yes') : $langs->trans('No'))."\n"; + + if (getDolGlobalString('IMPORTZUGFERD_NOTIFY_PRICE_DIFF')) { + $body .= "- ".$langs->trans('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD').": ".getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10)."%\n"; + } + + $body .= "\n"; + $body .= $langs->trans('NotifyTestSuccess'); + + return $this->sendEmail($subject, $body); + } + + /** + * Send email using Dolibarr's mail system + * + * @param string $subject Email subject + * @param string $body Email body (plain text) + * @return int 1 if sent, -1 on error + */ + protected function sendEmail($subject, $body) + { + global $conf, $langs, $mysoc; + + $to = $this->getNotifyEmail(); + if (empty($to)) { + $this->error = 'No notification email configured'; + return -1; + } + + // Get sender + $from = getDolGlobalString('MAIN_MAIL_EMAIL_FROM'); + if (empty($from)) { + $from = $mysoc->email; + } + if (empty($from)) { + $this->error = 'No sender email configured'; + return -1; + } + + // Add module prefix to subject + $subject = '[ZUGFeRD Import] '.$subject; + + // Create mail object + $mailfile = new CMailFile( + $subject, + $to, + $from, + $body, + array(), // files + array(), // mimefiles + array(), // ccfiles + '', // cc + '', // bcc + 0, // deliveryreceipt + 0, // msgishtml + '', // errors_to + '', // css + '', // trackid + '', // moreinheader + 'standard', // sendcontext + '' // replyto + ); + + $result = $mailfile->sendfile(); + + if ($result) { + dol_syslog("ImportNotification: Email sent to ".$to." - Subject: ".$subject, LOG_INFO); + return 1; + } else { + $this->error = $mailfile->error; + $this->errors = $mailfile->errors; + dol_syslog("ImportNotification: Failed to send email - ".$this->error, LOG_ERR); + return -1; + } + } +} diff --git a/class/productmapping.class.php b/class/productmapping.class.php new file mode 100755 index 0000000..9825b49 --- /dev/null +++ b/class/productmapping.class.php @@ -0,0 +1,478 @@ +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(); + } + + $this->fk_user_creat = $user->id; + + $sql = "INSERT INTO " . MAIN_DB_PREFIX . $this->table_element . " ("; + $sql .= "fk_soc, supplier_ref, fk_product, ean, manufacturer_ref,"; + $sql .= "description, priority, active, date_creation, fk_user_creat, entity"; + $sql .= ") VALUES ("; + $sql .= (int) $this->fk_soc . ","; + $sql .= "'" . $this->db->escape($this->supplier_ref) . "',"; + $sql .= (int) $this->fk_product . ","; + $sql .= "'" . $this->db->escape($this->ean) . "',"; + $sql .= "'" . $this->db->escape($this->manufacturer_ref) . "',"; + $sql .= "'" . $this->db->escape($this->description) . "',"; + $sql .= (int) $this->priority . ","; + $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; + } + + /** + * 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, supplier_ref, fk_product, ean, manufacturer_ref,"; + $sql .= " description, priority, 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->id = $obj->rowid; + $this->fk_soc = $obj->fk_soc; + $this->supplier_ref = $obj->supplier_ref; + $this->fk_product = $obj->fk_product; + $this->ean = $obj->ean; + $this->manufacturer_ref = $obj->manufacturer_ref; + $this->description = $obj->description; + $this->priority = $obj->priority; + $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; + + $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 + * @return int <0 if KO, >0 if OK + */ + public function update($user) + { + $this->fk_user_modif = $user->id; + + $sql = "UPDATE " . MAIN_DB_PREFIX . $this->table_element . " SET"; + $sql .= " fk_soc = " . (int) $this->fk_soc . ","; + $sql .= " supplier_ref = '" . $this->db->escape($this->supplier_ref) . "',"; + $sql .= " fk_product = " . (int) $this->fk_product . ","; + $sql .= " ean = '" . $this->db->escape($this->ean) . "',"; + $sql .= " manufacturer_ref = '" . $this->db->escape($this->manufacturer_ref) . "',"; + $sql .= " description = '" . $this->db->escape($this->description) . "',"; + $sql .= " priority = " . (int) $this->priority . ","; + $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; + } + + /** + * Find product by supplier reference + * + * @param int $fk_soc Supplier ID + * @param string $supplier_ref Supplier article number + * @return int Product ID or 0 if not found + */ + public function findProductBySupplierRef($fk_soc, $supplier_ref) + { + global $conf; + + // First check our mapping table + $sql = "SELECT fk_product FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE fk_soc = " . (int) $fk_soc; + $sql .= " AND supplier_ref = '" . $this->db->escape($supplier_ref) . "'"; + $sql .= " AND active = 1"; + $sql .= " AND entity = " . (int) $conf->entity; + $sql .= " ORDER BY priority DESC"; + $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->fk_product; + } + + return 0; + } + + /** + * Find product by EAN + * + * @param string $ean EAN/GTIN + * @return int Product ID or 0 if not found + */ + public function findProductByEan($ean) + { + global $conf; + + if (empty($ean)) { + return 0; + } + + // First check our mapping table + $sql = "SELECT fk_product FROM " . MAIN_DB_PREFIX . $this->table_element; + $sql .= " WHERE ean = '" . $this->db->escape($ean) . "'"; + $sql .= " AND active = 1"; + $sql .= " AND entity = " . (int) $conf->entity; + $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->fk_product; + } + + // Check product barcode + $sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product"; + $sql .= " WHERE barcode = '" . $this->db->escape($ean) . "'"; + $sql .= " AND entity IN (" . getEntity('product') . ")"; + $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; + } + + /** + * Find product by supplier price reference + * + * @param int $fk_soc Supplier ID + * @param string $ref_fourn Supplier reference + * @return int Product ID or 0 if not found + */ + public function findProductBySupplierPrice($fk_soc, $ref_fourn) + { + global $conf; + + $sql = "SELECT fk_product FROM " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sql .= " WHERE fk_soc = " . (int) $fk_soc; + $sql .= " AND ref_fourn = '" . $this->db->escape($ref_fourn) . "'"; + $sql .= " AND entity = " . (int) $conf->entity; + $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->fk_product; + } + + return 0; + } + + /** + * Find product using all available methods + * + * @param int $fk_soc Supplier ID + * @param array $product_data Product data from ZUGFeRD (seller_id, buyer_id, global_id, name) + * @return array Array with 'fk_product' and 'method' used + */ + public function findProduct($fk_soc, $product_data) + { + $result = array('fk_product' => 0, 'method' => ''); + + // 1. Check our mapping table with supplier reference + if (!empty($product_data['seller_id'])) { + $fk_product = $this->findProductBySupplierRef($fk_soc, $product_data['seller_id']); + if ($fk_product > 0) { + return array('fk_product' => $fk_product, 'method' => 'mapping_supplier_ref'); + } + } + + // 2. Check supplier price table + if (!empty($product_data['seller_id'])) { + $fk_product = $this->findProductBySupplierPrice($fk_soc, $product_data['seller_id']); + if ($fk_product > 0) { + return array('fk_product' => $fk_product, 'method' => 'supplier_price'); + } + } + + // 3. Check by EAN/GTIN + if (!empty($product_data['global_id'])) { + $fk_product = $this->findProductByEan($product_data['global_id']); + if ($fk_product > 0) { + return array('fk_product' => $fk_product, 'method' => 'ean'); + } + } + + // 4. Check buyer assigned ID (our article number) + if (!empty($product_data['buyer_id'])) { + global $conf; + $sql = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product"; + $sql .= " WHERE ref = '" . $this->db->escape($product_data['buyer_id']) . "'"; + $sql .= " AND entity IN (" . getEntity('product') . ")"; + $sql .= " LIMIT 1"; + + $resql = $this->db->query($sql); + if ($resql && $this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + return array('fk_product' => (int) $obj->rowid, 'method' => 'buyer_ref'); + } + } + + return $result; + } + + /** + * Get all mappings for a supplier + * + * @param int $fk_soc Supplier ID + * @param int $limit Limit results + * @param int $offset Offset + * @return array Array of mappings + */ + public function fetchAllBySupplier($fk_soc, $limit = 0, $offset = 0) + { + global $conf; + + $mappings = array(); + + $sql = "SELECT pm.rowid, pm.fk_soc, pm.supplier_ref, pm.fk_product, pm.ean,"; + $sql .= " pm.manufacturer_ref, pm.description, pm.priority, pm.active,"; + $sql .= " p.ref as product_ref, p.label as product_label"; + $sql .= " FROM " . MAIN_DB_PREFIX . $this->table_element . " as pm"; + $sql .= " LEFT JOIN " . MAIN_DB_PREFIX . "product as p ON p.rowid = pm.fk_product"; + $sql .= " WHERE pm.fk_soc = " . (int) $fk_soc; + $sql .= " AND pm.entity = " . (int) $conf->entity; + $sql .= " ORDER BY pm.supplier_ref ASC"; + + if ($limit > 0) { + $sql .= " LIMIT " . $limit; + if ($offset > 0) { + $sql .= " OFFSET " . $offset; + } + } + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $mappings[] = array( + 'id' => $obj->rowid, + 'fk_soc' => $obj->fk_soc, + 'supplier_ref' => $obj->supplier_ref, + 'fk_product' => $obj->fk_product, + 'product_ref' => $obj->product_ref, + 'product_label' => $obj->product_label, + 'ean' => $obj->ean, + 'manufacturer_ref' => $obj->manufacturer_ref, + 'description' => $obj->description, + 'priority' => $obj->priority, + 'active' => $obj->active, + ); + } + $this->db->free($resql); + } + + return $mappings; + } + + /** + * Count mappings 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; + } +} diff --git a/class/zugferdimport.class.php b/class/zugferdimport.class.php new file mode 100755 index 0000000..bfdda55 --- /dev/null +++ b/class/zugferdimport.class.php @@ -0,0 +1,795 @@ + 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']; + + // Copper surcharge (Kupferzuschlag) from ZUGFeRD - always set (0 if not present) + if (isset($line_data['copper_surcharge']) && $line_data['copper_surcharge'] > 0) { + $line->copper_surcharge = $line_data['copper_surcharge']; + $line->copper_surcharge_basis_qty = isset($line_data['copper_surcharge_basis_qty']) ? $line_data['copper_surcharge_basis_qty'] : $line->basis_quantity; + } else { + $line->copper_surcharge = 0; + $line->copper_surcharge_basis_qty = $line->basis_quantity; + } + + // 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']; + + // Update supplier price with EAN from invoice if empty + $invoiceEan = !empty($line_data['product']['global_id']) ? trim($line_data['product']['global_id']) : ''; + $supplierRef = !empty($line_data['product']['seller_id']) ? $line_data['product']['seller_id'] : ''; + if (!empty($invoiceEan) && !empty($supplierRef) && ctype_digit($invoiceEan)) { + // Barcode-Typ basierend auf Länge bestimmen + $eanLen = strlen($invoiceEan); + if ($eanLen == 13) { + $barcodeType = 2; // EAN13 + } elseif ($eanLen == 8) { + $barcodeType = 1; // EAN8 + } elseif ($eanLen == 12) { + $barcodeType = 3; // UPC-A + } else { + $barcodeType = 0; // Unbekannt + } + + $sqlEan = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlEan .= " SET barcode = '" . $this->db->escape($invoiceEan) . "'"; + $sqlEan .= ", fk_barcode_type = " . (int)$barcodeType; + $sqlEan .= " WHERE fk_product = " . (int)$fk_product; + $sqlEan .= " AND fk_soc = " . (int)$supplier_id; + $sqlEan .= " AND ref_fourn = '" . $this->db->escape($supplierRef) . "'"; + $sqlEan .= " AND (barcode IS NULL OR barcode = '')"; + $this->db->query($sqlEan); + } + } + } + + $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']); + } +} diff --git a/class/zugferdparser.class.php b/class/zugferdparser.class.php new file mode 100755 index 0000000..edeb6e1 --- /dev/null +++ b/class/zugferdparser.class.php @@ -0,0 +1,645 @@ +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 /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; + } +} diff --git a/core/boxes/box_new_products.php b/core/boxes/box_new_products.php new file mode 100755 index 0000000..c8c0647 --- /dev/null +++ b/core/boxes/box_new_products.php @@ -0,0 +1,98 @@ +db = $db; + } + + public function loadBox($max = 5) + { + global $langs; + + $langs->load('importzugferd@importzugferd'); + + // Auf Produktseite alle Einträge zeigen + if (strpos($_SERVER['PHP_SELF'], '/product/index.php') !== false) { + $max = 0; // 0 = kein Limit + } + + include_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; + $productstatic = new Product($this->db); + + // Anzahl zählen + $sql = "SELECT COUNT(*) as total FROM ".MAIN_DB_PREFIX."product WHERE ref LIKE 'New%'"; + $resql = $this->db->query($sql); + $total = 0; + if ($resql) { + $obj = $this->db->fetch_object($resql); + $total = $obj->total; + } + + $this->info_box_head = array( + 'text' => $langs->trans("BoxNewProductsToReview").' '.$total.'', + 'sublink' => dol_buildpath('/importzugferd/new_products.php', 1), + 'subtext' => $langs->trans("ShowAll"), + 'subpicto' => 'object_product', + ); + + // Produkte laden + $sql = "SELECT rowid, ref, label, datec FROM ".MAIN_DB_PREFIX."product"; + $sql .= " WHERE ref LIKE 'New%'"; + $sql .= " ORDER BY datec DESC"; + if ($max > 0) { + $sql .= " LIMIT ".((int) $max); + } + + $result = $this->db->query($sql); + if ($result) { + $num = $this->db->num_rows($result); + $line = 0; + + while ($line < $num) { + $objp = $this->db->fetch_object($result); + + $productstatic->id = $objp->rowid; + $productstatic->ref = $objp->ref; + $productstatic->label = $objp->label; + + $this->info_box_contents[$line][] = array( + 'td' => 'class="tdoverflowmax150"', + 'text' => $productstatic->getNomUrl(1), + 'asis' => 1, + ); + + $this->info_box_contents[$line][] = array( + 'td' => 'class="tdoverflowmax150"', + 'text' => $objp->label, + ); + + $this->info_box_contents[$line][] = array( + 'td' => 'class="right"', + 'text' => dol_print_date($this->db->jdate($objp->datec), 'day'), + ); + + $line++; + } + + if ($num == 0) { + $this->info_box_contents[0][0] = array( + 'td' => 'class="center"', + 'text' => $langs->trans("NoNewProductsToReview"), + ); + } + } + } + + public function showBox($head = null, $contents = null, $nooutput = 0) + { + return parent::showBox($this->info_box_head, $this->info_box_contents, $nooutput); + } +} diff --git a/core/modules/modImportZugferd.class.php b/core/modules/modImportZugferd.class.php new file mode 100755 index 0000000..a5af7bc --- /dev/null +++ b/core/modules/modImportZugferd.class.php @@ -0,0 +1,787 @@ + + * Copyright (C) 2018-2019 Nicolas ZABOURI + * Copyright (C) 2019-2024 Frédéric France + * Copyright (C) 2026 Eduard Wisch + * + * 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. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \defgroup importzugferd Module ImportZugferd + * \brief ImportZugferd module descriptor. + * + * \file htdocs/importzugferd/core/modules/modImportZugferd.class.php + * \ingroup importzugferd + * \brief Description and activation file for module ImportZugferd + */ +include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php'; + + +/** + * Description and activation class for module ImportZugferd + */ +class modImportZugferd extends DolibarrModules +{ + /** + * Constructor. Define names, constants, directories, boxes, permissions + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + global $conf, $langs; + + $this->db = $db; + + // Id for module (must be unique). + // Use here a free id (See in Home -> System information -> Dolibarr for list of used modules id). + $this->numero = 500016; // TODO Go on page https://wiki.dolibarr.org/index.php/List_of_modules_id to reserve an id number for your module + + // Key text used to identify module (for permissions, menus, etc...) + $this->rights_class = 'importzugferd'; + + // Family can be 'base' (core modules),'crm','financial','hr','projects','products','ecm','technic' (transverse modules),'interface' (link with external tools),'other','...' + // It is used to group modules by family in module setup page + $this->family = "other"; + + // Module position in the family on 2 digits ('01', '10', '20', ...) + $this->module_position = '90'; + + // Gives the possibility for the module, to provide his own family info and position of this family (Overwrite $this->family and $this->module_position. Avoid this) + //$this->familyinfo = array('myownfamily' => array('position' => '01', 'label' => $langs->trans("MyOwnFamily"))); + // Module label (no space allowed), used if translation string 'ModuleImportZugferdName' not found (ImportZugferd is name of module). + $this->name = preg_replace('/^mod/i', '', get_class($this)); + + // DESCRIPTION_FLAG + // Module description, used if translation string 'ModuleImportZugferdDesc' not found (ImportZugferd is name of module). + $this->description = "ImportZugferdDescription"; + // Used only if file README.md and README-LL.md not found. + $this->descriptionlong = "ImportZugferdDescription"; + + // Author + $this->editor_name = 'Alles Watt läuft (Testsystem)'; + $this->editor_url = ''; // Must be an external online web site + $this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@importzugferd' + + // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' + $this->version = '5.5'; + // Url to the file with your last numberversion of this module + //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; + + // Key used in llx_const table to save module status enabled/disabled (where IMPORTZUGFERD is value of property name of module in uppercase) + $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name); + + // Name of image file used for this module. + // If file is in theme/yourtheme/img directory under name object_pictovalue.png, use this->picto='pictovalue' + // If file is in module/img directory under name object_pictovalue.png, use this->picto='pictovalue@module' + // To use a supported fa-xxx css style of font awesome, use this->picto='xxx' + $this->picto = 'fa-file-invoice'; + + // Define some features supported by module (triggers, login, substitutions, menus, css, etc...) + $this->module_parts = array( + // Set this to 1 if module has its own trigger directory (core/triggers) + 'triggers' => 0, + // Set this to 1 if module has its own login method file (core/login) + 'login' => 0, + // Set this to 1 if module has its own substitution function file (core/substitutions) + 'substitutions' => 0, + // Set this to 1 if module has its own menus handler directory (core/menus) + 'menus' => 0, + // Set this to 1 if module overwrite template dir (core/tpl) + 'tpl' => 0, + // Set this to 1 if module has its own barcode directory (core/modules/barcode) + 'barcode' => 0, + // Set this to 1 if module has its own models directory (core/modules/xxx) + 'models' => 0, + // Set this to 1 if module has its own printing directory (core/modules/printing) + 'printing' => 0, + // Set this to 1 if module has its own theme directory (theme) + 'theme' => 0, + // Set this to relative path of css file if module has its own css file + 'css' => array( + '/importzugferd/css/importzugferd.css.php', + ), + // Set this to relative path of js file if module must load a js on all pages + 'js' => array( + // '/importzugferd/js/importzugferd.js.php', + ), + // Set here all hooks context managed by module. To find available hook context, make a "grep -r '>initHooks(' *" on source code. You can also set hook context to 'all' + /* BEGIN MODULEBUILDER HOOKSCONTEXTS */ + 'hooks' => array( + 'data' => array( + 'index', + 'productindex', + ), + 'entity' => '0', + ), + /* END MODULEBUILDER HOOKSCONTEXTS */ + // Set this to 1 if features of module are opened to external users + 'moduleforexternal' => 0, + // Set this to 1 if the module provides a website template into doctemplates/websites/website_template-mytemplate + 'websitetemplates' => 0, + // Set this to 1 if the module provides a captcha driver + 'captcha' => 0 + ); + + // Data directories to create when module is enabled. + $this->dirs = array("/importzugferd/temp", "/importzugferd/imports", "/importzugferd/datanorm"); + + // Config pages. Put here list of php page, stored into importzugferd/admin directory, to use to setup module. + $this->config_page_url = array("setup.php@importzugferd"); + + // Dependencies + // A condition to hide module + $this->hidden = getDolGlobalInt('MODULE_IMPORTZUGFERD_DISABLED'); // A condition to disable module; + // List of module class names that must be enabled if this module is enabled. Example: array('always'=>array('modModuleToEnable1','modModuleToEnable2'), 'FR'=>array('modModuleToEnableFR')...) + $this->depends = array(); + // List of module class names to disable if this one is disabled. Example: array('modModuleToDisable1', ...) + $this->requiredby = array(); + // List of module class names this module is in conflict with. Example: array('modModuleToDisable1', ...) + $this->conflictwith = array(); + + // The language file dedicated to your module + $this->langfiles = array("importzugferd@importzugferd"); + + // Prerequisites + $this->phpmin = array(7, 1); // Minimum version of PHP required by module + // $this->phpmax = array(8, 0); // Maximum version of PHP required by module + $this->need_dolibarr_version = array(19, -3); // Minimum version of Dolibarr required by module + // $this->max_dolibarr_version = array(19, -3); // Maximum version of Dolibarr required by module + $this->need_javascript_ajax = 0; + + // Messages at activation + $this->warnings_activation = array(); // Warning to show when we activate module. array('always'='text') or array('FR'='textfr','MX'='textmx'...) + $this->warnings_activation_ext = array(); // Warning to show when we activate an external module. array('always'='text') or array('FR'='textfr','MX'='textmx'...) + //$this->automatic_activation = array('FR'=>'ImportZugferdWasAutomaticallyActivatedBecauseOfYourCountryChoice'); + //$this->always_enabled = true; // If true, can't be disabled + + // Constants + // List of particular constants to add when module is enabled (key, 'chaine', value, desc, visible, 'current' or 'allentities', deleteonunactive) + // Example: $this->const=array(1 => array('IMPORTZUGFERD_MYNEWCONST1', 'chaine', 'myvalue', 'This is a constant to add', 1), + // 2 => array('IMPORTZUGFERD_MYNEWCONST2', 'chaine', 'myvalue', 'This is another constant to add', 0, 'current', 1) + // ); + $this->const = array(); + + // Some keys to add into the overwriting translation tables + /*$this->overwrite_translation = array( + 'en_US:ParentCompany'=>'Parent company or reseller', + 'fr_FR:ParentCompany'=>'Maison mère ou revendeur' + )*/ + + if (!isModEnabled("importzugferd")) { + $conf->importzugferd = new stdClass(); + $conf->importzugferd->enabled = 0; + } + + // Array to add new pages in new tabs + /* BEGIN MODULEBUILDER TABS */ + $this->tabs = array(); + /* END MODULEBUILDER TABS */ + // Example: + // To add a new tab identified by code tabname1 + // $this->tabs[] = array('data' => 'objecttype:+tabname1:Title1:mylangfile@importzugferd:$user->hasRight(\'importzugferd\', \'read\'):/importzugferd/mynewtab1.php?id=__ID__'); + // To add another new tab identified by code tabname2. Label will be result of calling all substitution functions on 'Title2' key. + // $this->tabs[] = array('data' => 'objecttype:+tabname2:SUBSTITUTION_Title2:mylangfile@importzugferd:$user->hasRight(\'othermodule\', \'read\'):/importzugferd/mynewtab2.php?id=__ID__', + // To remove an existing tab identified by code tabname + // $this->tabs[] = array('data' => 'objecttype:-tabname:NU:conditiontoremove'); + // + // Where objecttype can be + // 'categories_x' to add a tab in category view (replace 'x' by type of category (0=product, 1=supplier, 2=customer, 3=member) + // 'contact' to add a tab in contact view + // 'contract' to add a tab in contract view + // 'delivery' to add a tab in delivery view + // 'group' to add a tab in group view + // 'intervention' to add a tab in intervention view + // 'invoice' to add a tab in customer invoice view + // 'supplier_invoice' to add a tab in supplier invoice view + // 'member' to add a tab in foundation member view + // 'opensurveypoll' to add a tab in opensurvey poll view + // 'order' to add a tab in sale order view + // 'supplier_order' to add a tab in supplier order view + // 'payment' to add a tab in payment view + // 'supplier_payment' to add a tab in supplier payment view + // 'product' to add a tab in product view + // 'propal' to add a tab in propal view + // 'project' to add a tab in project view + // 'stock' to add a tab in stock view + // 'thirdparty' to add a tab in third party view + // 'user' to add a tab in user view + + + // Dictionaries + /* Example: + $this->dictionaries=array( + 'langs' => 'importzugferd@importzugferd', + // List of tables we want to see into dictionary editor + 'tabname' => array("table1", "table2", "table3"), + // Label of tables + 'tablib' => array("Table1", "Table2", "Table3"), + // Request to select fields + 'tabsql' => array('SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table1 as f', 'SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table2 as f', 'SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table3 as f'), + // Sort order + 'tabsqlsort' => array("label ASC", "label ASC", "label ASC"), + // List of fields (result of select to show dictionary) + 'tabfield' => array("code,label", "code,label", "code,label"), + // List of fields (list of fields to edit a record) + 'tabfieldvalue' => array("code,label", "code,label", "code,label"), + // List of fields (list of fields for insert) + 'tabfieldinsert' => array("code,label", "code,label", "code,label"), + // Name of columns with primary key (try to always name it 'rowid') + 'tabrowid' => array("rowid", "rowid", "rowid"), + // Condition to show each dictionary + 'tabcond' => array(isModEnabled('importzugferd'), isModEnabled('importzugferd'), isModEnabled('importzugferd')), + // Tooltip for every fields of dictionaries: DO NOT PUT AN EMPTY ARRAY + 'tabhelp' => array(array('code' => $langs->trans('CodeTooltipHelp'), 'field2' => 'field2tooltip'), array('code' => $langs->trans('CodeTooltipHelp'), 'field2' => 'field2tooltip'), ...), + ); + */ + /* BEGIN MODULEBUILDER DICTIONARIES */ + $this->dictionaries = array(); + /* END MODULEBUILDER DICTIONARIES */ + + // Boxes/Widgets + // Add here list of php file(s) stored in importzugferd/core/boxes that contains a class to show a widget. + /* BEGIN MODULEBUILDER WIDGETS */ + $this->boxes = array( + 0 => array( + 'file' => 'box_new_products.php@importzugferd', + 'note' => 'Widget showing new products to review', + 'enabledbydefaulton' => 'Home', + ), + ); + /* END MODULEBUILDER WIDGETS */ + + // Cronjobs (List of cron jobs entries to add when module is enabled) + // unit_frequency must be 60 for minute, 3600 for hour, 86400 for day, 604800 for week + /* BEGIN MODULEBUILDER CRON */ + $this->cronjobs = array( + 0 => array( + 'label' => 'ImportZugferdScheduled', + 'jobtype' => 'method', + 'class' => '/importzugferd/class/cron_importzugferd.class.php', + 'objectname' => 'CronImportZugferd', + 'method' => 'runScheduledImport', + 'parameters' => '', + 'comment' => 'Scheduled import from folder and mailbox (frequency controlled by module settings)', + 'frequency' => 15, + 'unitfrequency' => 60, + 'status' => 1, + 'test' => 'isModEnabled("importzugferd")', + 'priority' => 50, + ), + ); + /* END MODULEBUILDER CRON */ + // Example: $this->cronjobs=array( + // 0=>array('label'=>'My label', 'jobtype'=>'method', 'class'=>'/dir/class/file.class.php', 'objectname'=>'MyClass', 'method'=>'myMethod', 'parameters'=>'param1, param2', 'comment'=>'Comment', 'frequency'=>2, 'unitfrequency'=>3600, 'status'=>0, 'test'=>'isModEnabled("importzugferd")', 'priority'=>50), + // 1=>array('label'=>'My label', 'jobtype'=>'command', 'command'=>'', 'parameters'=>'param1, param2', 'comment'=>'Comment', 'frequency'=>1, 'unitfrequency'=>3600*24, 'status'=>0, 'test'=>'isModEnabled("importzugferd")', 'priority'=>50) + // ); + + // Permissions provided by this module + $this->rights = array(); + $r = 0; + + $this->rights[$r][0] = $this->numero . sprintf("%02d", 1); + $this->rights[$r][1] = 'Read ZUGFeRD imports'; + $this->rights[$r][4] = 'import'; + $this->rights[$r][5] = 'read'; + $r++; + + $this->rights[$r][0] = $this->numero . sprintf("%02d", 2); + $this->rights[$r][1] = 'Create/Import ZUGFeRD invoices'; + $this->rights[$r][4] = 'import'; + $this->rights[$r][5] = 'write'; + $r++; + + $this->rights[$r][0] = $this->numero . sprintf("%02d", 3); + $this->rights[$r][1] = 'Delete ZUGFeRD imports'; + $this->rights[$r][4] = 'import'; + $this->rights[$r][5] = 'delete'; + $r++; + + $this->rights[$r][0] = $this->numero . sprintf("%02d", 4); + $this->rights[$r][1] = 'Manage product mappings'; + $this->rights[$r][4] = 'mapping'; + $this->rights[$r][5] = 'write'; + $r++; + + $this->rights[$r][0] = $this->numero . sprintf("%02d", 5); + $this->rights[$r][1] = 'Manage Datanorm catalogs'; + $this->rights[$r][4] = 'datanorm'; + $this->rights[$r][5] = 'write'; + $r++; + + + // Main menu entries to add + $this->menu = array(); + $r = 0; + // Add here entries to declare new menus + /* BEGIN MODULEBUILDER TOPMENU */ + $this->menu[$r++] = array( + 'fk_menu' => '', // Will be stored into mainmenu + leftmenu. Use '' if this is a top menu. For left menu, use 'fk_mainmenu=xxx' or 'fk_mainmenu=xxx,fk_leftmenu=yyy' where xxx is mainmenucode and yyy is a leftmenucode + 'type' => 'top', // This is a Top menu entry + 'titre' => 'ModuleImportZugferdName', + 'prefix' => img_picto('', $this->picto, 'class="pictofixedwidth valignmiddle"'), + 'mainmenu' => 'importzugferd', + 'leftmenu' => '', + 'url' => '/importzugferd/importzugferdindex.php', + 'langs' => 'importzugferd@importzugferd', // Lang file to use (without .lang) by module. File must be in langs/code_CODE/ directory. + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("importzugferd")', // Define condition to show or hide menu entry. Use 'isModEnabled("importzugferd")' if entry must be visible if module is enabled. + 'perms' => '1', // Use 'perms'=>'$user->hasRight("importzugferd", "myobject", "read")' if you want your menu with a permission rules + 'target' => '', + 'user' => 2, // 0=Menu for internal users, 1=external users, 2=both + ); + /* END MODULEBUILDER TOPMENU */ + + // Left menu: Import + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=importzugferd', + 'type' => 'left', + 'titre' => 'ZugferdImport', + 'prefix' => img_picto('', 'fa-file-import', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'importzugferd', + 'leftmenu' => 'zugferd_import', + 'url' => '/importzugferd/import.php', + 'langs' => 'importzugferd@importzugferd', + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("importzugferd")', + 'perms' => '$user->hasRight("importzugferd", "import", "write")', + 'target' => '', + 'user' => 2, + ); + + // Left menu: Import list + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=importzugferd', + 'type' => 'left', + 'titre' => 'ImportList', + 'prefix' => img_picto('', 'fa-list', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'importzugferd', + 'leftmenu' => 'zugferd_list', + 'url' => '/importzugferd/list.php', + 'langs' => 'importzugferd@importzugferd', + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("importzugferd")', + 'perms' => '$user->hasRight("importzugferd", "import", "read")', + 'target' => '', + 'user' => 2, + ); + + // Left menu: Product Mapping + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=importzugferd', + 'type' => 'left', + 'titre' => 'ProductMapping', + 'prefix' => img_picto('', 'fa-exchange-alt', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'importzugferd', + 'leftmenu' => 'zugferd_mapping', + 'url' => '/importzugferd/mapping.php', + 'langs' => 'importzugferd@importzugferd', + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("importzugferd")', + 'perms' => '$user->hasRight("importzugferd", "mapping", "write")', + 'target' => '', + 'user' => 2, + ); + + // Left menu: Batch Import + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=importzugferd', + 'type' => 'left', + 'titre' => 'BatchImport', + 'prefix' => img_picto('', 'fa-folder-open', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'importzugferd', + 'leftmenu' => 'zugferd_batch', + 'url' => '/importzugferd/batch.php', + 'langs' => 'importzugferd@importzugferd', + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("importzugferd")', + 'perms' => '$user->hasRight("importzugferd", "import", "write")', + 'target' => '', + 'user' => 2, + ); + + // Left menu: Datanorm Catalogs + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=importzugferd', + 'type' => 'left', + 'titre' => 'DatanormCatalogs', + 'prefix' => img_picto('', 'fa-database', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'importzugferd', + 'leftmenu' => 'zugferd_datanorm', + 'url' => '/importzugferd/datanorm.php', + 'langs' => 'importzugferd@importzugferd', + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("importzugferd")', + 'perms' => '$user->hasRight("importzugferd", "datanorm", "write")', + 'target' => '', + 'user' => 2, + ); + + // Left menu: Datanorm Mass Update + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=importzugferd', + 'type' => 'left', + 'titre' => 'DatanormMassUpdate', + 'prefix' => img_picto('', 'fa-sync', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'importzugferd', + 'leftmenu' => 'zugferd_datanorm_update', + 'url' => '/importzugferd/datanorm_update.php', + 'langs' => 'importzugferd@importzugferd', + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("importzugferd")', + 'perms' => '$user->hasRight("produit", "creer")', + 'target' => '', + 'user' => 2, + ); + + // Left menu: Datanorm Change Log + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=importzugferd', + 'type' => 'left', + 'titre' => 'DatanormChangeLog', + 'prefix' => img_picto('', 'fa-history', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'importzugferd', + 'leftmenu' => 'zugferd_datanorm_log', + 'url' => '/importzugferd/datanorm_changelog.php', + 'langs' => 'importzugferd@importzugferd', + 'position' => 1000 + $r, + 'enabled' => 'isModEnabled("importzugferd")', + 'perms' => '$user->hasRight("produit", "lire")', + 'target' => '', + 'user' => 2, + ); + + // Left menu entry under Products main menu: New Products to Review (after Statistics) + $this->menu[$r++] = array( + 'fk_menu' => 'fk_mainmenu=products,fk_leftmenu=product', + 'type' => 'left', + 'titre' => 'NewProductsToReview', + 'prefix' => img_picto('', 'fa-star', 'class="pictofixedwidth valignmiddle paddingright"'), + 'mainmenu' => 'products', + 'leftmenu' => 'new_products_review', + 'url' => '/importzugferd/new_products.php', + 'langs' => 'importzugferd@importzugferd', + 'position' => 650, + 'enabled' => 'isModEnabled("importzugferd") && isModEnabled("product")', + 'perms' => '$user->hasRight("produit", "lire")', + 'target' => '', + 'user' => 2, + ); + + + // Exports profiles provided by this module + $r = 0; + /* BEGIN MODULEBUILDER EXPORT MYOBJECT */ + /* + $langs->load("importzugferd@importzugferd"); + $this->export_code[$r] = $this->rights_class.'_'.$r; + $this->export_label[$r] = 'MyObjectLines'; // Translation key (used only if key ExportDataset_xxx_z not found) + $this->export_icon[$r] = $this->picto; + // Define $this->export_fields_array, $this->export_TypeFields_array and $this->export_entities_array + $keyforclass = 'MyObject'; $keyforclassfile='/importzugferd/class/myobject.class.php'; $keyforelement='myobject@importzugferd'; + include DOL_DOCUMENT_ROOT.'/core/commonfieldsinexport.inc.php'; + //$this->export_fields_array[$r]['t.fieldtoadd']='FieldToAdd'; $this->export_TypeFields_array[$r]['t.fieldtoadd']='Text'; + //unset($this->export_fields_array[$r]['t.fieldtoremove']); + //$keyforclass = 'MyObjectLine'; $keyforclassfile='/importzugferd/class/myobject.class.php'; $keyforelement='myobjectline@importzugferd'; $keyforalias='tl'; + //include DOL_DOCUMENT_ROOT.'/core/commonfieldsinexport.inc.php'; + $keyforselect='myobject'; $keyforaliasextra='extra'; $keyforelement='myobject@importzugferd'; + include DOL_DOCUMENT_ROOT.'/core/extrafieldsinexport.inc.php'; + //$keyforselect='myobjectline'; $keyforaliasextra='extraline'; $keyforelement='myobjectline@importzugferd'; + //include DOL_DOCUMENT_ROOT.'/core/extrafieldsinexport.inc.php'; + //$this->export_dependencies_array[$r] = array('myobjectline' => array('tl.rowid','tl.ref')); // To force to activate one or several fields if we select some fields that need same (like to select a unique key if we ask a field of a child to avoid the DISTINCT to discard them, or for computed field than need several other fields) + //$this->export_special_array[$r] = array('t.field' => '...'); + //$this->export_examplevalues_array[$r] = array('t.field' => 'Example'); + //$this->export_help_array[$r] = array('t.field' => 'FieldDescHelp'); + $this->export_sql_start[$r]='SELECT DISTINCT '; + $this->export_sql_end[$r] =' FROM '.$this->db->prefix().'importzugferd_myobject as t'; + //$this->export_sql_end[$r] .=' LEFT JOIN '.$this->db->prefix().'importzugferd_myobject_line as tl ON tl.fk_myobject = t.rowid'; + $this->export_sql_end[$r] .=' WHERE 1 = 1'; + $this->export_sql_end[$r] .=' AND t.entity IN ('.getEntity('myobject').')'; + $r++; */ + /* END MODULEBUILDER EXPORT MYOBJECT */ + + // Imports profiles provided by this module + $r = 0; + /* BEGIN MODULEBUILDER IMPORT MYOBJECT */ + /* + $langs->load("importzugferd@importzugferd"); + $this->import_code[$r] = $this->rights_class.'_'.$r; + $this->import_label[$r] = 'MyObjectLines'; // Translation key (used only if key ExportDataset_xxx_z not found) + $this->import_icon[$r] = $this->picto; + $this->import_tables_array[$r] = array('t' => $this->db->prefix().'importzugferd_myobject', 'extra' => $this->db->prefix().'importzugferd_myobject_extrafields'); + $this->import_tables_creator_array[$r] = array('t' => 'fk_user_author'); // Fields to store import user id + $import_sample = array(); + $keyforclass = 'MyObject'; $keyforclassfile='/importzugferd/class/myobject.class.php'; $keyforelement='myobject@importzugferd'; + include DOL_DOCUMENT_ROOT.'/core/commonfieldsinimport.inc.php'; + $import_extrafield_sample = array(); + $keyforselect='myobject'; $keyforaliasextra='extra'; $keyforelement='myobject@importzugferd'; + include DOL_DOCUMENT_ROOT.'/core/extrafieldsinimport.inc.php'; + $this->import_fieldshidden_array[$r] = array('extra.fk_object' => 'lastrowid-'.$this->db->prefix().'importzugferd_myobject'); + $this->import_regex_array[$r] = array(); + $this->import_examplevalues_array[$r] = array_merge($import_sample, $import_extrafield_sample); + $this->import_updatekeys_array[$r] = array('t.ref' => 'Ref'); + $this->import_convertvalue_array[$r] = array( + 't.ref' => array( + 'rule'=>'getrefifauto', + 'class'=>(!getDolGlobalString('IMPORTZUGFERD_MYOBJECT_ADDON') ? 'mod_myobject_standard' : getDolGlobalString('IMPORTZUGFERD_MYOBJECT_ADDON')), + 'path'=>"/core/modules/importzugferd/".(!getDolGlobalString('IMPORTZUGFERD_MYOBJECT_ADDON') ? 'mod_myobject_standard' : getDolGlobalString('IMPORTZUGFERD_MYOBJECT_ADDON')).'.php', + 'classobject'=>'MyObject', + 'pathobject'=>'/importzugferd/class/myobject.class.php', + ), + 't.fk_soc' => array('rule' => 'fetchidfromref', 'file' => '/societe/class/societe.class.php', 'class' => 'Societe', 'method' => 'fetch', 'element' => 'ThirdParty'), + 't.fk_user_valid' => array('rule' => 'fetchidfromref', 'file' => '/user/class/user.class.php', 'class' => 'User', 'method' => 'fetch', 'element' => 'user'), + 't.fk_mode_reglement' => array('rule' => 'fetchidfromcodeorlabel', 'file' => '/compta/paiement/class/cpaiement.class.php', 'class' => 'Cpaiement', 'method' => 'fetch', 'element' => 'cpayment'), + ); + $this->import_run_sql_after_array[$r] = array(); + $r++; */ + /* END MODULEBUILDER IMPORT MYOBJECT */ + } + + /** + * Function called when module is enabled. + * The init function add constants, boxes, permissions and menus (defined in constructor) into Dolibarr database. + * It also creates data directories + * + * @param string $options Options when enabling module ('', 'noboxes') + * @return int<-1,1> 1 if OK, <=0 if KO + */ + public function init($options = '') + { + global $conf, $langs; + + // Create tables of module at module activation + //$result = $this->_load_tables('/install/mysql/', 'importzugferd'); + $result = $this->_load_tables('/importzugferd/sql/'); + if ($result < 0) { + return -1; // Do not activate module if error 'not allowed' returned when loading module SQL queries (the _load_table run sql with run_sql with the error allowed parameter set to 'default') + } + + // Create extrafields during init + include_once DOL_DOCUMENT_ROOT.'/core/class/extrafields.class.php'; + $extrafields = new ExtraFields($this->db); + + // Add extrafield for supplier customer number (our customer ID at the supplier) + // Signature: addExtraField($attrname, $label, $type, $pos, $size, $elementtype, $unique, $required, $default_value, $param, $alwayseditable, $perms, $list, $help, $computed, $entity, $langfile, $enabled, $totalizable, $printable) + $extrafields->addExtraField( + 'supplier_customer_number', // 1. attribute code + 'SupplierCustomerNumber', // 2. label (translation key) + 'varchar', // 3. type + 100, // 4. position + 64, // 5. size + 'thirdparty', // 6. element type + 0, // 7. unique + 0, // 8. required + '', // 9. default value + '', // 10. param + 1, // 11. always editable + '', // 12. permission + 1, // 13. list (show in list) + '', // 14. help + '', // 15. computed + '', // 16. entity + 'importzugferd@importzugferd', // 17. langfile + 'isModEnabled("importzugferd")', // 18. enabled condition + 0, // 19. totalizable + 0 // 20. printable + ); + + // Add extrafield for metal surcharge (Kupferzuschlag) on supplier prices + $extrafields->addExtraField( + 'kupferzuschlag', // 1. attribute code + 'Kupferzuschlag', // 2. label + 'price', // 3. type (price field) + 110, // 4. position + '24,8', // 5. size + 'product_fournisseur_price', // 6. element type + 0, // 7. unique + 0, // 8. required + '', // 9. default value + '', // 10. param + 1, // 11. always editable + '', // 12. permission + 1, // 13. list (show in list) + 'Metallzuschlag (Kupfer) für diesen Einkaufspreis', // 14. help + '', // 15. computed + '', // 16. entity + 'importzugferd@importzugferd', // 17. langfile + 'isModEnabled("importzugferd")', // 18. enabled condition + 0, // 19. totalizable + 0 // 20. printable + ); + + // Add extrafield for product price without copper surcharge (only for cables) + $extrafields->addExtraField( + 'produktpreis', // 1. attribute code + 'Produktpreis', // 2. label + 'price', // 3. type (price field) + 115, // 4. position + '24,8', // 5. size + 'product_fournisseur_price', // 6. element type + 0, // 7. unique + 0, // 8. required + '', // 9. default value + '', // 10. param + 1, // 11. always editable + '', // 12. permission + 1, // 13. list (show in list) + 'Materialpreis ohne Kupferzuschlag (nur bei Kabeln)', // 14. help + '', // 15. computed + '', // 16. entity + 'importzugferd@importzugferd', // 17. langfile + 'isModEnabled("importzugferd")', // 18. enabled condition + 0, // 19. totalizable + 0 // 20. printable + ); + + // Add extrafield for price unit (Preiseinheit) on supplier prices + $extrafields->addExtraField( + 'preiseinheit', // 1. attribute code + 'Preiseinheit', // 2. label + 'int', // 3. type + 120, // 4. position + 11, // 5. size + 'product_fournisseur_price', // 6. element type + 0, // 7. unique + 0, // 8. required + '1', // 9. default value + '', // 10. param + 1, // 11. always editable + '', // 12. permission + 1, // 13. list (show in list) + 'Preiseinheit aus Datanorm (z.B. 100 für Preis pro 100m)', // 14. help + '', // 15. computed + '', // 16. entity + 'importzugferd@importzugferd', // 17. langfile + 'isModEnabled("importzugferd")', // 18. enabled condition + 0, // 19. totalizable + 0 // 20. printable + ); + + // Add extrafield for product group (Warengruppe) on supplier prices + $extrafields->addExtraField( + 'warengruppe', // 1. attribute code + 'Warengruppe', // 2. label + 'varchar', // 3. type + 125, // 4. position + 32, // 5. size + 'product_fournisseur_price', // 6. element type + 0, // 7. unique + 0, // 8. required + '', // 9. default value + '', // 10. param + 1, // 11. always editable + '', // 12. permission + 1, // 13. list (show in list) + 'Datanorm-Warengruppe', // 14. help + '', // 15. computed + '', // 16. entity + 'importzugferd@importzugferd', // 17. langfile + 'isModEnabled("importzugferd")', // 18. enabled condition + 0, // 19. totalizable + 0 // 20. printable + ); + + // Add extrafield for purchase quantity (Kaufmenge) on supplier prices + // Signature: addExtraField($attrname, $label, $type, $pos, $size, $elementtype, $unique, $required, $default_value, $param, $alwayseditable, $perms, $list, $help, $computed, $entity, $langfile, $enabled, $totalizable, $printable) + $extrafields->addExtraField( + 'kaufmenge', // 1. attribute code + 'Kaufmenge (Datanorm-Vergleich)', // 2. label + 'int', // 3. type + 127, // 4. position + 11, // 5. size + 'product_fournisseur_price', // 6. element type + 0, // 7. unique + 0, // 8. required + '', // 9. default value + '', // 10. param (empty) + 1, // 11. always editable + '', // 12. permission + 1, // 13. list (show in forms) + 'Tatsächliche Kaufmenge für Preisvergleiche. Beispiele: Lüsterklemme mit 12 Positionen = 1, Kabel 100m = leer lassen.', // 14. help + '', // 15. computed + '', // 16. entity + 'importzugferd@importzugferd', // 17. langfile + 'isModEnabled("importzugferd")', // 18. enabled condition + 0, // 19. totalizable + 0 // 20. printable + ); + + // Add extrafield for copper content (Kupfergehalt) on product - kg per km for cables + $extrafields->addExtraField( + 'kupfergehalt', // 1. attribute code + 'Kupfergehalt', // 2. label + 'double', // 3. type (decimal number) + 130, // 4. position + '24,4', // 5. size (precision,scale) + 'product', // 6. element type + 0, // 7. unique + 0, // 8. required + '', // 9. default value + '', // 10. param + 1, // 11. always editable + '', // 12. permission + 1, // 13. list (show in list) + 'Kupfergehalt in kg/km (für Kupferzuschlag-Berechnung bei Kabeln)', // 14. help + '', // 15. computed + '', // 16. entity + 'importzugferd@importzugferd', // 17. langfile + 'isModEnabled("importzugferd")', // 18. enabled condition + 0, // 19. totalizable + 0 // 20. printable + ); + + // Permissions + $this->remove($options); + + $sql = array(); + + // Run standard init first (creates box definitions) + $result = $this->_init($sql, $options); + + // Now activate widget for product index page (area 4) + // Box definition is now created by _init(), so we can find it + $sql_box = "SELECT rowid FROM ".$this->db->prefix()."boxes_def WHERE file = 'box_new_products.php@importzugferd'"; + $resql = $this->db->query($sql_box); + if ($resql && $this->db->num_rows($resql) > 0) { + $obj = $this->db->fetch_object($resql); + $box_id = $obj->rowid; + + // Check if already activated for area 4 (product index) + $sql_check = "SELECT rowid FROM ".$this->db->prefix()."boxes WHERE box_id = ".(int)$box_id." AND position = 4 AND entity = ".(int)$conf->entity; + $resql2 = $this->db->query($sql_check); + if ($resql2 && $this->db->num_rows($resql2) == 0) { + // Not yet activated, add it + $sql_insert = "INSERT INTO ".$this->db->prefix()."boxes (box_id, position, box_order, fk_user, entity) VALUES (".(int)$box_id.", 4, 'A01', 0, ".(int)$conf->entity.")"; + $this->db->query($sql_insert); + } + } + + return $result; + } + + /** + * Function called when module is disabled. + * Remove from database constants, boxes and permissions from Dolibarr database. + * Data directories are not deleted + * + * @param string $options Options when enabling module ('', 'noboxes') + * @return int<-1,1> 1 if OK, <=0 if KO + */ + public function remove($options = '') + { + $sql = array(); + return $this->_remove($sql, $options); + } +} diff --git a/css/importzugferd.css.php b/css/importzugferd.css.php new file mode 100755 index 0000000..1e4a11b --- /dev/null +++ b/css/importzugferd.css.php @@ -0,0 +1,48 @@ + + * + * 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 css/importzugferd.css.php + * \ingroup importzugferd + * \brief CSS file for importzugferd module + */ + +// Load Dolibarr environment +if (!defined('NOREQUIRESOC')) { + define('NOREQUIRESOC', '1'); +} +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); +} +if (!defined('NOLOGIN')) { + define('NOLOGIN', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +session_cache_limiter('public'); + +require_once '../../../main.inc.php'; + +header('Content-Type: text/css'); + +?> +/* Icon for importzugferd new products dashboard box */ +.fa-dol-importzugferd_newproducts:before { + content: "\f1b3"; /* FontAwesome cubes icon for products */ +} + +/* Background color for the dashboard box */ +.bg-infobox-importzugferd_newproducts { + background: linear-gradient(135deg, #6c5ce7 0%, #a29bfe 100%) !important; +} diff --git a/datanorm.php b/datanorm.php new file mode 100755 index 0000000..4b47224 --- /dev/null +++ b/datanorm.php @@ -0,0 +1,309 @@ + + * + * 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 datanorm.php + * \ingroup importzugferd + * \brief Datanorm catalog management page + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once './class/datanorm.class.php'; +require_once './class/datanormparser.class.php'; +require_once './lib/importzugferd.lib.php'; + +$langs->loadLangs(array('importzugferd@importzugferd', 'companies', 'products')); + +// Access control +if (!$user->hasRight('importzugferd', 'datanorm', 'write')) { + accessforbidden(); +} + +// Parameters +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); +$fk_soc = GETPOSTINT('fk_soc'); +$id = GETPOSTINT('id'); + +// Objects +$form = new Form($db); +$datanorm = new Datanorm($db); + +/* + * Actions + */ + +// Upload Datanorm file +if ($action == 'upload' && !empty($_FILES['datanormfile']['name']) && $fk_soc > 0) { + $error = 0; + + // Check supplier exists and is a supplier + $supplier = new Societe($db); + if ($supplier->fetch($fk_soc) <= 0 || $supplier->fournisseur != 1) { + setEventMessages($langs->trans('ErrorSupplierNotFound'), null, 'errors'); + $error++; + } + + if (!$error) { + // Create upload directory + $upload_dir = $conf->importzugferd->dir_output.'/datanorm/'.$fk_soc; + if (!dol_is_dir($upload_dir)) { + dol_mkdir($upload_dir); + } + + // Handle file upload + $uploaded_files = array(); + + // Check if multiple files or single file + if (is_array($_FILES['datanormfile']['name'])) { + $file_count = count($_FILES['datanormfile']['name']); + for ($i = 0; $i < $file_count; $i++) { + if ($_FILES['datanormfile']['error'][$i] == UPLOAD_ERR_OK) { + $tmp_name = $_FILES['datanormfile']['tmp_name'][$i]; + $name = $_FILES['datanormfile']['name'][$i]; + $dest = $upload_dir.'/'.$name; + + if (dol_move_uploaded_file($tmp_name, $dest, 1) > 0) { + $uploaded_files[] = $dest; + } + } + } + } else { + if ($_FILES['datanormfile']['error'] == UPLOAD_ERR_OK) { + $tmp_name = $_FILES['datanormfile']['tmp_name']; + $name = $_FILES['datanormfile']['name']; + $dest = $upload_dir.'/'.$name; + + if (dol_move_uploaded_file($tmp_name, $dest, 1) > 0) { + $uploaded_files[] = $dest; + } + } + } + + if (empty($uploaded_files)) { + setEventMessages($langs->trans('ErrorUploadFailed'), null, 'errors'); + $error++; + } + } + + if (!$error && !empty($uploaded_files)) { + // Use streaming import for large files (directory-based) + $delete_existing = GETPOST('delete_existing', 'int') ? true : false; + $imported = $datanorm->importFromDirectoryStreaming($user, $fk_soc, $upload_dir, $delete_existing); + + if ($imported > 0) { + setEventMessages($langs->trans('DatanormImportSuccess', $imported), null, 'mesgs'); + } elseif ($imported == 0) { + setEventMessages($langs->trans('DatanormNoArticlesFound'), null, 'warnings'); + } else { + setEventMessages($langs->trans('DatanormImportFailed').': '.$datanorm->error, null, 'errors'); + } + } +} + +// Delete all articles for supplier +if ($action == 'delete' && $confirm == 'yes' && $fk_soc > 0) { + $result = $datanorm->deleteAllBySupplier($user, $fk_soc); + if ($result >= 0) { + setEventMessages($langs->trans('DatanormDeleted', $result), null, 'mesgs'); + } else { + setEventMessages($langs->trans('DatanormDeleteFailed').': '.$datanorm->error, null, 'errors'); + } + $action = ''; +} + +/* + * View + */ + +$title = $langs->trans('DatanormCatalogs'); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-datanorm'); + +print load_fiche_titre($title, '', 'fa-database'); + +// Confirmation dialog for delete +if ($action == 'delete' && $fk_soc > 0) { + $supplier = new Societe($db); + $supplier->fetch($fk_soc); + + $formconfirm = $form->formconfirm( + $_SERVER["PHP_SELF"].'?fk_soc='.$fk_soc, + $langs->trans('DeleteDatanorm'), + $langs->trans('ConfirmDeleteDatanorm', $supplier->name), + 'delete', + '', + 0, + 1 + ); + print $formconfirm; +} + +// Upload form +print '
'; + +print '
'; +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print '
'.$langs->trans('UploadDatanorm').'
'; + +print '
'; +print ''; +print ''; + +print ''; + +// Supplier selection +print ''; +print ''; +print ''; +print ''; + +// File upload +print ''; +print ''; +print ''; +print ''; + +// Delete existing option +print ''; +print ''; +print ''; +print ''; + +// Submit button +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans('Supplier').' *'; +print $form->select_company($fk_soc, 'fk_soc', 's.fournisseur = 1', 1, 0, 0, array(), 0, 'minwidth300'); +print '
'.$langs->trans('DatanormFiles').' *'; +print ''; +print '
'.$langs->trans('DatanormFileHelp').''; +print '
'.$langs->trans('DeleteExisting').''; +print ''; +print ' '.$langs->trans('DeleteExistingHelp').''; +print '
'; +print ''; +print '
'; +print '
'; + +print '
'; +print '
'; + +print '
'; + +// List of suppliers with Datanorm data +$suppliers = $datanorm->getSuppliersWithData(); + +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +if (!empty($suppliers)) { + foreach ($suppliers as $sup) { + print ''; + + // Supplier name with link + print ''; + + // Article count + print ''; + + // Last import + print ''; + + // Actions + print ''; + print ''; + } +} else { + print ''; + print ''; + print ''; +} + +print '
'.$langs->trans('Supplier').''.$langs->trans('ArticleCount').''.$langs->trans('LastImport').''.$langs->trans('Actions').'
'; + $supplier = new Societe($db); + $supplier->fetch($sup['fk_soc']); + print $supplier->getNomUrl(1, 'supplier'); + print ''; + print ''.$sup['article_count'].''; + print ''; + print dol_print_date($sup['last_import'], 'dayhour'); + print ''; + + // View articles button + print ''; + print img_picto($langs->trans('ViewArticles'), 'list'); + print ''; + + // Delete button + print ''; + print img_picto($langs->trans('Delete'), 'delete'); + print ''; + + print '
'.$langs->trans('NoDatanormData').'
'; +print '
'; + +print '
'; + +// Settings info +print '
'; +print '
'; +print ''; +print $langs->trans('DatanormSettingsInfo'); +print ' '.$langs->trans('Settings').''; +print '
'; + +llxFooter(); +$db->close(); diff --git a/datanorm_changelog.php b/datanorm_changelog.php new file mode 100755 index 0000000..c9a5ce4 --- /dev/null +++ b/datanorm_changelog.php @@ -0,0 +1,333 @@ + 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formcompany.class.php'; +require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; +dol_include_once('/importzugferd/lib/importzugferd.lib.php'); + +// Load translations +$langs->loadLangs(array("importzugferd@importzugferd", "products", "bills")); + +// Security check +if (!$user->hasRight('produit', 'lire')) { + accessforbidden(); +} + +// Get parameters +$action = GETPOST('action', 'aZ09'); +$batch_id = GETPOST('batch_id', 'alphanohtml'); +$fk_product = GETPOSTINT('fk_product'); +$fk_soc = GETPOSTINT('fk_soc'); +$date_start = GETPOST('date_start', 'alpha'); +$date_end = GETPOST('date_end', 'alpha'); + +// Pagination +$sortfield = GETPOST('sortfield', 'aZ09comma'); +$sortorder = GETPOST('sortorder', 'aZ09comma'); +$page = GETPOSTISSET('pageplusone') ? (GETPOSTINT('pageplusone') - 1) : GETPOSTINT('page'); +$limit = GETPOSTINT('limit') ? GETPOSTINT('limit') : $conf->liste_limit; +$offset = $limit * $page; + +if (!$sortfield) $sortfield = 'l.date_change'; +if (!$sortorder) $sortorder = 'DESC'; + +// Initialize objects +$form = new Form($db); +$formcompany = new FormCompany($db); + +/* + * View + */ + +$title = $langs->trans('DatanormChangeLog'); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-datanorm-changelog'); + +print load_fiche_titre($title, ''.$langs->trans('DatanormMassUpdate').'', 'fa-history'); + +// Filter form +print '
'; + +print '
'; +print ''; +print ''; +print ''; +print ''; + +print ''; + +// Batch ID filter +print ''; +print ''; + +// Supplier filter +print ''; +print ''; + +// Date range +print ''; +print ''; + +print ''; + +print ''; +print ''; +print ''; + +print '
'.$langs->trans('Filters').'
'.$langs->trans('BatchUpdate').''; +print ''; +print ''.$langs->trans('Supplier').''; +$sql = "SELECT DISTINCT s.rowid, s.nom FROM ".MAIN_DB_PREFIX."societe s"; +$sql .= " INNER JOIN ".MAIN_DB_PREFIX."importzugferd_datanorm_log l ON l.fk_soc = s.rowid"; +$sql .= " ORDER BY s.nom"; +$resql = $db->query($sql); +print ''; +print ''.$langs->trans('DateRange').''; +print ''; +print ' - '; +print ''; +print '
'; +print ''; +print '   '.$langs->trans('Reset').''; +print '
'; +print '
'; +print '
'; + +// Build SQL query +$sql = "SELECT l.*, p.ref as product_ref, p.label as product_label, s.nom as supplier_name, u.login as user_login"; +$sql .= " FROM ".MAIN_DB_PREFIX."importzugferd_datanorm_log l"; +$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = l.fk_product"; +$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe s ON s.rowid = l.fk_soc"; +$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user u ON u.rowid = l.fk_user"; +$sql .= " WHERE l.entity IN (".getEntity('product').")"; + +if (!empty($batch_id)) { + $sql .= " AND l.batch_id = '".$db->escape($batch_id)."'"; +} +if ($fk_soc > 0) { + $sql .= " AND l.fk_soc = ".((int)$fk_soc); +} +if ($fk_product > 0) { + $sql .= " AND l.fk_product = ".((int)$fk_product); +} +if (!empty($date_start)) { + $sql .= " AND l.date_change >= '".$db->escape($date_start)." 00:00:00'"; +} +if (!empty($date_end)) { + $sql .= " AND l.date_change <= '".$db->escape($date_end)." 23:59:59'"; +} + +// Count total +$sqlcount = preg_replace('/SELECT.*FROM/', 'SELECT COUNT(*) as total FROM', $sql); +$resqlcount = $db->query($sqlcount); +$total = 0; +if ($resqlcount) { + $objcount = $db->fetch_object($resqlcount); + $total = $objcount->total; +} + +$sql .= $db->order($sortfield, $sortorder); +$sql .= $db->plimit($limit + 1, $offset); + +$resql = $db->query($sql); + +print '
'; + +// Batch summary if filtering by batch +if (!empty($batch_id)) { + $sql_batch = "SELECT MIN(date_change) as start_date, MAX(date_change) as end_date, COUNT(DISTINCT fk_product) as product_count, COUNT(*) as change_count"; + $sql_batch .= " FROM ".MAIN_DB_PREFIX."importzugferd_datanorm_log"; + $sql_batch .= " WHERE batch_id = '".$db->escape($batch_id)."'"; + $res_batch = $db->query($sql_batch); + if ($res_batch) { + $batch_info = $db->fetch_object($res_batch); + print '
'; + print ''.$langs->trans('BatchUpdate').': '.$batch_id.'
'; + print ''.$langs->trans('DateChange').': '.dol_print_date($db->jdate($batch_info->start_date), 'dayhour').'
'; + print ''.$langs->trans('Products').': '.$batch_info->product_count.' | '; + print ''.$langs->trans('Changes').': '.$batch_info->change_count; + print '

'; + } +} + +// Navigation +print_barre_liste($langs->trans('ChangeHistory'), $page, $_SERVER['PHP_SELF'], '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc.'&date_start='.$date_start.'&date_end='.$date_end, $sortfield, $sortorder, '', $resql ? $db->num_rows($resql) : 0, $total, '', 0, '', '', $limit); + +if ($resql) { + $num = $db->num_rows($resql); + + print '
'; + print ''; + + // Header + print ''; + print_liste_field_titre($langs->trans('DateChange'), $_SERVER['PHP_SELF'], 'l.date_change', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder); + print_liste_field_titre($langs->trans('Product'), $_SERVER['PHP_SELF'], 'p.ref', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder); + print_liste_field_titre($langs->trans('Supplier'), $_SERVER['PHP_SELF'], 's.nom', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder); + print_liste_field_titre($langs->trans('DatanormArticle'), $_SERVER['PHP_SELF'], 'l.datanorm_ref', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder); + print_liste_field_titre($langs->trans('FieldChanged'), $_SERVER['PHP_SELF'], 'l.field_changed', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder); + print_liste_field_titre($langs->trans('OldValue'), $_SERVER['PHP_SELF'], '', '', '', ''); + print_liste_field_titre($langs->trans('NewValue'), $_SERVER['PHP_SELF'], '', '', '', ''); + print_liste_field_titre($langs->trans('ChangedBy'), $_SERVER['PHP_SELF'], 'u.login', '', '&batch_id='.urlencode($batch_id).'&fk_soc='.$fk_soc, '', $sortfield, $sortorder); + print ''; + + if ($num > 0) { + $i = 0; + while ($i < min($num, $limit)) { + $obj = $db->fetch_object($resql); + + print ''; + + // Date + print ''; + + // Product + print ''; + + // Supplier + print ''; + + // Datanorm ref + print ''; + + // Field changed + print ''; + + // Old value + print ''; + + // New value + print ''; + + // User + print ''; + + print ''; + + $i++; + } + } else { + print ''; + } + + print '
'.dol_print_date($db->jdate($obj->date_change), 'dayhour').''; + $product = new Product($db); + if ($product->fetch($obj->fk_product) > 0) { + print $product->getNomUrl(1); + print '
'.$obj->product_label.''; + } + print '
'.dol_escape_htmltag($obj->supplier_name).''.dol_escape_htmltag($obj->datanorm_ref).''; + $field_label = ''; + switch ($obj->field_changed) { + case 'price': + $field_label = $langs->trans('Price'); + print ''; + break; + case 'description': + $field_label = $langs->trans('Description'); + print ''; + break; + case 'label': + $field_label = $langs->trans('Label'); + print ''; + break; + default: + $field_label = $obj->field_changed; + } + print $field_label; + print ''; + if ($obj->field_changed == 'price') { + print price($obj->old_value); + } else { + print dol_escape_htmltag(dol_trunc($obj->old_value, 100)); + } + print ''; + if ($obj->field_changed == 'price') { + print price($obj->new_value); + // Show difference + $diff = $obj->new_value - $obj->old_value; + if ($obj->old_value > 0) { + $diff_percent = ($diff / $obj->old_value) * 100; + print '
'; + if ($diff > 0) { + print '+'.number_format($diff_percent, 1).'%'; + } else { + print ''.number_format($diff_percent, 1).'%'; + } + } + } else { + print dol_escape_htmltag(dol_trunc($obj->new_value, 100)); + } + print '
'.dol_escape_htmltag($obj->user_login).'
'.$langs->trans('NoChangesRecorded').'
'; + print '
'; + + $db->free($resql); +} else { + dol_print_error($db); +} + +// Export buttons +if ($total > 0) { + print '
'; +} + +llxFooter(); +$db->close(); diff --git a/datanorm_list.php b/datanorm_list.php new file mode 100755 index 0000000..37ff8a5 --- /dev/null +++ b/datanorm_list.php @@ -0,0 +1,258 @@ + + * + * 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 datanorm_list.php + * \ingroup importzugferd + * \brief List of Datanorm articles for a supplier + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once './class/datanorm.class.php'; +require_once './lib/importzugferd.lib.php'; + +$langs->loadLangs(array('importzugferd@importzugferd', 'companies', 'products')); + +// Access control +if (!$user->hasRight('importzugferd', 'datanorm', 'write')) { + accessforbidden(); +} + +// Parameters +$fk_soc = GETPOSTINT('fk_soc'); +$search_article = GETPOST('search_article', 'alpha'); +$search_text = GETPOST('search_text', 'alpha'); +$limit = GETPOSTINT('limit') ?: $conf->liste_limit; +$page = GETPOSTINT('page'); +$offset = $limit * $page; +$sortfield = GETPOST('sortfield', 'aZ09comma'); +$sortorder = GETPOST('sortorder', 'aZ09comma'); + +if (empty($sortfield)) { + $sortfield = 'article_number'; +} +if (empty($sortorder)) { + $sortorder = 'ASC'; +} + +// Check supplier +if ($fk_soc <= 0) { + header('Location: datanorm.php'); + exit; +} + +$supplier = new Societe($db); +if ($supplier->fetch($fk_soc) <= 0) { + header('Location: datanorm.php'); + exit; +} + +// Objects +$form = new Form($db); +$datanorm = new Datanorm($db); + +/* + * View + */ + +$title = $langs->trans('DatanormArticles').' - '.$supplier->name; +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-datanorm-list'); + +// Build SQL +$sql = "SELECT rowid, article_number, short_text1, short_text2, ean,"; +$sql .= " manufacturer_ref, manufacturer_name, unit_code, price, price_unit,"; +$sql .= " discount_group, product_group"; +$sql .= " FROM ".MAIN_DB_PREFIX."importzugferd_datanorm"; +$sql .= " WHERE fk_soc = ".(int)$fk_soc; +$sql .= " AND entity = ".(int)$conf->entity; +$sql .= " AND active = 1"; + +// Search filters +if (!empty($search_article)) { + $sql .= " AND (article_number LIKE '%".$db->escape($search_article)."%'"; + $sql .= " OR ean LIKE '%".$db->escape($search_article)."%'"; + $sql .= " OR manufacturer_ref LIKE '%".$db->escape($search_article)."%')"; +} +if (!empty($search_text)) { + $sql .= " AND (short_text1 LIKE '%".$db->escape($search_text)."%'"; + $sql .= " OR short_text2 LIKE '%".$db->escape($search_text)."%')"; +} + +// Count total +$sqlcount = preg_replace('/^SELECT .* FROM/', 'SELECT COUNT(*) as nb FROM', $sql); +$resqlcount = $db->query($sqlcount); +$total = 0; +if ($resqlcount) { + $objcount = $db->fetch_object($resqlcount); + $total = $objcount->nb; +} + +// Sort and limit +$sql .= $db->order($sortfield, $sortorder); +$sql .= $db->plimit($limit + 1, $offset); + +// Header with back link +$linkback = ''.$langs->trans("Back").''; +print load_fiche_titre($title, $linkback, 'fa-database'); + +// Search form +print '
'; +print ''; + +print '
'; +print ''; + +// Header row +print ''; +print_liste_field_titre('ArticleNumber', $_SERVER['PHP_SELF'], 'article_number', '', '&fk_soc='.$fk_soc, '', $sortfield, $sortorder); +print_liste_field_titre('Description', $_SERVER['PHP_SELF'], 'short_text1', '', '&fk_soc='.$fk_soc, '', $sortfield, $sortorder); +print_liste_field_titre('EAN', $_SERVER['PHP_SELF'], 'ean', '', '&fk_soc='.$fk_soc, '', $sortfield, $sortorder); +print_liste_field_titre('Manufacturer', $_SERVER['PHP_SELF'], 'manufacturer_name', '', '&fk_soc='.$fk_soc, '', $sortfield, $sortorder); +print_liste_field_titre('Price', $_SERVER['PHP_SELF'], 'price', '', '&fk_soc='.$fk_soc, 'class="right"', $sortfield, $sortorder); +print_liste_field_titre('Unit', $_SERVER['PHP_SELF'], 'unit_code', '', '&fk_soc='.$fk_soc, 'class="center"', $sortfield, $sortorder); +print ''; +print ''; + +// Search row +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +// Data rows +$resql = $db->query($sql); +if ($resql) { + $num = $db->num_rows($resql); + $i = 0; + + while ($i < min($num, $limit)) { + $obj = $db->fetch_object($resql); + + print ''; + + // Article number + print ''; + + // Description + print ''; + + // EAN + print ''; + + // Manufacturer + print ''; + + // Price + print ''; + + // Unit + print ''; + + // Actions placeholder + print ''; + + print ''; + + $i++; + } + + if ($num == 0) { + print ''; + print ''; + print ''; + } + + $db->free($resql); +} else { + dol_print_error($db); +} + +print '
'; +print ''; +print ' '.$langs->trans('Reset').''; +print '
'; + print ''.dol_escape_htmltag($obj->article_number).''; + print ''; + print dol_escape_htmltag($obj->short_text1); + if (!empty($obj->short_text2)) { + print '
'.dol_escape_htmltag($obj->short_text2).''; + } + print '
'; + if (!empty($obj->ean)) { + print ''; + print dol_escape_htmltag($obj->ean); + } + print ''; + if (!empty($obj->manufacturer_name)) { + print dol_escape_htmltag($obj->manufacturer_name); + } + if (!empty($obj->manufacturer_ref)) { + print '
'.dol_escape_htmltag($obj->manufacturer_ref).''; + } + print '
'; + $price = $obj->price; + if ($obj->price_unit > 1) { + print price($price).' / '.$obj->price_unit; + } else { + print price($price); + } + print ''; + print dol_escape_htmltag($obj->unit_code); + print ''; + print '
'.$langs->trans('NoRecordsFound').'
'; +print '
'; + +print '
'; + +// Pagination +print_barre_liste('', $page, $_SERVER['PHP_SELF'], '&fk_soc='.$fk_soc.'&search_article='.urlencode($search_article).'&search_text='.urlencode($search_text), $sortfield, $sortorder, '', $num, $total, '', 0, '', '', $limit); + +// Stats +print '
'; +print '
'; +print $langs->trans('TotalArticles').': '.$total.''; +print '
'; + +llxFooter(); +$db->close(); diff --git a/datanorm_update.php b/datanorm_update.php new file mode 100755 index 0000000..49d965c --- /dev/null +++ b/datanorm_update.php @@ -0,0 +1,1799 @@ + 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formcompany.class.php'; +require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; +require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +dol_include_once('/importzugferd/class/datanorm.class.php'); +dol_include_once('/importzugferd/lib/importzugferd.lib.php'); + +// Load translations +$langs->loadLangs(array("importzugferd@importzugferd", "products", "bills")); + +// Security check +if (!$user->hasRight('produit', 'creer')) { + accessforbidden(); +} + +// Get parameters +$action = GETPOST('action', 'aZ09'); +$fk_soc = GETPOSTINT('fk_soc'); +$search_mode = GETPOST('search_mode', 'alpha') ?: 'supplier'; // supplier, manual +$search_term = GETPOST('search_term', 'alphanohtml'); +$search_by_name = GETPOSTINT('search_by_name'); +$search_by_ean = GETPOSTINT('search_by_ean'); +$search_by_ref = GETPOSTINT('search_by_ref'); + +// Filters for what to update +// On first load (no action), default to price and description enabled +// On form submit, respect actual checkbox states +$isFormSubmitted = ($action == 'search' || GETPOSTISSET('fk_soc')); +if ($isFormSubmitted) { + $filter_price = GETPOSTINT('filter_price'); + $filter_description = GETPOSTINT('filter_description'); + $filter_label = GETPOSTINT('filter_label'); + $only_differences = GETPOSTINT('only_differences'); + $hide_cables = GETPOSTINT('hide_cables'); + $filter_price_up = GETPOSTINT('filter_price_up'); + $filter_price_down = GETPOSTINT('filter_price_down'); +} else { + // Defaults for first page load + $filter_price = 1; + $filter_description = 1; + $filter_label = 0; + $only_differences = 0; + $hide_cables = 0; + $filter_price_up = 0; + $filter_price_down = 0; +} + +// Initialize objects +$form = new Form($db); +$formcompany = new FormCompany($db); +$datanorm = new Datanorm($db); + +// Store pending changes in session +if (!isset($_SESSION['datanorm_pending_changes'])) { + $_SESSION['datanorm_pending_changes'] = array(); +} + +/* + * Actions + */ + +// Apply single row update +if ($action == 'apply_single' && GETPOSTINT('product_id') && GETPOST('datanorm_key', 'alphanohtml')) { + $product_id = GETPOSTINT('product_id'); + $datanorm_key = GETPOST('datanorm_key', 'alphanohtml'); + $apply_price = GETPOSTINT('apply_price'); + $apply_description = GETPOSTINT('apply_description'); + $apply_label = GETPOSTINT('apply_label'); + + $result = applyDatanormUpdate($db, $user, $product_id, $datanorm_key, $fk_soc, $apply_price, $apply_description, $apply_label); + + if ($result > 0) { + setEventMessages($langs->trans('ProductUpdated'), null, 'mesgs'); + } else { + setEventMessages($langs->trans('ErrorUpdatingProduct'), null, 'errors'); + } + + // Redirect to same page with same parameters (preserve all filters) + header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences.'&hide_cables='.$hide_cables.'&filter_price_up='.$filter_price_up.'&filter_price_down='.$filter_price_down.'&action=search'); + exit; +} + +// Add to pending changes +if ($action == 'add_pending') { + $product_id = GETPOSTINT('product_id'); + $datanorm_key = GETPOST('datanorm_key', 'alphanohtml'); + $apply_fields = GETPOST('apply_fields', 'array'); + + if ($product_id > 0 && !empty($datanorm_key)) { + $_SESSION['datanorm_pending_changes'][$product_id] = array( + 'datanorm_key' => $datanorm_key, + 'fk_soc' => $fk_soc, + 'apply_fields' => $apply_fields + ); + setEventMessages($langs->trans('AddedToPendingChanges'), null, 'mesgs'); + } + + // Redirect back with same parameters to preserve supplier selection and filters + header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences.'&hide_cables='.$hide_cables.'&filter_price_up='.$filter_price_up.'&filter_price_down='.$filter_price_down.'&action=search'); + exit; +} + +// Remove from pending +if ($action == 'remove_pending') { + $product_id = GETPOSTINT('product_id'); + unset($_SESSION['datanorm_pending_changes'][$product_id]); + + // Redirect back with same parameters + header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences.'&action=search'); + exit; +} + +// Clear all pending +if ($action == 'clear_pending') { + $_SESSION['datanorm_pending_changes'] = array(); + setEventMessages($langs->trans('PendingChangesCleared'), null, 'mesgs'); + + // Redirect back + header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc); + exit; +} + +// Add all items with differences to pending +if ($action == 'add_all_pending') { + $items_json = GETPOST('items_data', 'restricthtml'); + if (!empty($items_json)) { + $items = json_decode($items_json, true); + $added_count = 0; + + // Build apply_fields based on user's filter selection + $apply_fields = array(); + if ($filter_price) $apply_fields[] = 'price'; + if ($filter_description) $apply_fields[] = 'description'; + if ($filter_label) $apply_fields[] = 'label'; + + if (is_array($items) && !empty($apply_fields)) { + foreach ($items as $item) { + if (!empty($item['product_id']) && $item['product_id'] > 0 && !empty($item['datanorm_key'])) { + $_SESSION['datanorm_pending_changes'][$item['product_id']] = array( + 'datanorm_key' => $item['datanorm_key'], + 'fk_soc' => $fk_soc, + 'apply_fields' => $apply_fields + ); + $added_count++; + } + } + } + if ($added_count > 0) { + setEventMessages($langs->trans('AddedAllToPendingChanges', $added_count), null, 'mesgs'); + } + } + + // Redirect back with same parameters to preserve supplier selection and filters + header('Location: '.$_SERVER['PHP_SELF'].'?fk_soc='.$fk_soc.'&search_mode='.$search_mode.'&search_term='.urlencode($search_term).'&filter_price='.$filter_price.'&filter_description='.$filter_description.'&filter_label='.$filter_label.'&only_differences='.$only_differences.'&hide_cables='.$hide_cables.'&filter_price_up='.$filter_price_up.'&filter_price_down='.$filter_price_down.'&action=search'); + exit; +} + +// Show confirmation dialog +if ($action == 'confirm_apply_all') { + // Will be handled in view section +} + +// AJAX: Get raw Datanorm lines for debugging +if ($action == 'get_raw_lines' && GETPOST('article_number', 'alphanohtml')) { + header('Content-Type: application/json'); + $article_number = GETPOST('article_number', 'alphanohtml'); + $ajax_fk_soc = GETPOSTINT('fk_soc'); + + $result = array( + 'datanorm_line' => '', + 'datpreis_line' => '', + 'article_number' => $article_number + ); + + // Get the upload directory for this supplier + $upload_dir = $conf->importzugferd->dir_output.'/datanorm/'.$ajax_fk_soc; + + if (is_dir($upload_dir)) { + $allFiles = glob($upload_dir . '/*'); + + // Search in DATANORM files + foreach ($allFiles as $file) { + $basename = strtoupper(basename($file)); + if (preg_match('/^DATANORM\.\d{3}$/', $basename)) { + $handle = fopen($file, 'r'); + if ($handle) { + while (($line = fgets($handle)) !== false) { + // A-Satz starts with A; and contains the article number + if (preg_match('/^A;/', $line)) { + $parts = explode(';', $line); + if (isset($parts[2]) && trim($parts[2]) == $article_number) { + $result['datanorm_line'] = trim($line); + break; + } + } + } + fclose($handle); + } + if (!empty($result['datanorm_line'])) break; + } + } + + // Search in DATPREIS files + foreach ($allFiles as $file) { + $basename = strtoupper(basename($file)); + if (preg_match('/^DATPREIS\.\d{3}$/', $basename)) { + $handle = fopen($file, 'r'); + if ($handle) { + while (($line = fgets($handle)) !== false) { + // P-Satz contains article numbers at various positions + if (preg_match('/^P;/', $line) && strpos($line, $article_number) !== false) { + $result['datpreis_line'] = trim($line); + break; + } + } + fclose($handle); + } + if (!empty($result['datpreis_line'])) break; + } + } + + $result['upload_dir'] = $upload_dir; + } else { + $result['error'] = 'Upload directory not found: ' . $upload_dir; + } + + echo json_encode($result); + exit; +} + +// Apply all pending changes +if ($action == 'apply_all_confirmed' && GETPOST('confirm', 'alpha') == 'yes') { + $success = 0; + $errors = 0; + + // Generate batch ID for this mass update + $batch_id = 'batch_'.date('Ymd_His').'_'.$user->id; + + foreach ($_SESSION['datanorm_pending_changes'] as $product_id => $change) { + $apply_price = in_array('price', $change['apply_fields']) ? 1 : 0; + $apply_description = in_array('description', $change['apply_fields']) ? 1 : 0; + $apply_label = in_array('label', $change['apply_fields']) ? 1 : 0; + + $result = applyDatanormUpdate($db, $user, $product_id, $change['datanorm_key'], $change['fk_soc'], $apply_price, $apply_description, $apply_label, $batch_id); + + if ($result > 0) { + $success++; + } else { + $errors++; + } + } + + $_SESSION['datanorm_pending_changes'] = array(); + setEventMessages($langs->trans('DatanormMassUpdateComplete', $success, $errors), null, 'mesgs'); + + // Redirect to change log with batch filter + header('Location: '.dol_buildpath('/importzugferd/datanorm_changelog.php', 1).'?batch_id='.urlencode($batch_id)); + exit; +} + +/* + * View + */ + +$title = $langs->trans('DatanormMassUpdate'); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-datanorm-update'); + +print load_fiche_titre($title, '', 'fa-sync'); + +// Check if Datanorm data exists +$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."importzugferd_datanorm"; +$resql = $db->query($sql); +$obj = $db->fetch_object($resql); +if ($obj->cnt == 0) { + print '
'.$langs->trans('NoDatanormData').'
'; + print '
'.$langs->trans('UploadDatanorm').''; + llxFooter(); + $db->close(); + exit; +} + +// Search form +print '
'; +print ''; + +print '
'; +print '
'; +print ''; + +// Supplier selection +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +// Search mode +print ''; +print ''; +print ''; +print ''; + +// Manual search term +print ''; +print ''; +print ''; +print ''; + +// Additional search options +print ''; +print ''; +print ''; +print ''; + +// Filter: What to compare/update +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +// Only show differences +print ''; +print ''; +print ''; +print ''; + +// Price direction filter +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans('SelectSupplier').'
'.$langs->trans('Supplier').''; +// Get suppliers with Datanorm data +$sql = "SELECT DISTINCT s.rowid, s.nom FROM ".MAIN_DB_PREFIX."societe s"; +$sql .= " INNER JOIN ".MAIN_DB_PREFIX."importzugferd_datanorm d ON d.fk_soc = s.rowid"; +$sql .= " WHERE s.fournisseur = 1"; +$sql .= " ORDER BY s.nom"; +$resql = $db->query($sql); +print ''; +print '
'.$langs->trans('SearchMode').''; +print ''; +print ''; +print '     '; +print ''; +print ''; +print '
'.$langs->trans('SearchTerm').''; +print ''; +print '
'.$langs->trans('AdditionalSearchOptions').''; +print ''; +print ''; +print '   '; +print ''; +print ''; +print '   '; +print ''; +print ''; +print '
'.$langs->trans('FieldsToCompare').'
'.$langs->trans('Fields').''; +print ''; +print ''; +print '   '; +print ''; +print ''; +print '   '; +print ''; +print ''; +print '
'.$langs->trans('Display').''; +print ''; +print ''; +print '     '; +print ''; +print ''; +print '
Preisfilter'; +print ''; +print ''; +print '     '; +print ''; +print ''; +print '
'; +print '
'; +print '
'; + +print '
'; +print ''; +if (!empty($_SESSION['datanorm_pending_changes'])) { + print '   '.$langs->trans('ClearPendingChanges').' ('.count($_SESSION['datanorm_pending_changes']).')'; +} +print '
'; + +print '
'; + +// Show pending changes section (always visible when there are pending changes) +if (!empty($_SESSION['datanorm_pending_changes'])) { + $pendingCount = count($_SESSION['datanorm_pending_changes']); + print '
'; + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print '
'.$langs->trans('PendingChanges').' ('.$pendingCount.')
'; + print ''; + print ''.$langs->trans('ApplyAllPendingChanges').' ('.$pendingCount.')'; + print ''; + print '
'; + print '
'; + print '
'; +} + +// JavaScript for toggling manual search and initializing state +print ''; + +// Results +if ($fk_soc > 0 && ($action == 'search' || GETPOST('search_mode'))) { + $comparison_results = array(); + + if ($search_mode == 'supplier') { + // Find all products linked to this supplier + $comparison_results = findProductsForSupplier($db, $fk_soc, $search_by_name, $search_by_ean, $search_by_ref); + } elseif ($search_mode == 'manual' && !empty($search_term)) { + // Manual search in Datanorm + $comparison_results = searchDatanormProducts($db, $fk_soc, $search_term, $search_by_name, $search_by_ean, $search_by_ref); + } + + // Count differences before filtering + $total_results = count($comparison_results); + $diff_count = 0; + foreach ($comparison_results as $item) { + if (($filter_price && !empty($item['price_differs'])) || + ($filter_description && !empty($item['description_differs'])) || + ($filter_label && !empty($item['label_differs']))) { + $diff_count++; + } + } + + // Filter results if needed + if ($only_differences) { + $comparison_results = array_filter($comparison_results, function($item) use ($filter_price, $filter_description, $filter_label) { + return ($filter_price && $item['price_differs']) || + ($filter_description && $item['description_differs']) || + ($filter_label && $item['label_differs']); + }); + } + + // Collect items with differences for "Add all" button + $items_with_diff = array(); + foreach ($comparison_results as $item) { + $has_difference = ($filter_price && $item['price_differs']) || + ($filter_description && $item['description_differs']) || + ($filter_label && $item['label_differs']); + if ($has_difference && $item['product_id'] > 0) { + $items_with_diff[] = array( + 'product_id' => $item['product_id'], + 'datanorm_key' => $item['datanorm_key'] + ); + } + } + + // Show summary + print '
'; + print '
'; + print '
'; + print ''.$langs->trans('Results').': '; + print $total_results.' '.$langs->trans('Products'); + if ($diff_count > 0) { + print ' | '.$diff_count.' '.$langs->trans('WithDifferences').''; + } + if ($only_differences) { + print ' | '.$langs->trans('OnlyShowingDifferences').''; + } + print '
'; + + // "Add all with differences" button + if (!empty($items_with_diff)) { + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print '
'; + print '
'; + + // Hidden dialog for confirmation + print ''; + } + print '
'; + + if (!empty($comparison_results)) { + print '
'; + print '
'; + print ''; + + // Header + print ''; + print ''; + print ''; + if ($filter_price) { + print ''; + print ''; + } + if ($filter_description) { + print ''; + print ''; + } + if ($filter_label) { + print ''; + print ''; + } + print ''; + print ''; + + foreach ($comparison_results as $item) { + // Filter cables if requested + // Datanorm groups: 01-19, 101-119, 202, 205 are cables/wires + if ($hide_cables && !empty($item['datanorm_product_group'])) { + $pg = (int)$item['datanorm_product_group']; + if (($pg >= 1 && $pg <= 19) || ($pg >= 101 && $pg <= 119) || $pg == 202 || $pg == 205) { + continue; // Skip cables + } + } + + // Calculate price difference for filtering + $price_diff = 0; + if ($item['product_id'] > 0 && $item['price_differs']) { + $datanorm_pe = isset($item['datanorm_price_unit']) ? $item['datanorm_price_unit'] : 1; + $datanorm_raw = isset($item['datanorm_price_raw']) ? $item['datanorm_price_raw'] : 0; + $current_cu = isset($item['current_kupferzuschlag']) ? $item['current_kupferzuschlag'] : 0; + $effective_qty = isset($item['current_effective_quantity']) ? $item['current_effective_quantity'] : 1; + $current_total = isset($item['current_total_price']) ? $item['current_total_price'] : 0; + + // Scale Cu to Datanorm's price_unit + $cu_per_unit = ($current_cu > 0 && $effective_qty > 0) ? $current_cu / $effective_qty : 0; + $cu_for_pe = $cu_per_unit * $datanorm_pe; + $datanorm_total = $datanorm_raw + $cu_for_pe; + + $current_unit = $effective_qty > 0 ? $current_total / $effective_qty : $current_total; + $datanorm_unit = $datanorm_total / $datanorm_pe; + $price_diff = $datanorm_unit - $current_unit; + } + + // Filter by price direction + if ($filter_price_up && !$filter_price_down && $price_diff <= 0) { + continue; // Only show price increases + } + if ($filter_price_down && !$filter_price_up && $price_diff >= 0) { + continue; // Only show price decreases + } + if ($filter_price_up && $filter_price_down && $price_diff == 0) { + continue; // Both checked: show any change, skip unchanged + } + + $has_difference = ($filter_price && $item['price_differs']) || + ($filter_description && $item['description_differs']) || + ($filter_label && $item['label_differs']); + + $rowClass = $has_difference ? 'oddeven highlighted' : 'oddeven'; + + print ''; + + // Product + print ''; + + // Datanorm article + print ''; + + // Price comparison + if ($filter_price) { + $priceStyle = $item['price_differs'] ? 'background-color: #fcf8e3;' : ''; + print ''; + print ''; + } + + // Description comparison + if ($filter_description) { + $descStyle = $item['description_differs'] ? 'background-color: #fcf8e3;' : ''; + print ''; + print ''; + } + + // Label comparison + if ($filter_label) { + $labelStyle = $item['label_differs'] ? 'background-color: #fcf8e3;' : ''; + print ''; + print ''; + } + + // Actions + print ''; + + print ''; + } + + print '
'.$langs->trans('Product').''.$langs->trans('DatanormArticle').''.$langs->trans('CurrentPrice').''.$langs->trans('DatanormPrice').''.$langs->trans('CurrentDescription').''.$langs->trans('DatanormDescription').''.$langs->trans('CurrentLabel').''.$langs->trans('DatanormLabel').''.$langs->trans('Actions').'
'; + if ($item['product_id'] > 0) { + $product = new Product($db); + $product->fetch($item['product_id']); + print $product->getNomUrl(1, '', 0, 0, 0, 1, 1); // Open in new tab + print '
'.$product->ref.''; + } else { + print ''.$langs->trans('ProductNotInDatabase').''; + } + print '
'; + print ''.dol_escape_htmltag($item['datanorm_ref']).''; + print '
'.dol_escape_htmltag(dol_trunc($item['datanorm_name'], 50)).''; + // Show price_unit (PE = Preiseinheit) + $pe = isset($item['datanorm_price_unit']) ? $item['datanorm_price_unit'] : 1; + print '
PE='.$pe.''; + print '
'; + if ($item['product_id'] > 0) { + $dolibarr_total = isset($item['current_total_price']) ? $item['current_total_price'] : $item['current_price']; + $dolibarr_qty = isset($item['current_quantity']) ? $item['current_quantity'] : 1; + $dolibarr_cu = isset($item['current_kupferzuschlag']) ? $item['current_kupferzuschlag'] : 0; + + // IMPORTANT: Dolibarr price already includes Cu! Show as info only + if ($dolibarr_cu > 0) { + print '(davon '.price($dolibarr_cu).' Cu)
'; + } + + // Total price for minimum quantity (already includes Cu!) + print ''.price($dolibarr_total); + if ($dolibarr_qty > 1) { + print '/'.$dolibarr_qty; + } + print ''; + + // Unit price as secondary info + if ($dolibarr_qty > 1) { + $dolibarr_unit = $dolibarr_total / $dolibarr_qty; + print '
('.price($dolibarr_unit).'/Stk.)'; + } + } else { + print '-'; + } + print '
'; + $datanorm_pe = isset($item['datanorm_price_unit']) ? $item['datanorm_price_unit'] : 1; + $datanorm_raw = isset($item['datanorm_price_raw']) ? $item['datanorm_price_raw'] : $item['datanorm_price']; + $current_cu = isset($item['current_kupferzuschlag']) ? $item['current_kupferzuschlag'] : 0; + $effective_qty = isset($item['current_effective_quantity']) ? $item['current_effective_quantity'] : 1; + + // Scale Cu from Dolibarr's quantity to Datanorm's price_unit + // Example: Cu 254,55€ for 50m → for 100m = 509,10€ + $cu_per_unit = ($current_cu > 0 && $effective_qty > 0) ? $current_cu / $effective_qty : 0; + $cu_for_pe = $cu_per_unit * $datanorm_pe; + + // Show breakdown if copper exists + if ($current_cu > 0) { + print ''.price($datanorm_raw).' + '.price($cu_for_pe).' Cu
'; + } + + // Total price for Datanorm price_unit (with scaled Cu) + $datanorm_total = $datanorm_raw + $cu_for_pe; + print ''.price($datanorm_total); + if ($datanorm_pe > 1) { + print '/'.$datanorm_pe; + } + print ''; + + // Unit price as secondary info + if ($datanorm_pe > 1) { + $datanorm_unit = $datanorm_total / $datanorm_pe; + print '
('.price($datanorm_unit).'/Stk.)'; + } + if ($item['price_differs'] && $item['product_id'] > 0) { + // Calculate percentage difference using UNIT PRICE basis + $current_total = isset($item['current_total_price']) ? $item['current_total_price'] : $item['current_price']; + + // Dolibarr: unit price (already includes Cu) + $current_compare = $effective_qty > 0 ? $current_total / $effective_qty : $current_total; + + // Datanorm: unit price (material + scaled Cu) + $datanorm_compare = $datanorm_total / $datanorm_pe; + + $diff = $datanorm_compare - $current_compare; + $diffPercent = ($current_compare > 0) ? ($diff / $current_compare * 100) : 0; + print '
'; + if ($diff > 0) { + print ' +'.number_format($diffPercent, 1).'%'; + } else { + print ' '.number_format($diffPercent, 1).'%'; + } + } + print '
'; + print dol_escape_htmltag(dol_trunc($item['current_description'], 80)); + print ''; + print dol_escape_htmltag(dol_trunc($item['datanorm_description'], 80)); + print ''; + print dol_escape_htmltag($item['current_label']); + print ''; + print dol_escape_htmltag($item['datanorm_label']); + print ''; + if ($item['product_id'] > 0 && $has_difference) { + // Quick apply form + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + // Checkboxes for what to apply + if ($filter_price && $item['price_differs']) { + print ''; + print 'P '; + } + if ($filter_description && $item['description_differs']) { + print ''; + print 'D '; + } + if ($filter_label && $item['label_differs']) { + print ''; + print 'L '; + } + + print ''; + print '
'; + + // Add to pending + $isPending = isset($_SESSION['datanorm_pending_changes'][$item['product_id']]); + if (!$isPending) { + print ' '; + print ''; + print ''; + } else { + print ' '.$langs->trans('Pending').''; + } + } elseif ($item['product_id'] == 0) { + // Create product link + print ''; + print ''; + print ''; + } else { + print ''.$langs->trans('NoChanges').''; + } + + // Raw data button (always show) + print ' '; + print ''; + print ''; + + print '
'; + print '
'; + + // Summary and mass apply button + $pendingCount = count($_SESSION['datanorm_pending_changes']); + if ($pendingCount > 0) { + print '
'; + print ''; + } + + } else { + print '
'.$langs->trans('NoResultsFound').'
'; + } +} + +// Confirmation dialog for mass apply +if ($action == 'confirm_apply_all' && !empty($_SESSION['datanorm_pending_changes'])) { + print '

'; + print '
'; + print '

'.$langs->trans('ConfirmMassUpdate').'

'; + print '

'.$langs->trans('FollowingProductsWillBeUpdated').':

'; + + print ''; + print ''; + print ''; + print ''; + print ''; + + foreach ($_SESSION['datanorm_pending_changes'] as $product_id => $change) { + $product = new Product($db); + $product->fetch($product_id); + + print ''; + print ''; + print ''; + print ''; + } + + print '
'.$langs->trans('Product').''.$langs->trans('Changes').'
'.$product->getNomUrl(1).' - '.$product->label.''; + $changes = array(); + if (in_array('price', $change['apply_fields'])) $changes[] = $langs->trans('Price'); + if (in_array('description', $change['apply_fields'])) $changes[] = $langs->trans('Description'); + if (in_array('label', $change['apply_fields'])) $changes[] = $langs->trans('Label'); + print implode(', ', $changes); + print '
'; + + print '
'; + print '
'; + print ''; + print ''; + print ''; + print '
'; + print ''; + print '   '; + print ''.$langs->trans('Cancel').''; + print '
'; + print '
'; + + print '
'; +} + +print ''; + +// Modal for raw data +print '
'; +print '
'; +print '×'; +print '

Rohdaten:

'; +print '
'; +print '

Laden...

'; +print '
'; +print '
'; +print '
'; + +print ''; + +llxFooter(); +$db->close(); + +/* + * Helper functions + */ + +/** + * Find products linked to a supplier and compare with Datanorm + * + * @param object $db Database handler + * @param int $fk_soc Supplier ID + * @param int $search_by_name Search by name + * @param int $search_by_ean Search by EAN + * @param int $search_by_ref Search by reference + * @return array Comparison results + */ +function findProductsForSupplier($db, $fk_soc, $search_by_name = 0, $search_by_ean = 0, $search_by_ref = 0) +{ + global $conf; + + $results = array(); + + // Get all supplier products + $sql = "SELECT DISTINCT pf.fk_product, pf.ref_fourn, pf.price as fourn_price, p.ref, p.label, p.description, p.barcode"; + $sql .= " FROM ".MAIN_DB_PREFIX."product_fournisseur_price pf"; + $sql .= " INNER JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = pf.fk_product"; + $sql .= " WHERE pf.fk_soc = ".((int)$fk_soc); + $sql .= " AND pf.entity IN (".getEntity('product').")"; + + $resql = $db->query($sql); + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + // Try to find matching Datanorm article + $datanorm = findDatanormMatch($db, $fk_soc, $obj->ref_fourn, $obj->label, $obj->barcode, $obj->ref, $search_by_name, $search_by_ean, $search_by_ref); + + if ($datanorm) { + $results[] = buildComparisonResult($obj, $datanorm); + } + } + } + + return $results; +} + +/** + * Search Datanorm products manually + * + * @param object $db Database handler + * @param int $fk_soc Supplier ID + * @param string $search_term Search term + * @param int $search_by_name Search by name + * @param int $search_by_ean Search by EAN + * @param int $search_by_ref Search by reference + * @return array Comparison results + */ +function searchDatanormProducts($db, $fk_soc, $search_term, $search_by_name = 0, $search_by_ean = 0, $search_by_ref = 0) +{ + global $conf; + + $results = array(); + + // Search in Datanorm + $sql = "SELECT d.* FROM ".MAIN_DB_PREFIX."importzugferd_datanorm d"; + $sql .= " WHERE d.fk_soc = ".((int)$fk_soc); + $sql .= " AND (d.article_number LIKE '%".$db->escape($search_term)."%'"; + $sql .= " OR d.short_text1 LIKE '%".$db->escape($search_term)."%'"; + $sql .= " OR d.short_text2 LIKE '%".$db->escape($search_term)."%'"; + if ($search_by_ean) { + $sql .= " OR d.ean LIKE '%".$db->escape($search_term)."%'"; + } + $sql .= ")"; + $sql .= " ORDER BY d.article_number"; + $sql .= " LIMIT 100"; + + $resql = $db->query($sql); + if ($resql) { + while ($datanorm = $db->fetch_object($resql)) { + // Try to find matching product in database + $product = findProductMatch($db, $fk_soc, $datanorm); + + // Calculate unit price (Datanorm price may be per price_unit pieces) + $price_unit = (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) ? $datanorm->price_unit : 1; + $datanorm_metal_surcharge = !empty($datanorm->metal_surcharge) ? (float)$datanorm->metal_surcharge : 0; + + // Get current price and copper surcharge from extrafield + $current_total_price = 0; + $current_quantity = 1; + $current_kaufmenge = 0; + $current_kupferzuschlag = 0; + $datanorm_price_unit_code = isset($datanorm->price_unit_code) ? $datanorm->price_unit_code : -1; + + if ($product) { + $priceDetails = getSupplierPriceDetails($db, $product->rowid, $fk_soc); + $current_total_price = $priceDetails['price']; + $current_quantity = $priceDetails['quantity']; + $current_kaufmenge = $priceDetails['kaufmenge']; + $current_kupferzuschlag = $priceDetails['kupferzuschlag']; + } + + // Use kaufmenge if set, otherwise fall back to quantity + $effective_quantity = ($current_kaufmenge > 0) ? $current_kaufmenge : $current_quantity; + + // Priority for surcharge: 1) Invoice extrafield, 2) Datanorm + if ($current_kupferzuschlag > 0) { + $effective_surcharge = $current_kupferzuschlag; + $surcharge_source = 'invoice'; + } else { + $effective_surcharge = $datanorm_metal_surcharge; + $surcharge_source = 'datanorm'; + } + + // Calculate prices for comparison - UNIT PRICE basis + // IMPORTANT: Dolibarr price already INCLUDES kupferzuschlag! Don't add it again! + // Datanorm price is WITHOUT kupferzuschlag, so add SCALED Cu for comparison + // + // Example: Kabel NYM-J 5x10 + // - Dolibarr: 331,27€ for 50m (includes 254,55€ Cu for 50m) → 6,63€/m + // - Datanorm: 168,50€ for 100m (PE=100) + // - Cu per unit: 254,55€ / 50m = 5,09€/m → for 100m = 509,10€ + // - Datanorm total: 168,50€ + 509,10€ = 677,60€ → 6,78€/m + + // Calculate Cu per unit (from Dolibarr's quantity basis) + $cu_per_unit = ($current_kupferzuschlag > 0 && $effective_quantity > 0) + ? $current_kupferzuschlag / $effective_quantity + : 0; + + // Scale Cu to Datanorm's price_unit basis + $cu_for_price_unit = $cu_per_unit * $price_unit; + + // Dolibarr: unit price (already includes Cu) + $current_compare_price = $effective_quantity > 0 ? $current_total_price / $effective_quantity : $current_total_price; + + // Datanorm: material price + scaled Cu, then to unit price + $datanorm_compare_price = ($datanorm->price + $cu_for_price_unit) / $price_unit; + + // For display: always show unit prices + $datanorm_material_unit_price = $datanorm->price / $price_unit; + $total_price_with_surcharge = $datanorm->price + $cu_for_price_unit; + $datanorm_total_unit_price = $total_price_with_surcharge / $price_unit; + $current_unit_price = $effective_quantity > 0 ? $current_total_price / $effective_quantity : $current_total_price; + + $results[] = array( + 'product_id' => $product ? $product->rowid : 0, + 'current_price' => $current_unit_price, + 'current_total_price' => $current_total_price, + 'current_quantity' => $current_quantity, + 'current_kaufmenge' => $current_kaufmenge, + 'current_effective_quantity' => $effective_quantity, + 'current_kupferzuschlag' => $current_kupferzuschlag, + 'current_description' => $product ? $product->description : '', + 'current_label' => $product ? $product->label : '', + 'datanorm_key' => $datanorm->article_number, + 'datanorm_ref' => $datanorm->article_number, + 'datanorm_name' => $datanorm->short_text1, + 'datanorm_price' => $datanorm_material_unit_price, + 'datanorm_price_with_surcharge' => $datanorm_total_unit_price, + 'datanorm_price_raw' => $datanorm->price, + 'datanorm_material_price' => $datanorm->price, + 'datanorm_metal_surcharge' => $datanorm_metal_surcharge, + 'datanorm_price_unit_code' => $datanorm_price_unit_code, + 'effective_surcharge' => $effective_surcharge, + 'surcharge_source' => $surcharge_source, + 'datanorm_price_unit' => $price_unit, + 'datanorm_product_group' => isset($datanorm->product_group) ? $datanorm->product_group : '', + 'datanorm_description' => trim($datanorm->short_text1.' '.$datanorm->short_text2), + 'datanorm_label' => $datanorm->short_text1, + 'price_differs' => $product && abs($current_compare_price - $datanorm_compare_price) > 0.01, + 'description_differs' => $product && $product->description != trim($datanorm->short_text1.' '.$datanorm->short_text2), + 'label_differs' => $product && $product->label != $datanorm->short_text1, + ); + } + } + + return $results; +} + +/** + * Find Datanorm match for a product + */ +function findDatanormMatch($db, $fk_soc, $ref_fourn, $label, $barcode, $ref, $search_by_name, $search_by_ean, $search_by_ref) +{ + // First try by supplier reference (article number) + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."importzugferd_datanorm"; + $sql .= " WHERE fk_soc = ".((int)$fk_soc); + $sql .= " AND article_number = '".$db->escape($ref_fourn)."'"; + $sql .= " LIMIT 1"; + + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + return $db->fetch_object($resql); + } + + // Try by EAN if enabled + if ($search_by_ean && !empty($barcode)) { + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."importzugferd_datanorm"; + $sql .= " WHERE fk_soc = ".((int)$fk_soc); + $sql .= " AND ean = '".$db->escape($barcode)."'"; + $sql .= " LIMIT 1"; + + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + return $db->fetch_object($resql); + } + } + + // Try by product ref if enabled + if ($search_by_ref && !empty($ref)) { + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."importzugferd_datanorm"; + $sql .= " WHERE fk_soc = ".((int)$fk_soc); + $sql .= " AND article_number = '".$db->escape($ref)."'"; + $sql .= " LIMIT 1"; + + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + return $db->fetch_object($resql); + } + } + + // Try by product name/label if enabled + if ($search_by_name && !empty($label)) { + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."importzugferd_datanorm"; + $sql .= " WHERE fk_soc = ".((int)$fk_soc); + $sql .= " AND (short_text1 LIKE '%".$db->escape($label)."%'"; + $sql .= " OR short_text2 LIKE '%".$db->escape($label)."%')"; + $sql .= " LIMIT 1"; + + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + return $db->fetch_object($resql); + } + } + + return null; +} + +/** + * Find product match for Datanorm article + */ +function findProductMatch($db, $fk_soc, $datanorm) +{ + // Try by supplier reference + $sql = "SELECT p.* FROM ".MAIN_DB_PREFIX."product p"; + $sql .= " INNER JOIN ".MAIN_DB_PREFIX."product_fournisseur_price pf ON pf.fk_product = p.rowid"; + $sql .= " WHERE pf.fk_soc = ".((int)$fk_soc); + $sql .= " AND pf.ref_fourn = '".$db->escape($datanorm->article_number)."'"; + $sql .= " LIMIT 1"; + + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + return $db->fetch_object($resql); + } + + // Try by EAN + if (!empty($datanorm->ean)) { + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."product"; + $sql .= " WHERE barcode = '".$db->escape($datanorm->ean)."'"; + $sql .= " LIMIT 1"; + + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + return $db->fetch_object($resql); + } + } + + return null; +} + +/** + * Get supplier price for a product + */ +function getSupplierPrice($db, $product_id, $fk_soc) +{ + // Use unitprice (price per 1 piece) for comparison, not price (which may be for a quantity) + $sql = "SELECT unitprice, price, quantity FROM ".MAIN_DB_PREFIX."product_fournisseur_price"; + $sql .= " WHERE fk_product = ".((int)$product_id); + $sql .= " AND fk_soc = ".((int)$fk_soc); + $sql .= " ORDER BY rowid DESC LIMIT 1"; + + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + $obj = $db->fetch_object($resql); + // Return unitprice if available, otherwise calculate from price/quantity + if (!empty($obj->unitprice) && $obj->unitprice > 0) { + return $obj->unitprice; + } + // Fallback: calculate unit price from price and quantity + if (!empty($obj->quantity) && $obj->quantity > 0) { + return $obj->price / $obj->quantity; + } + return $obj->price; + } + return 0; +} + +/** + * Get supplier price details including extrafields (Kupferzuschlag) + */ +function getSupplierPriceDetails($db, $product_id, $fk_soc) +{ + $result = array( + 'price' => 0, + 'quantity' => 1, + 'kaufmenge' => 0, + 'unitprice' => 0, + 'kupferzuschlag' => 0, + 'preiseinheit' => 1, + 'price_id' => 0, + ); + + // Get base price - ALWAYS load price + quantity, NOT unitprice alone! + $sql = "SELECT pf.rowid, pf.price, pf.quantity"; + $sql .= " FROM ".MAIN_DB_PREFIX."product_fournisseur_price pf"; + $sql .= " WHERE pf.fk_product = ".((int)$product_id); + $sql .= " AND pf.fk_soc = ".((int)$fk_soc); + $sql .= " ORDER BY pf.rowid DESC LIMIT 1"; + + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + $obj = $db->fetch_object($resql); + $result['price_id'] = $obj->rowid; + $result['price'] = (float)$obj->price; + $result['quantity'] = max(1, (int)$obj->quantity); + + // Calculate unit price from price / quantity + $result['unitprice'] = $result['quantity'] > 0 ? $result['price'] / $result['quantity'] : $result['price']; + + // Get extrafields (Kupferzuschlag, Preiseinheit, Kaufmenge) + $sql_extra = "SELECT kupferzuschlag, preiseinheit, kaufmenge"; + $sql_extra .= " FROM ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields"; + $sql_extra .= " WHERE fk_object = ".((int)$obj->rowid); + + $res_extra = $db->query($sql_extra); + if ($res_extra && $db->num_rows($res_extra) > 0) { + $extra = $db->fetch_object($res_extra); + $result['kupferzuschlag'] = !empty($extra->kupferzuschlag) ? (float)$extra->kupferzuschlag : 0; + $result['preiseinheit'] = !empty($extra->preiseinheit) ? (int)$extra->preiseinheit : 1; + $result['kaufmenge'] = !empty($extra->kaufmenge) ? (int)$extra->kaufmenge : 0; + } + } + + return $result; +} + +/** + * Extract price unit from short text (e.g. "Ri100" = 100, "Tr.500" = 500) + * This is a fallback when price_unit field is not properly filled + * + * @param string $short_text1 Short text 1 + * @param string $short_text2 Short text 2 (optional) + * @return int Extracted price unit or 1 if not found + */ +function extractPriceUnitFromText($short_text1, $short_text2 = '') +{ + $text = $short_text1 . ' ' . $short_text2; + + // Patterns to match: + // Ri100, Ri.100, Ri 100, Ri. 100 (Rolle = Roll) + // Tr100, Tr.100, Tr 100, Tr. 100 (Trommel = Drum) + // Ring 100, Ring100 + // /100, /50 (per unit indicator) + // VPE100, VPE 100 (Verpackungseinheit) + // 100er, 50er (German quantity suffix) + + $patterns = array( + '/\bRi\.?\s*(\d+)\b/i', // Ri100, Ri.100, Ri 100 + '/\bTr\.?\s*(\d+)\b/i', // Tr100, Tr.100, Tr 500 + '/\bRing\.?\s*(\d+)\b/i', // Ring 100 + '/\bRolle\.?\s*(\d+)\b/i', // Rolle 100 + '/\bTrommel\.?\s*(\d+)\b/i', // Trommel 500 + '/\/(\d+)\s*(?:Stk?|m|M)?\b/', // /100, /100Stk, /100m + '/\bVPE\.?\s*(\d+)\b/i', // VPE100, VPE 100 + '/\b(\d+)er\b/', // 100er + '/\bPE\s*(\d+)\b/i', // PE100 + ); + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $text, $matches)) { + $unit = (int)$matches[1]; + if ($unit > 1 && $unit <= 10000) { + return $unit; + } + } + } + + return 1; +} + +/** + * Get effective price unit - uses database value if > 1, otherwise tries to extract from text + * + * @param object $datanorm Datanorm database object + * @return int Effective price unit + */ +function getEffectivePriceUnit($datanorm) +{ + // If database has a valid price_unit > 1, use it + if (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) { + return (int)$datanorm->price_unit; + } + + // Otherwise try to extract from text + $short_text2 = isset($datanorm->short_text2) ? $datanorm->short_text2 : ''; + return extractPriceUnitFromText($datanorm->short_text1, $short_text2); +} + +/** + * Build comparison result array + * + * @param object $product Product from supplier price + * @param object $datanorm Datanorm data + * @return array Comparison result + */ +function buildComparisonResult($product, $datanorm) +{ + global $db; + + $fk_soc = $datanorm->fk_soc; + // Get supplier price details including extrafields (Kupferzuschlag, Kaufmenge) + $priceDetails = getSupplierPriceDetails($db, $product->fk_product, $fk_soc); + $current_total_price = $priceDetails['price']; + $current_quantity = $priceDetails['quantity']; + $current_kaufmenge = $priceDetails['kaufmenge']; // Actual purchase quantity (if set) + $current_kupferzuschlag = $priceDetails['kupferzuschlag']; + + // Use kaufmenge if set, otherwise fall back to quantity + $effective_quantity = ($current_kaufmenge > 0) ? $current_kaufmenge : $current_quantity; + $current_unit_price = $effective_quantity > 0 ? $current_total_price / $effective_quantity : $current_total_price; + + // Calculate unit price (Datanorm price may be per price_unit pieces) + // Datanorm metal_surcharge is usually 0 for Sonepar - use extrafield from invoice instead + $price_unit = (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) ? $datanorm->price_unit : 1; + $datanorm_price_unit_code = isset($datanorm->price_unit_code) ? $datanorm->price_unit_code : -1; + $datanorm_metal_surcharge = !empty($datanorm->metal_surcharge) ? (float)$datanorm->metal_surcharge : 0; + + // Priority for surcharge: 1) Invoice extrafield, 2) Datanorm + if ($current_kupferzuschlag > 0) { + $effective_surcharge = $current_kupferzuschlag; + $surcharge_source = 'invoice'; + } else { + $effective_surcharge = $datanorm_metal_surcharge; + $surcharge_source = 'datanorm'; + } + + // Calculate prices for comparison + // IMPORTANT: Dolibarr price already INCLUDES kupferzuschlag! Don't add it again! + // Datanorm price is WITHOUT kupferzuschlag, so add it for comparison + // Compare on UNIT PRICE basis (per 1 piece/meter) + // + // Example: Kabel NYM-J 5x10 + // - Dolibarr: 331,27€ for 50m (includes 254,55€ Cu for 50m) → 6,63€/m + // - Datanorm: 168,50€ for 100m (PE=100) + Cu must be scaled to 100m + // - Cu per unit: 254,55€ / 50m = 5,09€/m → for 100m = 509,10€ + // - Datanorm total for 100m: 168,50€ + 509,10€ = 677,60€ → 6,78€/m + + // Calculate Cu per unit (from Dolibarr's quantity basis) + $cu_per_unit = ($current_kupferzuschlag > 0 && $effective_quantity > 0) + ? $current_kupferzuschlag / $effective_quantity + : 0; + + // Scale Cu to Datanorm's price_unit basis + $cu_for_price_unit = $cu_per_unit * $price_unit; + + // Dolibarr: unit price (already includes Cu) + $current_compare_price = $effective_quantity > 0 ? $current_total_price / $effective_quantity : $current_total_price; + + // Datanorm: material price + scaled Cu, then to unit price + $datanorm_compare_price = ($datanorm->price + $cu_for_price_unit) / $price_unit; + + // For display: always show unit prices + $datanorm_material_unit_price = $datanorm->price / $price_unit; + $total_price_with_surcharge = $datanorm->price + $cu_for_price_unit; + $datanorm_total_unit_price = $total_price_with_surcharge / $price_unit; + + return array( + 'product_id' => $product->fk_product, + 'current_price' => $current_unit_price, + 'current_total_price' => $current_total_price, + 'current_quantity' => $current_quantity, + 'current_kaufmenge' => $current_kaufmenge, + 'current_effective_quantity' => $effective_quantity, + 'current_kupferzuschlag' => $current_kupferzuschlag, + 'current_description' => $product->description, + 'current_label' => $product->label, + 'datanorm_key' => $datanorm->article_number, + 'datanorm_ref' => $datanorm->article_number, + 'datanorm_name' => $datanorm->short_text1, + 'datanorm_price' => $datanorm_material_unit_price, // Material price per unit + 'datanorm_price_with_surcharge' => $datanorm_total_unit_price, // Total price including surcharge + 'datanorm_price_raw' => $datanorm->price, // Raw price from DATPREIS + 'datanorm_material_price' => $datanorm->price, + 'datanorm_metal_surcharge' => $datanorm_metal_surcharge, // From Datanorm (usually 0) + 'datanorm_price_unit_code' => $datanorm_price_unit_code, + 'effective_surcharge' => $effective_surcharge, // From invoice or Datanorm + 'surcharge_source' => $surcharge_source, // Source of surcharge (invoice/datanorm) + 'datanorm_price_unit' => $price_unit, + 'datanorm_product_group' => isset($datanorm->product_group) ? $datanorm->product_group : '', + 'datanorm_description' => trim($datanorm->short_text1.' '.$datanorm->short_text2), + 'datanorm_label' => $datanorm->short_text1, + 'price_differs' => abs($current_compare_price - $datanorm_compare_price) > 0.01, + 'description_differs' => $product->description != trim($datanorm->short_text1.' '.$datanorm->short_text2), + 'label_differs' => $product->label != $datanorm->short_text1, + ); +} + +/** + * Apply Datanorm update to a product and log changes + */ +function applyDatanormUpdate($db, $user, $product_id, $datanorm_key, $fk_soc, $apply_price, $apply_description, $apply_label, $batch_id = '') +{ + global $conf; + + // Get Datanorm data + $sql = "SELECT * FROM ".MAIN_DB_PREFIX."importzugferd_datanorm"; + $sql .= " WHERE fk_soc = ".((int)$fk_soc); + $sql .= " AND article_number = '".$db->escape($datanorm_key)."'"; + + $resql = $db->query($sql); + if (!$resql || $db->num_rows($resql) == 0) { + return -1; + } + + $datanorm = $db->fetch_object($resql); + + // Calculate unit price (Datanorm price may be per price_unit pieces) + // Total price = material price + metal surcharge (for cables) + $price_unit = (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) ? $datanorm->price_unit : 1; + + // Get existing supplier price details to get kupferzuschlag and quantity from extrafield + $priceDetails = getSupplierPriceDetails($db, $product_id, $fk_soc); + $current_kupferzuschlag = $priceDetails['kupferzuschlag']; + $current_quantity = $priceDetails['quantity']; + $current_kaufmenge = $priceDetails['kaufmenge']; + $effective_quantity = ($current_kaufmenge > 0) ? $current_kaufmenge : max(1, $current_quantity); + + // Priority for surcharge: 1) Dolibarr extrafield (from invoice), 2) Datanorm metal_surcharge + $datanorm_metal_surcharge = !empty($datanorm->metal_surcharge) ? (float)$datanorm->metal_surcharge : 0; + + // Scale Cu from Dolibarr's quantity to Datanorm's price_unit + // Example: Cu 152,73€ for 50m → per meter = 3,05€ → for 100m = 305,46€ + $cu_per_unit = ($current_kupferzuschlag > 0 && $effective_quantity > 0) + ? $current_kupferzuschlag / $effective_quantity + : 0; + $cu_for_price_unit = $cu_per_unit * $price_unit; + + // Use scaled Cu, or fallback to Datanorm metal_surcharge + $effective_surcharge = ($cu_for_price_unit > 0) ? $cu_for_price_unit : $datanorm_metal_surcharge; + + // Total price for price_unit includes scaled surcharge + $total_price_for_pe = $datanorm->price + $effective_surcharge; + // Unit price (per 1 piece/meter) + $datanorm_unit_price = $total_price_for_pe / $price_unit; + + // Load product + $product = new Product($db); + $result = $product->fetch($product_id); + if ($result <= 0) { + return -2; + } + + // Store original values for logging + $old_label = $product->label; + $old_description = $product->description; + $old_price = getSupplierPrice($db, $product_id, $fk_soc); + + $updated = false; + $changes = array(); + + // Update label + if ($apply_label && $product->label != $datanorm->short_text1) { + $changes[] = array( + 'field' => 'label', + 'old' => $old_label, + 'new' => $datanorm->short_text1 + ); + $product->label = $datanorm->short_text1; + $updated = true; + } + + // Update label only (description goes to supplier price desc_fourn below) + // Save product changes + if ($updated) { + $result = $product->update($product->id, $user); + if ($result < 0) { + return -3; + } + } + + // Update supplier price and/or description + if ($apply_price || $apply_description) { + $productFourn = new ProductFournisseur($db); + $productFourn->fetch($product_id); + + // Load supplier object (required by update_buyprice - expects Societe object, not integer) + $supplier = new Societe($db); + $supplier->fetch($fk_soc); + + // Find existing supplier price + $sql = "SELECT rowid, quantity, price, unitprice, desc_fourn FROM ".MAIN_DB_PREFIX."product_fournisseur_price"; + $sql .= " WHERE fk_product = ".((int)$product_id); + $sql .= " AND fk_soc = ".((int)$fk_soc); + $sql .= " ORDER BY rowid DESC LIMIT 1"; + + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + $priceObj = $db->fetch_object($resql); + $price_rowid = $priceObj->rowid; + + // Use effective_quantity (kaufmenge if set, otherwise quantity) for price comparison + // This ensures consistent comparison with buildComparisonResult() + $effective_qty = ($priceDetails['kaufmenge'] > 0) ? $priceDetails['kaufmenge'] : max(1, $priceObj->quantity); + + // Get the actual unit price from Dolibarr (price per 1 effective piece) + $current_unit_price = $effective_qty > 0 ? $priceObj->price / $effective_qty : $priceObj->price; + + // Prepare new description if requested + $new_desc_fourn = null; + if ($apply_description) { + $new_desc_fourn = trim($datanorm->short_text1.' '.$datanorm->short_text2); + if ($priceObj->desc_fourn != $new_desc_fourn) { + $changes[] = array( + 'field' => 'desc_fourn', + 'old' => $priceObj->desc_fourn, + 'new' => $new_desc_fourn + ); + } else { + $new_desc_fourn = null; // No change needed + } + } + + // Check if price needs update + $price_changed = $apply_price && (abs($current_unit_price - $datanorm_unit_price) > 0.01); + + if ($price_changed) { + $changes[] = array( + 'field' => 'price', + 'old' => $current_unit_price, + 'new' => $datanorm_unit_price + ); + } + + // Update only the fields that need changing (preserves all other fields!) + if ($price_changed || $new_desc_fourn !== null) { + $update_fields = array(); + + if ($price_changed) { + // Calculate total price for the quantity, round to 2 decimals + $total_price_for_qty = round($datanorm_unit_price * $priceObj->quantity, 2); + $rounded_unit_price = round($datanorm_unit_price, 2); + $update_fields[] = "price = ".((float)$total_price_for_qty); + $update_fields[] = "unitprice = ".((float)$rounded_unit_price); + } + + if ($new_desc_fourn !== null) { + $update_fields[] = "desc_fourn = '".$db->escape($new_desc_fourn)."'"; + } + + if (!empty($update_fields)) { + $sql_update = "UPDATE ".MAIN_DB_PREFIX."product_fournisseur_price"; + $sql_update .= " SET ".implode(", ", $update_fields); + $sql_update .= " WHERE rowid = ".(int)$price_rowid; + + $result = $db->query($sql_update); + if (!$result) { + return -4; + } + } + } + } + } + + // Log all changes + if (!empty($changes)) { + $now = dol_now(); + $batch_id = $batch_id ?: uniqid('single_'); + + foreach ($changes as $change) { + $sql = "INSERT INTO ".MAIN_DB_PREFIX."importzugferd_datanorm_log"; + $sql .= " (fk_product, fk_soc, fk_user, datanorm_ref, field_changed, old_value, new_value, date_change, batch_id, entity)"; + $sql .= " VALUES ("; + $sql .= ((int)$product_id).", "; + $sql .= ((int)$fk_soc).", "; + $sql .= ((int)$user->id).", "; + $sql .= "'".$db->escape($datanorm_key)."', "; + $sql .= "'".$db->escape($change['field'])."', "; + $sql .= "'".$db->escape($change['old'])."', "; + $sql .= "'".$db->escape($change['new'])."', "; + $sql .= "'".$db->idate($now)."', "; + $sql .= "'".$db->escape($batch_id)."', "; + $sql .= ((int)$conf->entity); + $sql .= ")"; + + $db->query($sql); + } + } + + return 1; +} diff --git a/docs/DATANORM_FORMAT.md b/docs/DATANORM_FORMAT.md new file mode 100755 index 0000000..bd7a659 --- /dev/null +++ b/docs/DATANORM_FORMAT.md @@ -0,0 +1,215 @@ +# Datanorm Format Dokumentation + +## Allgemeines + +Datanorm ist ein Dateiformat für den Datenaustausch von Artikelstammdaten zwischen Produktlieferant, Fachgroßhandel und Handwerksbetrieb. Es wird vornehmlich im Baunebengewerbe (Sanitär, Heizung, Elektro, Maler) verwendet. + +**Wichtig:** Datanorm ist kein offener Standard. Die offizielle Spezifikation ist kostenpflichtig über den Krammer Verlag erhältlich. + +## Datanorm Versionen + +- **Datanorm 3.0**: Feste Feldbreiten (128 Zeichen pro Satz), ASCII +- **Datanorm 4.0**: Semikolon-getrennte Felder, erweiterte Funktionen +- **Datanorm 5.0**: XML-basiert + +## Dateistruktur + +Eine Datanorm-Lieferung besteht aus mehreren Dateien: + +| Datei | Inhalt | +|-------|--------| +| `DATANORM.001` - `.999` | Artikelstammdaten (A/B-Sätze) | +| `DATPREIS.001` - `.999` | Preisdaten (P-Sätze) | +| `DATANORM.WRG` | Warengruppen | +| `DATANORM.RAB` | Rabattgruppen | + +## Satzarten + +| Kennzeichen | Typ | Beschreibung | +|-------------|-----|--------------| +| A | Artikelsatz | Stammdaten des Artikels | +| B | Ergänzungssatz | Zusatzinfos, EAN, VPE, Langtext | +| P | Preissatz | Preisinformationen | +| T | Textsatz | Mehrzeilige Texte | +| G | Grafiksatz | Bildverknüpfungen | + +## A-Satz (Artikelstammdaten) - Datanorm 4.0 Semikolon-Format + +### Sonepar-Format + +``` +A;N;ArtNr;TextKz;Kurztext1;Kurztext2;PreisKz;PE;ME;Preis;RabGrp;WG;LangTextKey +``` + +| Index | Feld | Beschreibung | Beispiel | +|-------|------|--------------|----------| +| 0 | Satzart | Immer "A" | `A` | +| 1 | **Aktionscode** | **N=Neu, A=Ändern, L=Löschen** | `N` | +| 2 | Artikelnummer | Eindeutige Nummer | `0480145` | +| 3 | Textkennzeichen | Text-Typ | `00` | +| 4 | Kurztext 1 | Erste Bezeichnung (max 40 Z.) | `OBO BETT. Verschraubung` | +| 5 | Kurztext 2 | Zweite Bezeichnung (max 40 Z.) | `V-TEC PG21 LGR` | +| 6 | Preiskennzeichen | 1=Brutto, 2=Netto | `1` | +| 7 | **PE (Preiseinheit)** | **CODE** (siehe unten) | `2` | +| 8 | ME (Mengeneinheit) | Einheit | `Stck` | +| 9 | Preis | In Cent (wenn vorhanden) | `59085` | +| 10 | Rabattgruppe | Rabatt-Code | `A12N` | +| 11 | Warengruppe | Waren-Code | `303` | +| 12 | Langtextschlüssel | Verknüpfung zu Texten | ` ` | + +### Preiseinheit-Codes (PE) - WICHTIG! + +**Die Preiseinheit ist ein CODE, nicht die tatsächliche Menge!** + +| Code | Bedeutung | Divisor | +|------|-----------|---------| +| 0 (oder leer) | Preis pro 1 Stück | 1 | +| 1 | Preis pro 10 Stück | 10 | +| 2 | Preis pro 100 Stück | 100 | +| 3 | Preis pro 1000 Stück | 1000 | + +**Beispiel:** +``` +A;N;0480145;00;OBO BETT. Verschraubung;V-TEC PG21 LGR;1;2;Stck;... + ↑ + PE-Code 2 = pro 100 Stück +``` + +Wenn DATPREIS den Preis 9997 (= 99,97 €) liefert: +- Stückpreis = 99,97 € / 100 = **0,9997 €** + +### Aktionscode - Artikelstatus + +Der Aktionscode gibt an, ob ein Artikel neu ist, geändert wurde oder nicht mehr verfügbar ist: + +| Code | Bedeutung | Verhalten | +|------|-----------|-----------| +| N | Neu | Artikel wird angelegt | +| A | Ändern | Artikel wird aktualisiert | +| L | Löschen | Artikel wird als inaktiv markiert (`active=0`) | + +**Wichtig:** Bei `L`-Artikeln wird das Feld `active` auf `0` gesetzt. Diese Artikel erscheinen nicht mehr in Suchergebnissen und können beim Massenupdate als "nicht mehr verfügbar" gekennzeichnet werden. + +## B-Satz (Ergänzungssatz) - Sonepar-Format + +``` +B;N;ArtNr;Matchcode; ; ;;;;EAN; ; ;0;VPE;;; +``` + +| Index | Feld | Beschreibung | +|-------|------|--------------| +| 0 | Satzart | `B` | +| 1 | Aktion | N/L/A | +| 2 | Artikelnummer | Bezug zum A-Satz | +| 3 | Matchcode | Suchbegriff | +| 8 | EAN | 13-stellige EAN/GTIN | +| 13 | VPE | Verpackungseinheit (tatsächliche Menge) | + +**Hinweis:** Die VPE im B-Satz ist die Verpackungseinheit (z.B. 100 Stück pro Packung), während der PE-Code im A-Satz die Preisbasis definiert. Diese können unterschiedlich sein! + +## P-Satz (Preissatz) - DATPREIS-Datei + +### Format: Mehrere Artikel pro Zeile + +``` +P;A;ArtNr1;PreisKz1;Preis1;x;Zuschlag1;x;x;x;ArtNr2;PreisKz2;Preis2;x;Zuschlag2;... +``` + +| Index | Feld | Beschreibung | +|-------|------|--------------| +| 0 | P | Satzkennung | +| 1 | A | Aktionskennung | +| 2 | ArtNr | Artikelnummer | +| 3 | PreisKz | Preiskennzeichen (2=Nettopreis) | +| 4 | Preis | Materialpreis in **Cent** (für A-Satz PE-Einheit!) | +| 5 | x | Unbekannt (immer 1 bei Sonepar) | +| 6 | Zuschlag | **Metallzuschlag** in Cent (Kupfer/Aluminium) | +| 7-10 | x | Weitere Felder (Flags) | + +**Wichtig:** Der Preis in DATPREIS ist bereits für die PE-Einheit aus dem A-Satz angegeben! Keine Normalisierung nötig. + +### Metallzuschlag (für Kabel) + +Bei Kabeln und metallhaltigen Produkten gibt es oft zwei Preiskomponenten: +- **Materialpreis** (Preis): Grundpreis des Produkts +- **Metallzuschlag** (Zuschlag): Zusatzkosten für Kupfer/Aluminium + +**Gesamtpreis = Materialpreis + Metallzuschlag** + +**Beispiel Kabel NYM-J 5x1,5:** +``` +P;A;0110350;2;2920;2;7629;0;1;0;... +``` +- Materialpreis: 2920 Cent = 29,20 €/100m +- Metallzuschlag: 7629 Cent = 76,29 €/100m +- **Gesamtpreis: 105,49 €/100m = 1,05 €/m** + +**Beispiel ohne Metallzuschlag:** +``` +P;A;0480145;2;9997;1;0;1;0;1;0;0480146;2;20689;1;0;1;0;1;0; +``` +- Artikel 0480145: Preis = 9997 Cent = 99,97 € + +## Preisberechnung + +### Formel für Stückpreis + +``` +Stückpreis = Preis / PE_Divisor +``` + +Wobei PE_Divisor aus dem PE-Code berechnet wird: +- Code 0 → Divisor 1 +- Code 1 → Divisor 10 +- Code 2 → Divisor 100 +- Code 3 → Divisor 1000 + +### Beispiel + +``` +Artikel: 0480145 +DATPREIS: 9997 (Cent) = 99,97 € +A-Satz PE-Code: 2 → Divisor 100 + +Stückpreis = 99,97 € / 100 = 0,9997 € +``` + +## Datenbankfelder + +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| `price` | DOUBLE | Materialpreis aus DATPREIS (in Euro) | +| `price_unit` | INT | Konvertierter PE-Divisor (1, 10, 100, 1000) | +| `price_unit_code` | TINYINT | Originaler PE-Code (0, 1, 2, 3) | +| `price_type` | TINYINT | Preiskennzeichen (1=Brutto, 2=Netto) | +| `metal_surcharge` | DOUBLE | Metallzuschlag (Kupfer/Aluminium) in Euro | +| `vpe` | INT | VPE aus B-Satz (Verpackungseinheit) | +| `action_code` | CHAR(1) | Aktionscode (N=Neu, A=Ändern, L=Löschen) | +| `active` | TINYINT | Artikelstatus (1=aktiv, 0=gelöscht bei L) | + +### Preisberechnung mit Metallzuschlag + +``` +Gesamtpreis = price + metal_surcharge +Stückpreis = Gesamtpreis / price_unit +``` + +**Beispiel:** +``` +price = 29.20 € +metal_surcharge = 76.29 € +price_unit = 100 + +Gesamtpreis = 29.20 + 76.29 = 105.49 € +Stückpreis = 105.49 / 100 = 1.0549 € +``` + +## Quellen + +- [Datanorm Wikipedia](https://de.wikipedia.org/wiki/Datanorm) +- [DATANORM.de](https://www.datanorm.de/) +- [Comtech Hilfe](https://hilfe.comtech.at/ce/773/html/datanorm_datei.htm) + +## Hinweis + +Diese Dokumentation basiert auf der Analyse von Sonepar-Datanorm-Dateien und öffentlich verfügbaren Informationen. Für die vollständige offizielle Spezifikation wird das Datanorm-Taschenbuch vom Krammer Verlag empfohlen. diff --git a/img/README.md b/img/README.md new file mode 100755 index 0000000..b96b533 --- /dev/null +++ b/img/README.md @@ -0,0 +1,14 @@ + +Directory for module image files +-------------------------------- + +You can put here the .png files of your module: + + +If the picto of your module is an image (property $picto has been set to 'importzugferd.png@importzugferd', you can put into this +directory a .png file called *object_importzugferd.png* (16x16 or 32x32 pixels) + + +If the picto of an object is an image (property $picto of the object.class.php has been set to 'myobject.png@importzugferd', then you can put into this +directory a .png file called *object_myobject.png* (16x16 or 32x32 pixels) + diff --git a/img/object_importzugferd.svg b/img/object_importzugferd.svg new file mode 100755 index 0000000..2d82491 --- /dev/null +++ b/img/object_importzugferd.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/import.php b/import.php new file mode 100755 index 0000000..988a342 --- /dev/null +++ b/import.php @@ -0,0 +1,3423 @@ + 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; +require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + +dol_include_once('/importzugferd/class/zugferdparser.class.php'); +dol_include_once('/importzugferd/class/zugferdimport.class.php'); +dol_include_once('/importzugferd/class/importline.class.php'); +dol_include_once('/importzugferd/class/productmapping.class.php'); +dol_include_once('/importzugferd/class/actions_importzugferd.class.php'); +dol_include_once('/importzugferd/class/datanorm.class.php'); +dol_include_once('/importzugferd/class/datanormparser.class.php'); +dol_include_once('/importzugferd/class/importnotification.class.php'); +dol_include_once('/importzugferd/lib/importzugferd.lib.php'); + +// Load translation files +$langs->loadLangs(array("importzugferd@importzugferd", "bills", "products", "companies")); + +// Security check +if (!$user->hasRight('importzugferd', 'import', 'write')) { + accessforbidden(); +} + +// Get parameters +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); +$id = GETPOST('id', 'int'); // Import ID for editing existing imports +$supplier_id = GETPOST('supplier_id', 'int'); +$line_id = GETPOST('line_id', 'int'); +$product_id = GETPOST('product_id', 'int'); +$template_product_id = GETPOST('template_product_id', 'int'); +// Zeilenspezifische Produkt-IDs (wegen eindeutiger select2-IDs pro Zeile) +if (empty($product_id) && $line_id > 0) { + $product_id = GETPOST('product_id_'.$line_id, 'int'); +} +if (empty($template_product_id) && $line_id > 0) { + $template_product_id = GETPOST('template_product_id_'.$line_id, 'int'); +} + +// Initialize objects +$form = new Form($db); +$formfile = new FormFile($db); +$actions = new ActionsImportZugferd($db); +$import = new ZugferdImport($db); +$importLine = new ImportLine($db); +$notification = new ImportNotification($db); + +$error = 0; +$message = ''; + +/* + * Helper-Funktionen (DRY) + */ + +/** + * Parse Aderanzahl und Querschnitt aus Kabelbezeichnung + * Erkennt Formate wie: NYM-J 3x2,5 / NYM-J 5x1.5 / H07V-K 1x4 / J-Y(ST)Y 2x2x0,8 etc. + * + * @param string $text Kabelbezeichnung (z.B. "NYM-J 3x2,5 Eca Ri100") + * @return array|null Array mit 'aderanzahl', 'querschnitt' oder null wenn kein Kabel + */ +function parseCableSpecsFromText($text) +{ + // Spezialfall: Fernmeldekabel wie J-Y(ST)Y 2x2x0,8 (Paare x Adern pro Paar x Querschnitt) + // Pattern: Zahl x Zahl x Zahl (z.B. 2x2x0,8 = 4 Adern mit 0,8mm²) + if (preg_match('/(\d+)\s*[xX]\s*(\d+)\s*[xX]\s*(\d+(?:[,\.]\d+)?)/', $text, $matches)) { + $paare = (int) $matches[1]; + $adernProPaar = (int) $matches[2]; + $querschnitt = (float) str_replace(',', '.', $matches[3]); + $aderanzahl = $paare * $adernProPaar; + + // Plausibilitätsprüfung + if ($aderanzahl >= 1 && $aderanzahl <= 200 && $querschnitt >= 0.14 && $querschnitt <= 400) { + return array( + 'aderanzahl' => $aderanzahl, + 'querschnitt' => $querschnitt + ); + } + } + + // Standard: NYM-J 3x2,5 (Adern x Querschnitt) + // Pattern: Zahl x Zahl (mit Komma oder Punkt als Dezimaltrenner) + if (preg_match('/(\d+)\s*[xX]\s*(\d+(?:[,\.]\d+)?)/', $text, $matches)) { + $aderanzahl = (int) $matches[1]; + $querschnitt = (float) str_replace(',', '.', $matches[2]); + + // Plausibilitätsprüfung + if ($aderanzahl >= 1 && $aderanzahl <= 100 && $querschnitt >= 0.5 && $querschnitt <= 400) { + return array( + 'aderanzahl' => $aderanzahl, + 'querschnitt' => $querschnitt + ); + } + } + return null; +} + +/** + * Berechne Kupfergehalt aus Aderanzahl und Querschnitt + * Formel: Aderanzahl × Querschnitt × 8.9 (Dichte Kupfer) = kg/km + * + * @param int $aderanzahl Anzahl der Adern + * @param float $querschnitt Querschnitt in mm² + * @return float Kupfergehalt in kg/km + */ +function calculateKupfergehalt($aderanzahl, $querschnitt) +{ + // Kupferdichte: 8.9 g/cm³ = 8.9 kg/dm³ + // 1 mm² × 1 km = 1 mm² × 1000m = 1000 mm³ = 1 cm³ + // Also: 1 mm² Querschnitt × 1 km Länge = 1000 cm³ = 1 dm³ = 8.9 kg + return $aderanzahl * $querschnitt * 8.9; +} + +/** + * Hole aktuellen Kupferpreis aus Metallzuschlag-Modul + * + * @param DoliDB $db Datenbank + * @param int $supplierId Lieferanten-ID (optional, für lieferantenspezifischen Preis) + * @return float CU-Notiz in EUR/100kg oder 0 wenn nicht verfügbar + */ +function getCurrentCopperPrice($db, $supplierId = 0) +{ + // Erst prüfen ob Metallzuschlag-Modul aktiv ist + if (!isModEnabled('metallzuschlag')) { + return 0; + } + + // Lieferanten-spezifischer CU-Wert (aus societe_extrafields) + if ($supplierId > 0) { + $sql = "SELECT metallzuschlag_cu FROM ".MAIN_DB_PREFIX."societe_extrafields"; + $sql .= " WHERE fk_object = ".(int)$supplierId; + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + $obj = $db->fetch_object($resql); + if (!empty($obj->metallzuschlag_cu) && (float)$obj->metallzuschlag_cu > 0) { + return (float)$obj->metallzuschlag_cu; + } + } + } + + // Fallback: Aktuellster CU-Wert aus History + $sql = "SELECT value FROM ".MAIN_DB_PREFIX."metallzuschlag_history"; + $sql .= " WHERE metal = 'CU' ORDER BY date_notiz DESC LIMIT 1"; + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + $obj = $db->fetch_object($resql); + return (float)$obj->value; + } + + return 0; +} + +/** + * Berechne Kupferzuschlag für eine bestimmte Menge + * Formel: Kupfergehalt (kg/km) × CU (EUR/100kg) / 100000 × Menge + * + * @param float $kupfergehalt Kupfergehalt in kg/km + * @param float $cuPrice CU-Notiz in EUR/100kg + * @param float $quantity Menge (z.B. 100 für 100m) + * @return float Kupferzuschlag in EUR + */ +function calculateKupferzuschlag($kupfergehalt, $cuPrice, $quantity = 1) +{ + if ($kupfergehalt <= 0 || $cuPrice <= 0) { + return 0; + } + // kg/km × EUR/100kg / 100000 × m = EUR + return round($kupfergehalt * $cuPrice / 100000 * $quantity, 2); +} + +/** + * Prüft ob ein Produkt ein Kabel ist (basierend auf Warengruppe oder Bezeichnung) + * + * @param Datanorm $datanorm Datanorm-Objekt + * @return bool True wenn Kabel + */ +function isCableProduct($datanorm) +{ + // Warengruppen die typisch für Kabel sind + $cableGroups = array('KAB', 'KABEL', 'LEI', 'LEIT', 'LEITUNG'); + + if (!empty($datanorm->product_group)) { + $group = strtoupper(substr($datanorm->product_group, 0, 5)); + foreach ($cableGroups as $cg) { + if (strpos($group, $cg) !== false) { + return true; + } + } + } + + // Typische Kabelbezeichnungen + $cablePatterns = array( + '/NYM[-\s]?[JYOA]/i', + '/NYY[-\s]?[JO]/i', + '/H0[357]V[-\s]?[KUR]/i', + '/H0[357]RN[-\s]?F/i', + '/NHXH/i', + '/J[-\s]?Y\(ST\)Y/i', + '/LiYCY/i', + '/ÖLFLEX/i', + ); + + $text = $datanorm->short_text1 . ' ' . $datanorm->short_text2; + foreach ($cablePatterns as $pattern) { + if (preg_match($pattern, $text)) { + return true; + } + } + + return false; +} + +/** + * Ringgröße aus Kabel-Bezeichnung extrahieren + * Erkennt Muster wie: Ri100, Ri.50, Ri 100, Ring100, Tr500, Fol.25m, "Ring 100m", "Trommel 500m" + * + * WICHTIG: Nur verwenden wenn price_unit = 1! + * Bei price_unit > 1 ist das bereits die korrekte Preiseinheit (z.B. 100 für 100m) + * + * @param string $text Produktbezeichnung + * @return int Ringgröße in Metern (0 wenn nicht gefunden) + */ +function extractCableRingSize($text) +{ + // Muster für Ringgröße: Ri100, Ri.50, Ri 100, Ring100, Ring 50 + if (preg_match('/Ri(?:ng)?[.\s]?(\d+)/i', $text, $matches)) { + return (int)$matches[1]; + } + // Muster für "Ring 100m", "Ring 50 m" + if (preg_match('/Ring\s+(\d+)\s*m/i', $text, $matches)) { + return (int)$matches[1]; + } + // Muster für Trommel: Tr500, Tr.500, Trommel500, "Trommel 500m" + if (preg_match('/Tr(?:ommel)?[.\s]?(\d+)/i', $text, $matches)) { + return (int)$matches[1]; + } + if (preg_match('/Trommel\s+(\d+)\s*m/i', $text, $matches)) { + return (int)$matches[1]; + } + // Muster für Folie/Rolle: Fol.25m, Fol25, Rol.50m + if (preg_match('/(?:Fol|Rol)[.\s]?(\d+)/i', $text, $matches)) { + return (int)$matches[1]; + } + return 0; +} + +/** + * Berechne Kabelpreis unter Berücksichtigung unterschiedlicher Lieferanten-Formate + * + * Logik: + * - Kluxen/Witte/eltric: price_unit > 1 (z.B. 100) → Preis ist für 100m + * - Sonepar: price_unit = 1 → Preis ist für kompletten Ring (Größe aus Name) + * + * @param Datanorm $datanorm Datanorm-Objekt + * @param float $minQty Mindestbestellmenge (default 1) + * @return array Array mit 'unitPrice', 'totalPrice', 'priceUnit' + */ +function calculateCablePricing($datanorm, $minQty = 1) +{ + $priceUnit = $datanorm->price_unit > 0 ? $datanorm->price_unit : 1; + $cableText = $datanorm->short_text1 . ' ' . $datanorm->short_text2; + + if ($priceUnit > 1) { + // Kluxen/Witte-Format: price_unit gibt die Preiseinheit an (z.B. 100m) + $unitPrice = $datanorm->price / $priceUnit; + $effectivePriceUnit = $priceUnit; + } else { + // Sonepar-Format: price_unit = 1, aber Preis ist für kompletten Ring + $ringSize = extractCableRingSize($cableText); + if ($ringSize > 0) { + $unitPrice = $datanorm->price / $ringSize; + $effectivePriceUnit = $ringSize; + } else { + // Einzelstück + $unitPrice = $datanorm->price; + $effectivePriceUnit = 1; + } + } + + // Schutz gegen Division durch Null + $effectivePriceUnit = max(1, $effectivePriceUnit); + + return array( + 'unitPrice' => $unitPrice, + 'totalPrice' => $unitPrice * $minQty, + 'priceUnit' => $effectivePriceUnit + ); +} + +/** + * Extrafields fuer Lieferantenpreis aus Datanorm-Daten zusammenstellen + * + * @param Datanorm $datanorm Datanorm-Objekt + * @param ImportLine|null $lineObj Import-Zeile (optional, fuer ZUGFeRD-Daten) + * @return array Extrafields-Array + */ +function datanormBuildSupplierPriceExtrafields($datanorm, $lineObj = null) +{ + $extrafields = array(); + + // Produktpreis (reiner Materialpreis ohne Kupferzuschlag) - nur bei Kabeln mit Metallzuschlag + // Der Preis ist bereits auf Mindestmenge (price_unit) bezogen + if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0 && !empty($datanorm->price)) { + $extrafields['options_produktpreis'] = $datanorm->price; + } + + // Preiseinheit + if (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) { + $extrafields['options_preiseinheit'] = $datanorm->price_unit; + } elseif ($lineObj && !empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) { + $extrafields['options_preiseinheit'] = $lineObj->basis_quantity; + } + // Warengruppe + if (!empty($datanorm->product_group)) { + $extrafields['options_warengruppe'] = $datanorm->product_group; + } + return $extrafields; +} + +/** + * Lieferantenpreis aus Datanorm hinzufuegen + * + * @param DoliDB $db Datenbank + * @param int $productId Produkt-ID + * @param Datanorm $datanorm Datanorm-Objekt + * @param Societe $supplier Lieferant-Objekt + * @param User $user Benutzer + * @param float $purchasePrice Einkaufspreis + * @param float $taxPercent MwSt-Satz + * @param array $extrafields Extrafields + * @return int >0 bei Erfolg, <0 bei Fehler + */ +function datanormAddSupplierPrice($db, $productId, $datanorm, $supplier, $user, $purchasePrice, $taxPercent = 19, $extrafields = array()) +{ + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php'; + + $prodfourn = new ProductFournisseur($db); + $prodfourn->id = $productId; + + $supplierEan = !empty($datanorm->ean) ? $datanorm->ean : ''; + $supplierEanType = !empty($datanorm->ean) ? 2 : 0; + $description = trim($datanorm->short_text1 . ($datanorm->short_text2 ? ' ' . $datanorm->short_text2 : '')); + + // Mindestbestellmenge und Verpackungseinheit vom bestehenden Lieferantenpreis übernehmen + // (gleiches Produkt = gleiche Mengen, nur anderer Lieferant) + $minQty = 1; + $packaging = null; + + $sqlExisting = "SELECT quantity, packaging FROM " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlExisting .= " WHERE fk_product = " . (int)$productId; + $sqlExisting .= " AND quantity > 0"; + $sqlExisting .= " ORDER BY rowid ASC LIMIT 1"; + $resExisting = $db->query($sqlExisting); + if ($resExisting && $db->num_rows($resExisting) > 0) { + $objExisting = $db->fetch_object($resExisting); + if ($objExisting->quantity > 0) { + $minQty = $objExisting->quantity; + } + if (!empty($objExisting->packaging)) { + $packaging = $objExisting->packaging; + } + } + + // Preis berechnen mit zentraler Funktion + $pricing = calculateCablePricing($datanorm, $minQty); + $totalPrice = $pricing['totalPrice']; + + $result = $prodfourn->update_buyprice( + $minQty, $totalPrice, $user, 'HT', $supplier, 0, + $datanorm->article_number, $taxPercent, + 0, 0, 0, 0, 0, 0, array(), '', + 0, 'HT', 1, '', + $description, $supplierEan, $supplierEanType, + $extrafields + ); + + // Verpackungseinheit nachträglich setzen (nicht in update_buyprice verfügbar) + if ($result > 0 && !empty($packaging)) { + $sqlPkg = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlPkg .= " SET packaging = " . (float)$packaging; + $sqlPkg .= " WHERE rowid = " . (int)$result; + $db->query($sqlPkg); + } + + return $result; +} + +/** + * Extrafields in product_fournisseur_price_extrafields einfuegen + * + * @param DoliDB $db Datenbank + * @param int $priceId ID des Lieferantenpreises + * @param array $extrafields Extrafields-Array + */ +function datanormInsertPriceExtrafields($db, $priceId, $extrafields) +{ + if (empty($priceId) || empty($extrafields)) { + return; + } + // Note: kupferzuschlag is NOT set here - it's calculated by a separate module + // based on copper content (kupfergehalt) and current copper price + $produktpreis = !empty($extrafields['options_produktpreis']) ? (float)$extrafields['options_produktpreis'] : 'NULL'; + $preiseinheit = !empty($extrafields['options_preiseinheit']) ? (int)$extrafields['options_preiseinheit'] : 1; + $warengruppe = !empty($extrafields['options_warengruppe']) ? "'".$db->escape($extrafields['options_warengruppe'])."'" : 'NULL'; + + // Pruefen ob bereits vorhanden + $sqlCheck = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields WHERE fk_object = ".(int)$priceId; + $resCheck = $db->query($sqlCheck); + if ($resCheck && $db->num_rows($resCheck) > 0) { + // Update statt Insert wenn bereits vorhanden + $sql = "UPDATE ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields SET "; + $sql .= "produktpreis = ".($produktpreis === 'NULL' ? "NULL" : $produktpreis).", "; + $sql .= "preiseinheit = ".$preiseinheit.", "; + $sql .= "warengruppe = ".$warengruppe." "; + $sql .= "WHERE fk_object = ".(int)$priceId; + if (!$db->query($sql)) { + dol_syslog('ImportZugferd: Fehler beim Update der Extrafields: '.$db->lasterror(), LOG_ERR); + } + return; + } + + $sql = "INSERT INTO ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields"; + $sql .= " (fk_object, produktpreis, preiseinheit, warengruppe) VALUES ("; + $sql .= (int)$priceId.", "; + $sql .= ($produktpreis === 'NULL' ? "NULL" : $produktpreis).", "; + $sql .= $preiseinheit.", "; + $sql .= $warengruppe.")"; + if (!$db->query($sql)) { + dol_syslog('ImportZugferd: Fehler beim Einfuegen der Extrafields: '.$db->lasterror(), LOG_ERR); + } +} + +/* + * Actions + */ + +// AJAX: Get raw Datanorm lines for debugging +if ($action == 'get_raw_lines' && GETPOST('article_number', 'alphanohtml')) { + header('Content-Type: application/json'); + $article_number = GETPOST('article_number', 'alphanohtml'); + $ajax_fk_soc = GETPOSTINT('fk_soc'); + + $result = array( + 'datanorm_line' => '', + 'datpreis_line' => '', + 'article_number' => $article_number + ); + + // Get the upload directory for this supplier + $upload_dir = $conf->importzugferd->dir_output.'/datanorm/'.$ajax_fk_soc; + + if (is_dir($upload_dir)) { + $allFiles = glob($upload_dir . '/*'); + + // Search in DATANORM files + foreach ($allFiles as $file) { + $basename = strtoupper(basename($file)); + if (preg_match('/^DATANORM\.\d{3}$/', $basename)) { + $handle = fopen($file, 'r'); + if ($handle) { + while (($line = fgets($handle)) !== false) { + // A-Satz starts with A; and contains the article number + if (preg_match('/^A;/', $line)) { + $parts = explode(';', $line); + if (isset($parts[2]) && trim($parts[2]) == $article_number) { + $result['datanorm_line'] = trim($line); + break; + } + } + } + fclose($handle); + } + if (!empty($result['datanorm_line'])) break; + } + } + + // Search in DATPREIS files + foreach ($allFiles as $file) { + $basename = strtoupper(basename($file)); + if (preg_match('/^DATPREIS\.\d{3}$/', $basename)) { + $handle = fopen($file, 'r'); + if ($handle) { + while (($line = fgets($handle)) !== false) { + // P-Satz contains article numbers at various positions + if (preg_match('/^P;/', $line) && strpos($line, $article_number) !== false) { + $result['datpreis_line'] = trim($line); + break; + } + } + fclose($handle); + } + if (!empty($result['datpreis_line'])) break; + } + } + + $result['upload_dir'] = $upload_dir; + } else { + $result['error'] = 'Upload directory not found: ' . $upload_dir; + } + + echo json_encode($result); + exit; +} + +// Upload and parse PDF - creates import record immediately +if ($action == 'upload') { + if (!empty($_FILES['zugferd_file']['tmp_name'])) { + $upload_dir = $conf->importzugferd->dir_output.'/temp'; + if (!is_dir($upload_dir)) { + dol_mkdir($upload_dir); + } + + $filename = dol_sanitizeFileName($_FILES['zugferd_file']['name']); + $destfile = $upload_dir.'/'.$filename; + + if (move_uploaded_file($_FILES['zugferd_file']['tmp_name'], $destfile)) { + $force_reimport = GETPOST('force_reimport', 'int'); + + // Check for duplicate + $file_hash = hash_file('sha256', $destfile); + $isDuplicate = $import->isDuplicate($file_hash); + + if ($isDuplicate && !$force_reimport) { + $error++; + $message = $langs->trans('ErrorDuplicateInvoice'); + @unlink($destfile); + } else { + // If force reimport, delete the old record first + if ($isDuplicate && $force_reimport) { + $oldImport = new ZugferdImport($db); + $oldImport->fetch(0, null, $file_hash); + if ($oldImport->id > 0) { + $db->begin(); + // Alten Import-Datensatz komplett loeschen (Transaktion) + $oldLines = new ImportLine($db); + $oldLines->deleteAllByImport($oldImport->id); + $old_dir = $conf->importzugferd->dir_output.'/imports/'.$oldImport->id; + if (is_dir($old_dir)) { + dol_delete_dir_recursive($old_dir); + } + $oldImport->delete($user); + $db->commit(); + } + } + // Parse the file + $parser = new ZugferdParser($db); + $res = $parser->extractFromPdf($destfile); + + if ($res > 0) { + $res = $parser->parse(); + if ($res > 0) { + $parsed_data = $parser->getInvoiceData(); + + // Create import record immediately + $import->invoice_number = $parsed_data['invoice_number']; + $import->invoice_date = $parsed_data['invoice_date']; + $import->seller_name = $parsed_data['seller']['name']; + $import->seller_vat = $parsed_data['seller']['vat_id']; + $import->buyer_reference = $parsed_data['buyer']['reference'] ?: $parsed_data['buyer']['id']; + $import->total_ht = $parsed_data['totals']['net']; + $import->total_ttc = $parsed_data['totals']['gross']; + $import->currency = $parsed_data['totals']['currency']; + $import->xml_content = $parser->getXmlContent(); + $import->pdf_filename = $filename; + $import->file_hash = $file_hash; + + // Find supplier + $supplier_id = $actions->findSupplier($parsed_data); + $import->fk_soc = $supplier_id; + + // Process line items to find products + $processed_lines = $actions->processLineItems($parsed_data['lines'], $supplier_id); + + // Check if all lines have products + $all_have_products = true; + $has_any_product = false; + $total_lines = count($processed_lines); + foreach ($processed_lines as $line) { + if ($line['fk_product'] <= 0) { + $all_have_products = false; + } else { + $has_any_product = true; + } + } + + // Set status based on product matching + // STATUS_IMPORTED only if: supplier found, has lines, ALL lines have products + if ($all_have_products && $supplier_id > 0 && $total_lines > 0 && $has_any_product) { + $import->status = ZugferdImport::STATUS_IMPORTED; + } else { + $import->status = ZugferdImport::STATUS_PENDING; + } + + $import->date_creation = dol_now(); + $result = $import->create($user); + + if ($result > 0) { + // Store line items in database + foreach ($processed_lines as $line) { + $importLineObj = new ImportLine($db); + $importLineObj->fk_import = $import->id; + $importLineObj->line_id = $line['line_id']; + $importLineObj->supplier_ref = $line['supplier_ref']; + $importLineObj->product_name = $line['name']; + $importLineObj->description = $line['description']; + $importLineObj->quantity = $line['quantity']; + $importLineObj->unit_code = $line['unit_code']; + $importLineObj->unit_price = $line['unit_price']; + $importLineObj->unit_price_raw = $line['unit_price_raw']; + $importLineObj->basis_quantity = $line['basis_quantity']; + $importLineObj->basis_quantity_unit = $line['basis_quantity_unit']; + $importLineObj->line_total = $line['line_total']; + $importLineObj->tax_percent = $line['tax_percent']; + $importLineObj->ean = $line['ean']; + $importLineObj->fk_product = $line['fk_product']; + $importLineObj->match_method = $line['match_method']; + $importLineObj->create($user); + } + + // Move PDF to permanent storage + $final_dir = $conf->importzugferd->dir_output.'/imports/'.$import->id; + if (!is_dir($final_dir)) { + dol_mkdir($final_dir); + } + if (!@rename($destfile, $final_dir.'/'.$filename)) { + // Fallback: copy + delete (z.B. bei verschiedenen Dateisystemen) + if (@copy($destfile, $final_dir.'/'.$filename)) { + @unlink($destfile); + } else { + dol_syslog('ImportZugferd: Fehler beim Verschieben der PDF nach '.$final_dir, LOG_ERR); + } + } + + // Send notification if manual intervention required + if ($import->status == ZugferdImport::STATUS_PENDING) { + $storedLines = $importLine->fetchAllByImport($import->id); + $notification->sendManualInterventionNotification($import, $storedLines); + } + + // Check for price differences + if ($import->status == ZugferdImport::STATUS_IMPORTED) { + $storedLines = $importLine->fetchAllByImport($import->id); + $notification->checkAndNotifyPriceDifferences($import, $storedLines); + } + + // Redirect to edit page + $id = $import->id; + $action = 'edit'; + setEventMessages($langs->trans('ImportRecordCreated'), null, 'mesgs'); + } else { + $error++; + $message = $import->error; + @unlink($destfile); + // Send error notification + $notification->sendErrorNotification($import, $message, $filename); + } + } else { + $error++; + $message = $parser->error; + @unlink($destfile); + } + } else { + $error++; + $message = $parser->error; + @unlink($destfile); + } + } + } else { + $error++; + $message = $langs->trans('ErrorFileUploadFailed'); + } + } else { + $error++; + $message = $langs->trans('ErrorNoFileUploaded'); + } +} + +// Load existing import for editing +if ($id > 0 && empty($action)) { + $action = 'edit'; +} + +if ($action == 'edit' && $id > 0) { + $result = $import->fetch($id); + if ($result <= 0) { + $error++; + $message = $langs->trans('ErrorRecordNotFound'); + $action = ''; + } +} + +// Assign product to line +if ($action == 'assignproduct' && $line_id > 0 && $product_id > 0) { + $lineObj = new ImportLine($db); + $result = $lineObj->fetch($line_id); + if ($result > 0) { + $lineObj->setProduct($product_id, $langs->trans('ManualAssignment'), $user); + setEventMessages($langs->trans('ProductAssigned'), null, 'mesgs'); + + // Get import ID to reload + $id = $lineObj->fk_import; + + // Check if all lines now have products + $allHaveProducts = $importLine->allLinesHaveProducts($id); + if ($allHaveProducts) { + // Update import status + $import->fetch($id); + if ($import->status == ZugferdImport::STATUS_PENDING) { + $import->status = ZugferdImport::STATUS_IMPORTED; + $import->update($user); + + // Check for price differences now that all products are assigned + $storedLines = $importLine->fetchAllByImport($id); + $notification->checkAndNotifyPriceDifferences($import, $storedLines); + } + } + } + $action = 'edit'; + $import->fetch($id); +} + +// Remove product assignment from line +if ($action == 'removeproduct' && $line_id > 0) { + $lineObj = new ImportLine($db); + $result = $lineObj->fetch($line_id); + if ($result > 0) { + $id = $lineObj->fk_import; + $lineObj->setProduct(0, '', $user); + setEventMessages($langs->trans('ProductRemoved'), null, 'mesgs'); + + // Update import status to pending + $import->fetch($id); + if ($import->status == ZugferdImport::STATUS_IMPORTED) { + $import->status = ZugferdImport::STATUS_PENDING; + $import->update($user); + } + } + $action = 'edit'; + $import->fetch($id); +} + +// Fehlende Lieferantenpreise aus anderen Katalogen hinzufuegen +if ($action == 'addmissingprices' && $id > 0) { + $import->fetch($id); + $selectedPrices = GETPOST('add_prices', 'array'); + + if (!empty($selectedPrices)) { + $addedCount = 0; + $errorCount = 0; + $processedKeys = array(); + + foreach ($selectedPrices as $entry) { + // Duplikate ueberspringen + if (isset($processedKeys[$entry])) { + continue; + } + $processedKeys[$entry] = true; + + $parts = explode(',', $entry); + if (count($parts) !== 3) { + continue; + } + $productId = (int) $parts[0]; + $socId = (int) $parts[1]; + $datanormId = (int) $parts[2]; + + if ($productId <= 0 || $socId <= 0 || $datanormId <= 0) { + continue; + } + + $datanorm = new Datanorm($db); + if ($datanorm->fetch($datanormId) > 0) { + $altSupplier = new Societe($db); + $altSupplier->fetch($socId); + + $priceExtrafields = datanormBuildSupplierPriceExtrafields($datanorm); + $result = datanormAddSupplierPrice($db, $productId, $datanorm, $altSupplier, $user, 0, 19, $priceExtrafields); + + if ($result > 0) { + datanormInsertPriceExtrafields($db, $result, $priceExtrafields); + + $mapping = new ProductMapping($db); + $mapping->fk_soc = $socId; + $mapping->supplier_ref = $datanorm->article_number; + $mapping->fk_product = $productId; + $mapping->ean = $datanorm->ean; + $mapping->manufacturer_ref = $datanorm->manufacturer_ref; + $mapping->description = $datanorm->short_text1; + $mapping->create($user); + + $addedCount++; + } else { + $errorCount++; + dol_syslog('ImportZugferd addmissingprices: Fehler bei Lieferantenpreis product='.$productId.' supplier='.$socId, LOG_ERR); + } + } + } + + if ($addedCount > 0) { + setEventMessages($langs->trans('SupplierPricesAdded', $addedCount), null, 'mesgs'); + } + if ($errorCount > 0) { + setEventMessages($addedCount.' hinzugefuegt, '.$errorCount.' Fehler', null, 'warnings'); + } + } else { + setEventMessages('Keine Preise ausgewählt', null, 'warnings'); + } + $action = 'edit'; +} + +// Update supplier +if ($action == 'setsupplier' && $id > 0) { + $import->fetch($id); + $import->fk_soc = $supplier_id; + $import->update($user); + setEventMessages($langs->trans('SupplierUpdated'), null, 'mesgs'); + $action = 'edit'; +} + +// Duplicate product from template +if ($action == 'duplicateproduct' && $template_product_id > 0 && $line_id > 0) { + $lineObj = new ImportLine($db); + $result = $lineObj->fetch($line_id); + + if ($result > 0) { + // Load template product + $template = new Product($db); + if ($template->fetch($template_product_id) > 0) { + // Create new product as copy + $newproduct = new Product($db); + + // Copy basic properties from template + $newproduct->type = $template->type; + $newproduct->status = $template->status; + $newproduct->status_buy = $template->status_buy; + $newproduct->status_batch = $template->status_batch; + $newproduct->fk_product_type = $template->fk_product_type; + $newproduct->price = $lineObj->unit_price; + $newproduct->price_base_type = 'HT'; + $newproduct->tva_tx = $lineObj->tax_percent ?: $template->tva_tx; + $newproduct->weight = $template->weight; + $newproduct->weight_units = $template->weight_units; + $newproduct->fk_unit = $template->fk_unit; + + // Set label from ZUGFeRD + $newproduct->label = $lineObj->product_name; + + // Generate unique ref + $newproduct->ref = 'NEW-'.dol_print_date(dol_now(), '%Y%m%d%H%M%S'); + + // Build description with ZUGFeRD data + $zugferd_info = ''; + if (!empty($lineObj->supplier_ref)) { + $zugferd_info .= $langs->trans('SupplierRef').': '.$lineObj->supplier_ref."\n"; + } + if (!empty($lineObj->unit_code)) { + $zugferd_info .= $langs->trans('Unit').': '.zugferdGetUnitLabel($lineObj->unit_code)."\n"; + } + if (!empty($lineObj->ean)) { + $zugferd_info .= 'EAN: '.$lineObj->ean."\n"; + } + $zugferd_info .= "---\n"; + $newproduct->description = $zugferd_info . ($template->description ?: ''); + + // Create the product + $result = $newproduct->create($user); + if ($result > 0) { + setEventMessages($langs->trans('ProductCreated'), null, 'mesgs'); + // Redirect to product card for editing + header('Location: '.DOL_URL_ROOT.'/product/card.php?id='.$result); + exit; + } else { + setEventMessages($newproduct->error, $newproduct->errors, 'errors'); + } + } + $id = $lineObj->fk_import; + } + $action = 'edit'; + $import->fetch($id); +} + +// Create product from Datanorm +if ($action == 'createfromdatanorm' && $line_id > 0) { + $lineObj = new ImportLine($db); + $result = $lineObj->fetch($line_id); + + if ($result > 0) { + $id = $lineObj->fk_import; + $import->fetch($id); + + // Get Datanorm settings + $markup = getDolGlobalString('IMPORTZUGFERD_DATANORM_MARKUP', 30); + $searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0); + + // Search in Datanorm database + $datanorm = new Datanorm($db); + $results = $datanorm->searchByArticleNumber($lineObj->supplier_ref, $import->fk_soc, $searchAll, 1); + + if (empty($results)) { + // Try with EAN if available + if (!empty($lineObj->ean)) { + $results = $datanorm->searchByArticleNumber($lineObj->ean, $import->fk_soc, $searchAll, 1); + } + } + + if (!empty($results)) { + $datanormArticle = $results[0]; + $datanorm->fetch($datanormArticle['id']); + + // Load supplier for ref prefix + $supplier = new Societe($db); + $supplier->fetch($import->fk_soc); + $supplierPrefix = strtoupper(substr(preg_replace('/[^a-zA-Z]/', '', $supplier->name), 0, 3)); + + // Create new product + $newproduct = new Product($db); + $newproduct->type = 0; // Product + $newproduct->status = 1; // On sale + $newproduct->status_buy = 1; // On purchase + + // Generate reference + $newproduct->ref = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number; + + // Set default accounting codes from module settings + $newproduct->accountancy_code_sell = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL', ''); + $newproduct->accountancy_code_sell_intra = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL_INTRA', ''); + $newproduct->accountancy_code_sell_export = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL_EXPORT', ''); + $newproduct->accountancy_code_buy = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY', ''); + $newproduct->accountancy_code_buy_intra = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY_INTRA', ''); + $newproduct->accountancy_code_buy_export = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY_EXPORT', ''); + + // Label from Datanorm + $newproduct->label = $datanorm->short_text1; + if (!empty($datanorm->short_text2) && strlen($newproduct->label) < 100) { + $newproduct->label .= ' '.$datanorm->short_text2; + } + + // Description + $newproduct->description = $datanorm->getFullDescription(); + + // Preise und Kupferzuschlag + // Datanorm liefert den reinen Materialpreis (ohne Kupferzuschlag) + // WICHTIG: Bei Kabeln ist der Preis bereits für die Ringgröße (z.B. 49,20€ für 100m Ring) + $materialPrice = $datanorm->price; + $priceUnit = $datanorm->price_unit > 0 ? $datanorm->price_unit : 1; + + // Prüfen ob es ein Kabel ist + $isCable = isCableProduct($datanorm); + + // Preiseinheit bestimmen - unterschiedliche Logik je nach Lieferant-Datenformat: + // - Kluxen/Witte/eltric: price_unit > 1 (z.B. 100) → Preis ist für 100m + // - Sonepar: price_unit = 1, aber Preis ist für kompletten Ring → Größe aus Name extrahieren + $cableText = $datanorm->short_text1 . ' ' . $datanorm->short_text2; + if ($priceUnit == 1) { + // Sonepar-Format: Ringgröße aus Bezeichnung extrahieren + $ringSize = extractCableRingSize($cableText); + if ($ringSize > 0) { + $priceUnit = $ringSize; // z.B. 100 für Ri100 + } + } + // Bei price_unit > 1 (Kluxen/Witte) bleibt priceUnit unverändert + $cableSpecs = null; + $kupfergehalt = 0; + $kupferzuschlag = 0; + $cuPrice = 0; + + if ($isCable) { + // Parse Aderanzahl und Querschnitt aus Bezeichnung + $cableSpecs = parseCableSpecsFromText($datanorm->short_text1 . ' ' . $datanorm->short_text2); + + if ($cableSpecs) { + // Kupfergehalt berechnen + $kupfergehalt = calculateKupfergehalt($cableSpecs['aderanzahl'], $cableSpecs['querschnitt']); + + // Aktuellen Kupferpreis holen + $cuPrice = getCurrentCopperPrice($db, $import->fk_soc); + + if ($cuPrice > 0 && $kupfergehalt > 0) { + // Kupferzuschlag für die Preiseinheit berechnen (z.B. 100m) + $kupferzuschlag = calculateKupferzuschlag($kupfergehalt, $cuPrice, $priceUnit); + } + } + } + + // Einkaufspreis = Materialpreis + Kupferzuschlag (für die Preiseinheit) + $totalPurchasePrice = $materialPrice + $kupferzuschlag; + + // Stückpreis (pro 1 Einheit, z.B. pro Meter) + $purchasePricePerUnit = $totalPurchasePrice / $priceUnit; + + // Verkaufspreis mit Aufschlag + $sellingPrice = $purchasePricePerUnit * (1 + $markup / 100); + $newproduct->price = $sellingPrice; + $newproduct->price_base_type = 'HT'; + $newproduct->tva_tx = $lineObj->tax_percent ?: 19; + + // Weight if available + if (!empty($datanorm->weight)) { + $newproduct->weight = $datanorm->weight; + $newproduct->weight_units = 0; // kg + } + + // Let Dolibarr auto-generate barcode if configured + // Setting barcode to '-1' triggers automatic generation + if (isModEnabled('barcode') && getDolGlobalString('BARCODE_PRODUCT_ADDON_NUM')) { + $newproduct->barcode = '-1'; + } + + // Create the product + $result = $newproduct->create($user); + + if ($result > 0) { + // Bei Kabeln: Produkt-Extrafields für Aderanzahl, Querschnitt und Kupfergehalt setzen + if ($isCable && $cableSpecs && $kupfergehalt > 0) { + $sqlProdExtra = "INSERT INTO ".MAIN_DB_PREFIX."product_extrafields"; + $sqlProdExtra .= " (fk_object, aderanzahl, querschnitt, kupfergehalt)"; + $sqlProdExtra .= " VALUES (".(int)$newproduct->id.", "; + $sqlProdExtra .= (int)$cableSpecs['aderanzahl'].", "; + $sqlProdExtra .= (float)$cableSpecs['querschnitt'].", "; + $sqlProdExtra .= (float)$kupfergehalt.")"; + $sqlProdExtra .= " ON DUPLICATE KEY UPDATE"; + $sqlProdExtra .= " aderanzahl = ".(int)$cableSpecs['aderanzahl'].","; + $sqlProdExtra .= " querschnitt = ".(float)$cableSpecs['querschnitt'].","; + $sqlProdExtra .= " kupfergehalt = ".(float)$kupfergehalt; + if (!$db->query($sqlProdExtra)) { + dol_syslog("ImportZugferd: Fehler beim Setzen der Kabel-Extrafields: ".$db->lasterror(), LOG_WARNING); + } else { + dol_syslog("ImportZugferd: Kabel-Extrafields gesetzt - Adern: ".$cableSpecs['aderanzahl'].", Querschnitt: ".$cableSpecs['querschnitt'].", Kupfergehalt: ".$kupfergehalt." kg/km", LOG_INFO); + } + } + + // Add supplier price + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php'; + $prodfourn = new ProductFournisseur($db); + $prodfourn->id = $newproduct->id; + $prodfourn->fourn_ref = $datanorm->article_number; + + // Determine EAN for supplier price + $supplierEan = ''; + $supplierEanType = 0; + if (!empty($datanorm->ean)) { + $supplierEan = $datanorm->ean; + $supplierEanType = 2; // EAN13 + } elseif (!empty($lineObj->ean)) { + $supplierEan = $lineObj->ean; + $supplierEanType = 2; // EAN13 + } + + // Prepare extrafields for supplier price + $supplierPriceExtrafields = array(); + // Produktpreis (reiner Materialpreis ohne Kupferzuschlag) - nur bei Kabeln + if ($isCable && $materialPrice > 0) { + $supplierPriceExtrafields['options_produktpreis'] = $materialPrice; + } + // Preiseinheit + if ($priceUnit > 1) { + $supplierPriceExtrafields['options_preiseinheit'] = $priceUnit; + } elseif (!empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) { + $supplierPriceExtrafields['options_preiseinheit'] = $lineObj->basis_quantity; + } + // Warengruppe aus Datanorm + if (!empty($datanorm->product_group)) { + $supplierPriceExtrafields['options_warengruppe'] = $datanorm->product_group; + } + + // Lieferantenpreis speichern (Gesamtpreis inkl. Kupferzuschlag für die Preiseinheit) + $res = $prodfourn->update_buyprice( + $priceUnit, // Quantity (Mindestmenge, z.B. 100 für 100m) + $totalPurchasePrice, // Price (Gesamtpreis für die Mindestmenge inkl. Kupfer) + $user, + 'HT', // Price base + $supplier, // Supplier + 0, // Availability + $datanorm->article_number, // Supplier ref + $lineObj->tax_percent ?: 19, // VAT + 0, // Charges + 0, // Remise + 0, // Remise percentage + 0, // No price minimum + 0, // Delivery delay + 0, // Reputation + array(), // Localtaxes array + '', // Default VAT code + 0, // Multicurrency price + 'HT', // Multicurrency price base type + 1, // Multicurrency tx + '', // Multicurrency code + trim($datanorm->short_text1 . ($datanorm->short_text2 ? ' ' . $datanorm->short_text2 : '')), // Description from Datanorm + $supplierEan, // Barcode/EAN in supplier price + $supplierEanType, // Barcode type (EAN13) + $supplierPriceExtrafields // Extra fields + ); + + dol_syslog("ImportZugferd: Lieferantenpreis - Material: ".$materialPrice.", Kupfer: ".$kupferzuschlag.", Gesamt: ".$totalPurchasePrice." (für ".$priceUnit." Einheiten)", LOG_INFO); + + // Manually ensure extrafields record exists for supplier price + // (Dolibarr update_buyprice doesn't always create it properly) + $sqlGetPrice = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price"; + $sqlGetPrice .= " WHERE fk_product = ".(int)$newproduct->id; + $sqlGetPrice .= " AND fk_soc = ".(int)$supplier->id; + $sqlGetPrice .= " ORDER BY rowid DESC LIMIT 1"; + $resGetPrice = $db->query($sqlGetPrice); + if ($resGetPrice && $db->num_rows($resGetPrice) > 0) { + $objPrice = $db->fetch_object($resGetPrice); + $priceId = $objPrice->rowid; + + // Check if extrafields record exists + $sqlCheckExtra = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields"; + $sqlCheckExtra .= " WHERE fk_object = ".(int)$priceId; + $resCheckExtra = $db->query($sqlCheckExtra); + + // Werte für Extrafields vorbereiten + $produktpreisVal = $isCable && $materialPrice > 0 ? (float)$materialPrice : 'NULL'; + $kupferzuschlagVal = $kupferzuschlag > 0 ? (float)$kupferzuschlag : 'NULL'; + $preiseinheitVal = $priceUnit > 1 ? (int)$priceUnit : 1; + $warengruppeVal = !empty($datanorm->product_group) ? "'".$db->escape($datanorm->product_group)."'" : 'NULL'; + + if ($resCheckExtra && $db->num_rows($resCheckExtra) == 0) { + // Insert extrafields record + $sqlInsertExtra = "INSERT INTO ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields"; + $sqlInsertExtra .= " (fk_object, produktpreis, kupferzuschlag, preiseinheit, warengruppe) VALUES ("; + $sqlInsertExtra .= (int)$priceId.", "; + $sqlInsertExtra .= ($produktpreisVal === 'NULL' ? "NULL" : $produktpreisVal).", "; + $sqlInsertExtra .= ($kupferzuschlagVal === 'NULL' ? "NULL" : $kupferzuschlagVal).", "; + $sqlInsertExtra .= $preiseinheitVal.", "; + $sqlInsertExtra .= $warengruppeVal.")"; + if (!$db->query($sqlInsertExtra)) { + dol_syslog('ImportZugferd: Fehler beim Einfuegen der Extrafields: '.$db->lasterror(), LOG_ERR); + } + } else { + // Update extrafields record + $sqlUpdateExtra = "UPDATE ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields SET "; + $sqlUpdateExtra .= "produktpreis = ".($produktpreisVal === 'NULL' ? "NULL" : $produktpreisVal).", "; + $sqlUpdateExtra .= "kupferzuschlag = ".($kupferzuschlagVal === 'NULL' ? "NULL" : $kupferzuschlagVal).", "; + $sqlUpdateExtra .= "preiseinheit = ".$preiseinheitVal.", "; + $sqlUpdateExtra .= "warengruppe = ".$warengruppeVal." "; + $sqlUpdateExtra .= "WHERE fk_object = ".(int)$priceId; + if (!$db->query($sqlUpdateExtra)) { + dol_syslog('ImportZugferd: Fehler beim Update der Extrafields: '.$db->lasterror(), LOG_ERR); + } + } + } + + // Create product mapping for future imports + $mapping = new ProductMapping($db); + $mapping->fk_soc = $import->fk_soc; + $mapping->supplier_ref = $datanorm->article_number; + $mapping->fk_product = $newproduct->id; + $mapping->ean = $datanorm->ean; + $mapping->manufacturer_ref = $datanorm->manufacturer_ref; + $mapping->description = $datanorm->short_text1; + $mapping->create($user); + + // Assign to import line + $lineObj->setProduct($newproduct->id, 'datanorm', $user); + + setEventMessages($langs->trans('ProductCreatedFromDatanorm', $newproduct->ref), null, 'mesgs'); + + // Check if all lines now have products + $allHaveProducts = $importLine->allLinesHaveProducts($id); + if ($allHaveProducts) { + $import->status = ZugferdImport::STATUS_IMPORTED; + $import->update($user); + } + } else { + setEventMessages($newproduct->error, $newproduct->errors, 'errors'); + } + } else { + setEventMessages($langs->trans('DatanormArticleNotFound', $lineObj->supplier_ref), null, 'errors'); + } + } + $action = 'edit'; + $import->fetch($id); +} + +// "Alle zuordnen" - Assign Datanorm matches to import lines +if ($action == 'assignallfromdatanorm' && $id > 0) { + $import->fetch($id); + + if ($import->fk_soc > 0) { + $searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0); + + // Get all lines without product + $lines = $importLine->fetchAllByImport($import->id); + $datanorm = new Datanorm($db); + $mapping = new ProductMapping($db); + $assignedCount = 0; + $datanormFoundCount = 0; + + foreach ($lines as $lineObj) { + // Skip lines that already have a product + if ($lineObj->fk_product > 0) { + continue; + } + + // Skip lines without supplier_ref + if (empty($lineObj->supplier_ref)) { + continue; + } + + // Search in Datanorm database + $results = $datanorm->searchByArticleNumber($lineObj->supplier_ref, $import->fk_soc, $searchAll, 1); + + if (empty($results) && !empty($lineObj->ean)) { + $results = $datanorm->searchByArticleNumber($lineObj->ean, $import->fk_soc, $searchAll, 1); + } + + if (!empty($results)) { + $datanormFoundCount++; + $datanormMatch = $results[0]; + // Get Datanorm ID and article number (array access) + $datanormId = isset($datanormMatch['id']) ? $datanormMatch['id'] : (isset($datanormMatch['rowid']) ? $datanormMatch['rowid'] : 0); + $articleNumber = isset($datanormMatch['article_number']) ? $datanormMatch['article_number'] : ''; + + // Check if product already exists for this supplier ref + $existingProductId = $mapping->findProductBySupplierRef($import->fk_soc, $articleNumber); + + if ($existingProductId > 0) { + // Product exists - assign both product and Datanorm to the line + $lineObj->fk_product = $existingProductId; + $lineObj->fk_datanorm = $datanormId; + $lineObj->match_method = 'datanorm_assign'; + $lineObj->update($user); + $assignedCount++; + } else { + // No product yet - save Datanorm reference for later product creation + $lineObj->fk_datanorm = $datanormId; + $lineObj->match_method = 'datanorm_pending'; + $lineObj->update($user); + } + } + } + + if ($assignedCount > 0) { + setEventMessages($langs->trans('ProductsAssignedFromDatanorm', $assignedCount), null, 'mesgs'); + } + if ($datanormFoundCount > $assignedCount) { + $pendingCount = $datanormFoundCount - $assignedCount; + setEventMessages($langs->trans('DatanormMatchesFoundNotAssigned', $pendingCount), null, 'mesgs'); + } + if ($datanormFoundCount == 0) { + setEventMessages($langs->trans('DatanormBatchNoMatches'), null, 'warnings'); + } + } + + header('Location: '.$_SERVER['PHP_SELF'].'?action=edit&id='.$id.'&token='.newToken()); + exit; +} + +// Create ALL products from Datanorm (batch) +if ($action == 'createallfromdatanorm' && $id > 0) { + $import->fetch($id); + + if ($import->fk_soc > 0) { + // Get Datanorm settings + $markup = getDolGlobalString('IMPORTZUGFERD_DATANORM_MARKUP', 30); + $searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0); + + // Load supplier + $supplier = new Societe($db); + $supplier->fetch($import->fk_soc); + $supplierPrefix = strtoupper(substr(preg_replace('/[^a-zA-Z]/', '', $supplier->name), 0, 3)); + + // Get all lines without product + $lines = $importLine->fetchAllByImport($import->id); + $datanorm = new Datanorm($db); + $createdCount = 0; + $assignedCount = 0; + $errorCount = 0; + + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php'; + + foreach ($lines as $lineObj) { + // Skip lines that already have a product + if ($lineObj->fk_product > 0) { + continue; + } + + // Skip lines without supplier_ref + if (empty($lineObj->supplier_ref)) { + continue; + } + + // Search in Datanorm database + $results = $datanorm->searchByArticleNumber($lineObj->supplier_ref, $import->fk_soc, $searchAll, 1); + + if (empty($results) && !empty($lineObj->ean)) { + $results = $datanorm->searchByArticleNumber($lineObj->ean, $import->fk_soc, $searchAll, 1); + } + + if (!empty($results)) { + $datanormArticle = $results[0]; + $datanorm->fetch($datanormArticle['id']); + + $purchasePrice = $datanorm->price; + if ($datanorm->price_unit > 1) { + $purchasePrice = $datanorm->price / $datanorm->price_unit; + } + + // Get copper surcharge for selling price calculation + // Priority: 1. Datanorm, 2. ZUGFeRD + $copperSurchargeForPrice = 0; + if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0) { + $copperSurchargeForPrice = $datanorm->metal_surcharge; + // Normalize to per-unit if price_unit > 1 + if ($datanorm->price_unit > 1) { + $copperSurchargeForPrice = $datanorm->metal_surcharge / $datanorm->price_unit; + } + } elseif (!empty($lineObj->copper_surcharge) && $lineObj->copper_surcharge > 0) { + $copperSurchargeForPrice = $lineObj->copper_surcharge; + // Normalize based on copper_surcharge_basis_qty + if (!empty($lineObj->copper_surcharge_basis_qty) && $lineObj->copper_surcharge_basis_qty > 1) { + $copperSurchargeForPrice = $lineObj->copper_surcharge / $lineObj->copper_surcharge_basis_qty; + } + } + + // Check if product already exists in Dolibarr + $existingProduct = new Product($db); + $productExists = false; + $existingProductId = 0; + + // 1. Check by supplier reference (ProductFournisseur) + $sqlCheck = "SELECT DISTINCT pf.fk_product FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pf"; + $sqlCheck .= " WHERE pf.fk_soc = ".(int)$import->fk_soc; + $sqlCheck .= " AND pf.ref_fourn = '".$db->escape($datanorm->article_number)."'"; + $sqlCheck .= " AND pf.entity IN (".getEntity('product').")"; + $resqlCheck = $db->query($sqlCheck); + if ($resqlCheck && $db->num_rows($resqlCheck) > 0) { + $objCheck = $db->fetch_object($resqlCheck); + $existingProductId = $objCheck->fk_product; + $productExists = true; + } + + // 2. Check by product reference pattern + if (!$productExists) { + $expectedRef = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number; + $fetchResult = $existingProduct->fetch(0, $expectedRef); + if ($fetchResult > 0) { + $existingProductId = $existingProduct->id; + $productExists = true; + } + } + + // 3. Check by EAN if available + if (!$productExists && !empty($datanorm->ean)) { + $sqlEan = "SELECT rowid FROM ".MAIN_DB_PREFIX."product"; + $sqlEan .= " WHERE barcode = '".$db->escape($datanorm->ean)."'"; + $sqlEan .= " AND entity IN (".getEntity('product').")"; + $resqlEan = $db->query($sqlEan); + if ($resqlEan && $db->num_rows($resqlEan) > 0) { + $objEan = $db->fetch_object($resqlEan); + $existingProductId = $objEan->rowid; + $productExists = true; + } + } + + if ($productExists && $existingProductId > 0) { + // Product exists - just assign it to the line + $lineObj->setProduct($existingProductId, 'datanorm', $user); + + // Add additional supplier prices from selected alternatives (for existing products too) + $supplierPricesPost = GETPOST('supplier_prices', 'array'); + if (!empty($supplierPricesPost[$lineObj->id])) { + foreach ($supplierPricesPost[$lineObj->id] as $altSocId => $altDatanormId) { + // Check if supplier price already exists for this product/supplier + $sqlCheckSupplier = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price"; + $sqlCheckSupplier .= " WHERE fk_product = ".(int)$existingProductId; + $sqlCheckSupplier .= " AND fk_soc = ".(int)$altSocId; + $resCheckSupplier = $db->query($sqlCheckSupplier); + if ($resCheckSupplier && $db->num_rows($resCheckSupplier) > 0) { + continue; // Skip if supplier price already exists + } + + // Fetch the alternative Datanorm article + $altDatanorm = new Datanorm($db); + if ($altDatanorm->fetch($altDatanormId) > 0) { + $altSupplier = new Societe($db); + $altSupplier->fetch($altSocId); + + // Prepare extrafields + $altExtrafields = array(); + if (!empty($altDatanorm->metal_surcharge) && $altDatanorm->metal_surcharge > 0 && !empty($altDatanorm->price)) { + $altExtrafields['options_produktpreis'] = $altDatanorm->price; + } + if (!empty($altDatanorm->product_group)) { + $altExtrafields['options_warengruppe'] = $altDatanorm->product_group; + } + + // Mindestbestellmenge, Verpackungseinheit, Steuersatz und kaufmenge vom bestehenden Preis übernehmen + $altMinQty = 1; + $altPackaging = null; + $altTvaTx = $lineObj->tax_percent ?: 19; + $altKaufmenge = null; + $sqlAltExisting = "SELECT pf.rowid, pf.quantity, pf.packaging, pf.tva_tx FROM " . MAIN_DB_PREFIX . "product_fournisseur_price pf"; + $sqlAltExisting .= " WHERE pf.fk_product = " . (int)$existingProductId; + $sqlAltExisting .= " AND pf.quantity > 0 ORDER BY pf.rowid ASC LIMIT 1"; + $resAltExisting = $db->query($sqlAltExisting); + if ($resAltExisting && $db->num_rows($resAltExisting) > 0) { + $objAltExisting = $db->fetch_object($resAltExisting); + if ($objAltExisting->quantity > 0) { + $altMinQty = $objAltExisting->quantity; + } + if (!empty($objAltExisting->packaging)) { + $altPackaging = $objAltExisting->packaging; + } + if ($objAltExisting->tva_tx > 0) { + $altTvaTx = $objAltExisting->tva_tx; + } + // kaufmenge aus Extrafields übernehmen (nur wenn numerisch und > 0) + $sqlKaufmenge = "SELECT kaufmenge FROM " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields"; + $sqlKaufmenge .= " WHERE fk_object = " . (int)$objAltExisting->rowid; + $resKaufmenge = $db->query($sqlKaufmenge); + if ($resKaufmenge && $db->num_rows($resKaufmenge) > 0) { + $objKaufmenge = $db->fetch_object($resKaufmenge); + $kmValue = trim($objKaufmenge->kaufmenge); + if ($kmValue !== '' && is_numeric($kmValue) && (int)$kmValue > 0) { + $altKaufmenge = (int)$kmValue; + } + } + } + + // Preis berechnen - Datanorm-Preis ist für die price_unit Menge! + $altCableText = $altDatanorm->short_text1 . ' ' . $altDatanorm->short_text2; + $altRingSize = extractCableRingSize($altCableText); + $altPriceUnit = 1; + + if ($altRingSize > 0) { + // Kabel mit Ringgröße + $altPriceUnit = $altRingSize; + $altExtrafields['options_preiseinheit'] = $altRingSize; + } elseif (!empty($altDatanorm->price_unit) && $altDatanorm->price_unit > 1) { + // Nicht-Kabel mit price_unit > 1 + $altPriceUnit = $altDatanorm->price_unit; + $altExtrafields['options_preiseinheit'] = $altDatanorm->price_unit; + } + + // WICHTIG: Datanorm price ist bereits für die altPriceUnit Menge! + // Nur umrechnen wenn altMinQty != altPriceUnit + if ($altMinQty == $altPriceUnit) { + // Gleiche Menge: Preis direkt übernehmen + $altTotalPrice = $altDatanorm->price; + } else { + // Unterschiedliche Menge: Umrechnen über Stückpreis + $altUnitPrice = $altDatanorm->price / $altPriceUnit; + $altTotalPrice = $altUnitPrice * $altMinQty; + } + + // Add supplier price (use tva_tx from existing price) + $altProdfourn = new ProductFournisseur($db); + $altProdfourn->id = $existingProductId; + $altResult = $altProdfourn->update_buyprice( + $altMinQty, $altTotalPrice, $user, 'HT', $altSupplier, 0, + $altDatanorm->article_number, $altTvaTx, + 0, 0, 0, 0, 0, 0, array(), '', + 0, 'HT', 1, '', + trim($altDatanorm->short_text1 . ($altDatanorm->short_text2 ? ' ' . $altDatanorm->short_text2 : '')), + !empty($altDatanorm->ean) ? $altDatanorm->ean : '', + !empty($altDatanorm->ean) ? 2 : 0, + $altExtrafields + ); + + // Verpackungseinheit und kaufmenge nachträglich setzen + if ($altResult > 0) { + // Packaging = Mindestmenge (als Standard) + $sqlAltPkg = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlAltPkg .= " SET packaging = " . (float)($altPackaging ?: $altMinQty); + $sqlAltPkg .= " WHERE rowid = " . (int)$altResult; + $db->query($sqlAltPkg); + + // kaufmenge in Extrafields übernehmen wenn vorhanden + if (!empty($altKaufmenge)) { + $sqlCheckExtra = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields WHERE fk_object = " . (int)$altResult; + $resCheckExtra = $db->query($sqlCheckExtra); + if ($resCheckExtra && $db->num_rows($resCheckExtra) > 0) { + $sqlUpdateKm = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields"; + $sqlUpdateKm .= " SET kaufmenge = " . (int)$altKaufmenge; + $sqlUpdateKm .= " WHERE fk_object = " . (int)$altResult; + $db->query($sqlUpdateKm); + } else { + $sqlInsertKm = "INSERT INTO " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields"; + $sqlInsertKm .= " (fk_object, kaufmenge) VALUES (" . (int)$altResult . ", " . (int)$altKaufmenge . ")"; + $db->query($sqlInsertKm); + } + } + } + + // Create product mapping + $altMapping = new ProductMapping($db); + $altMapping->fk_soc = $altSocId; + $altMapping->supplier_ref = $altDatanorm->article_number; + $altMapping->fk_product = $existingProductId; + $altMapping->ean = $altDatanorm->ean; + $altMapping->manufacturer_ref = $altDatanorm->manufacturer_ref; + $altMapping->description = $altDatanorm->short_text1; + $altMapping->create($user); + } + } + } + + $assignedCount++; + } else { + // Create new product + $newproduct = new Product($db); + $newproduct->type = 0; + $newproduct->status = 1; + $newproduct->status_buy = 1; + $newproduct->ref = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number; + $newproduct->label = $datanorm->short_text1; + if (!empty($datanorm->short_text2) && strlen($newproduct->label) < 100) { + $newproduct->label .= ' '.$datanorm->short_text2; + } + $newproduct->description = $datanorm->getFullDescription(); + + // Set default accounting codes from module settings + $newproduct->accountancy_code_sell = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL', ''); + $newproduct->accountancy_code_sell_intra = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL_INTRA', ''); + $newproduct->accountancy_code_sell_export = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_SELL_EXPORT', ''); + $newproduct->accountancy_code_buy = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY', ''); + $newproduct->accountancy_code_buy_intra = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY_INTRA', ''); + $newproduct->accountancy_code_buy_export = getDolGlobalString('IMPORTZUGFERD_ACCOUNTING_CODE_BUY_EXPORT', ''); + + // Selling price: (purchase price + copper surcharge) × (1 + markup%) + $sellingPrice = ($purchasePrice + $copperSurchargeForPrice) * (1 + $markup / 100); + $newproduct->price = $sellingPrice; + $newproduct->price_base_type = 'HT'; + $newproduct->tva_tx = $lineObj->tax_percent ?: 19; + + if (!empty($datanorm->weight)) { + $newproduct->weight = $datanorm->weight; + $newproduct->weight_units = 0; + } + + if (isModEnabled('barcode') && getDolGlobalString('BARCODE_PRODUCT_ADDON_NUM')) { + $newproduct->barcode = '-1'; + } + + $result = $newproduct->create($user); + + if ($result > 0) { + // Add supplier price + $prodfourn = new ProductFournisseur($db); + $prodfourn->id = $newproduct->id; + $prodfourn->fourn_ref = $datanorm->article_number; + + $supplierEan = ''; + $supplierEanType = 0; + if (!empty($datanorm->ean)) { + $supplierEan = $datanorm->ean; + $supplierEanType = 2; + } elseif (!empty($lineObj->ean)) { + $supplierEan = $lineObj->ean; + $supplierEanType = 2; + } + + // Prepare extrafields for supplier price + $supplierPriceExtrafields = array(); + // Produktpreis (reiner Materialpreis) - nur bei Kabeln mit Metallzuschlag + if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0 && !empty($datanorm->price)) { + $supplierPriceExtrafields['options_produktpreis'] = $datanorm->price; + } + // Preiseinheit - Priorität: 1. ZUGFeRD basis_quantity, 2. Datanorm price_unit + if (!empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) { + $supplierPriceExtrafields['options_preiseinheit'] = $lineObj->basis_quantity; + } elseif (!empty($datanorm->price_unit) && $datanorm->price_unit > 1) { + $supplierPriceExtrafields['options_preiseinheit'] = $datanorm->price_unit; + } + // Warengruppe aus Datanorm + if (!empty($datanorm->product_group)) { + $supplierPriceExtrafields['options_warengruppe'] = $datanorm->product_group; + } + + // Mindestbestellmenge und Preis mit zentraler Funktion berechnen + $pricing = calculateCablePricing($datanorm, 1); + $newMinQty = $pricing['priceUnit']; + $newPackaging = $pricing['priceUnit']; + $newTotalPrice = $datanorm->price; // Originalpreis aus Datanorm + + // Fallback auf ZUGFeRD basis_quantity wenn keine Ringgröße erkannt + if ($newMinQty == 1 && !empty($lineObj->basis_quantity) && $lineObj->basis_quantity > 1) { + $newMinQty = $lineObj->basis_quantity; + $newPackaging = $lineObj->basis_quantity; + $newTotalPrice = $purchasePrice * $newMinQty; + } + + $newPriceResult = $prodfourn->update_buyprice( + $newMinQty, // Quantity (Mindestbestellmenge) + $newTotalPrice, // Price (Gesamtpreis für die Mindestmenge) + $user, + 'HT', // Price base + $supplier, // Supplier + 0, // Availability + $datanorm->article_number, // Supplier ref + $lineObj->tax_percent ?: 19, // VAT + 0, // Charges + 0, // Remise + 0, // Remise percentage + 0, // No price minimum + 0, // Delivery delay + 0, // Reputation + array(), // Localtaxes array + '', // Default VAT code + 0, // Multicurrency price + 'HT', // Multicurrency price base type + 1, // Multicurrency tx + '', // Multicurrency code + trim($datanorm->short_text1 . ($datanorm->short_text2 ? ' ' . $datanorm->short_text2 : '')), // Description from Datanorm + $supplierEan, // Barcode/EAN + $supplierEanType, // Barcode type + $supplierPriceExtrafields // Extra fields + ); + + // Manually ensure extrafields record exists for supplier price + // (Dolibarr update_buyprice doesn't always create it properly) + $sqlGetPrice = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price"; + $sqlGetPrice .= " WHERE fk_product = ".(int)$newproduct->id; + $sqlGetPrice .= " AND fk_soc = ".(int)$supplier->id; + $sqlGetPrice .= " ORDER BY rowid DESC LIMIT 1"; + $resGetPrice = $db->query($sqlGetPrice); + if ($resGetPrice && $db->num_rows($resGetPrice) > 0) { + $objPrice = $db->fetch_object($resGetPrice); + $priceId = $objPrice->rowid; + + // Check if extrafields record exists + $sqlCheckExtra = "SELECT rowid FROM ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields"; + $sqlCheckExtra .= " WHERE fk_object = ".(int)$priceId; + $resCheckExtra = $db->query($sqlCheckExtra); + + $produktpreis = !empty($supplierPriceExtrafields['options_produktpreis']) ? (float)$supplierPriceExtrafields['options_produktpreis'] : 'NULL'; + $preiseinheit = !empty($supplierPriceExtrafields['options_preiseinheit']) ? (int)$supplierPriceExtrafields['options_preiseinheit'] : 1; + $warengruppe = !empty($supplierPriceExtrafields['options_warengruppe']) ? "'".$db->escape($supplierPriceExtrafields['options_warengruppe'])."'" : 'NULL'; + + if ($resCheckExtra && $db->num_rows($resCheckExtra) == 0) { + // Insert extrafields record + $sqlInsertExtra = "INSERT INTO ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields"; + $sqlInsertExtra .= " (fk_object, produktpreis, preiseinheit, warengruppe) VALUES ("; + $sqlInsertExtra .= (int)$priceId.", "; + $sqlInsertExtra .= ($produktpreis === 'NULL' ? "NULL" : $produktpreis).", "; + $sqlInsertExtra .= $preiseinheit.", "; + $sqlInsertExtra .= $warengruppe.")"; + if (!$db->query($sqlInsertExtra)) { + dol_syslog('ImportZugferd: Fehler beim Einfuegen der Extrafields: '.$db->lasterror(), LOG_ERR); + } + } else { + // Update extrafields record + $sqlUpdateExtra = "UPDATE ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields SET "; + $sqlUpdateExtra .= "produktpreis = ".($produktpreis === 'NULL' ? "NULL" : $produktpreis).", "; + $sqlUpdateExtra .= "preiseinheit = ".$preiseinheit.", "; + $sqlUpdateExtra .= "warengruppe = ".$warengruppe." "; + $sqlUpdateExtra .= "WHERE fk_object = ".(int)$priceId; + if (!$db->query($sqlUpdateExtra)) { + dol_syslog('ImportZugferd: Fehler beim Update der Extrafields: '.$db->lasterror(), LOG_ERR); + } + } + } + + // Create product mapping + $mapping = new ProductMapping($db); + $mapping->fk_soc = $import->fk_soc; + $mapping->supplier_ref = $datanorm->article_number; + $mapping->fk_product = $newproduct->id; + $mapping->ean = $datanorm->ean; + $mapping->manufacturer_ref = $datanorm->manufacturer_ref; + $mapping->description = $datanorm->short_text1; + $mapping->create($user); + + // Add additional supplier prices from selected alternatives + $supplierPricesPost = GETPOST('supplier_prices', 'array'); + if (!empty($supplierPricesPost[$lineObj->id])) { + // Hole Werte vom gerade angelegten Hauptpreis (Mindestmenge, Verpackung, Steuersatz) + $mainMinQty = $newMinQty; + $mainPackaging = $newPackaging; + $mainTvaTx = $lineObj->tax_percent ?: 19; + $mainKaufmenge = null; + + // kaufmenge vom Hauptpreis-Extrafield holen (nur wenn numerisch und > 0) + if (!empty($priceId)) { + $sqlMainKm = "SELECT kaufmenge FROM " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields"; + $sqlMainKm .= " WHERE fk_object = " . (int)$priceId; + $resMainKm = $db->query($sqlMainKm); + if ($resMainKm && $db->num_rows($resMainKm) > 0) { + $objMainKm = $db->fetch_object($resMainKm); + $kmValue = trim($objMainKm->kaufmenge); + if ($kmValue !== '' && is_numeric($kmValue) && (int)$kmValue > 0) { + $mainKaufmenge = (int)$kmValue; + } + } + } + + foreach ($supplierPricesPost[$lineObj->id] as $altSocId => $altDatanormId) { + // Skip the main invoice supplier (already added above) + if ($altSocId == $import->fk_soc) { + continue; + } + + // Fetch the alternative Datanorm article + $altDatanorm = new Datanorm($db); + if ($altDatanorm->fetch($altDatanormId) > 0) { + $altSupplier = new Societe($db); + $altSupplier->fetch($altSocId); + + // Prepare extrafields for alternative supplier price + $altExtrafields = array(); + if (!empty($altDatanorm->metal_surcharge) && $altDatanorm->metal_surcharge > 0 && !empty($altDatanorm->price)) { + $altExtrafields['options_produktpreis'] = $altDatanorm->price; + } + if (!empty($altDatanorm->product_group)) { + $altExtrafields['options_warengruppe'] = $altDatanorm->product_group; + } + + // Preis berechnen - Datanorm-Preis ist für die price_unit Menge! + $altCableText = $altDatanorm->short_text1 . ' ' . $altDatanorm->short_text2; + $altRingSize = extractCableRingSize($altCableText); + $altPriceUnit = 1; + + if ($altRingSize > 0) { + // Kabel mit Ringgröße + $altPriceUnit = $altRingSize; + $altExtrafields['options_preiseinheit'] = $altRingSize; + } elseif (!empty($altDatanorm->price_unit) && $altDatanorm->price_unit > 1) { + // Nicht-Kabel mit price_unit > 1 + $altPriceUnit = $altDatanorm->price_unit; + $altExtrafields['options_preiseinheit'] = $altDatanorm->price_unit; + } + + // WICHTIG: Datanorm price ist bereits für die altPriceUnit Menge! + // Umrechnen auf mainMinQty (vom Hauptpreis übernommen) + if ($mainMinQty == $altPriceUnit) { + // Gleiche Menge: Preis direkt übernehmen + $altTotalPrice = $altDatanorm->price; + } else { + // Unterschiedliche Menge: Umrechnen über Stückpreis + $altUnitPrice = $altDatanorm->price / $altPriceUnit; + $altTotalPrice = $altUnitPrice * $mainMinQty; + } + + // Add supplier price for alternative supplier + $altProdfourn = new ProductFournisseur($db); + $altProdfourn->id = $newproduct->id; + $altResult = $altProdfourn->update_buyprice( + $mainMinQty, // Quantity (vom Hauptpreis übernommen) + $altTotalPrice, // Price (für mainMinQty berechnet) + $user, + 'HT', // Price base + $altSupplier, // Alternative supplier + 0, // Availability + $altDatanorm->article_number, // Supplier ref + $mainTvaTx, // VAT (vom Hauptpreis übernommen) + 0, 0, 0, 0, 0, 0, array(), '', + 0, 'HT', 1, '', + trim($altDatanorm->short_text1 . ($altDatanorm->short_text2 ? ' ' . $altDatanorm->short_text2 : '')), + !empty($altDatanorm->ean) ? $altDatanorm->ean : '', + !empty($altDatanorm->ean) ? 2 : 0, + $altExtrafields + ); + + // Verpackungseinheit und kaufmenge nachträglich setzen (vom Hauptpreis übernommen) + if ($altResult > 0) { + $sqlAltPkg = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price"; + $sqlAltPkg .= " SET packaging = " . (float)$mainPackaging; + $sqlAltPkg .= " WHERE rowid = " . (int)$altResult; + $db->query($sqlAltPkg); + + // kaufmenge in Extrafields übernehmen wenn vorhanden + if (!empty($mainKaufmenge)) { + $sqlCheckExtra = "SELECT rowid FROM " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields WHERE fk_object = " . (int)$altResult; + $resCheckExtra = $db->query($sqlCheckExtra); + if ($resCheckExtra && $db->num_rows($resCheckExtra) > 0) { + $sqlUpdateKm = "UPDATE " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields"; + $sqlUpdateKm .= " SET kaufmenge = " . (int)$mainKaufmenge; + $sqlUpdateKm .= " WHERE fk_object = " . (int)$altResult; + $db->query($sqlUpdateKm); + } else { + $sqlInsertKm = "INSERT INTO " . MAIN_DB_PREFIX . "product_fournisseur_price_extrafields"; + $sqlInsertKm .= " (fk_object, kaufmenge) VALUES (" . (int)$altResult . ", " . (int)$mainKaufmenge . ")"; + $db->query($sqlInsertKm); + } + } + } + + // Create product mapping for alternative supplier + $altMapping = new ProductMapping($db); + $altMapping->fk_soc = $altSocId; + $altMapping->supplier_ref = $altDatanorm->article_number; + $altMapping->fk_product = $newproduct->id; + $altMapping->ean = $altDatanorm->ean; + $altMapping->manufacturer_ref = $altDatanorm->manufacturer_ref; + $altMapping->description = $altDatanorm->short_text1; + $altMapping->create($user); + } + } + } + + // Assign to import line + $lineObj->setProduct($newproduct->id, 'datanorm', $user); + $createdCount++; + } else { + $errorCount++; + } + } + } + } + + if ($createdCount > 0) { + setEventMessages($langs->trans('DatanormBatchCreated', $createdCount), null, 'mesgs'); + } + if ($assignedCount > 0) { + setEventMessages($langs->trans('DatanormBatchAssigned', $assignedCount), null, 'mesgs'); + } + if ($errorCount > 0) { + setEventMessages($langs->trans('DatanormBatchErrors', $errorCount), null, 'warnings'); + } + if ($createdCount == 0 && $assignedCount == 0 && $errorCount == 0) { + setEventMessages($langs->trans('DatanormBatchNoMatches'), null, 'warnings'); + } + + // Check if all lines now have products + $allHaveProducts = $importLine->allLinesHaveProducts($id); + if ($allHaveProducts) { + $import->status = ZugferdImport::STATUS_IMPORTED; + $import->update($user); + } + } + $action = 'edit'; + $import->fetch($id); +} + +// Preview Datanorm matches (step 1 - show what will be created) +$datanormPreviewMatches = array(); +if ($action == 'previewdatanorm' && $id > 0) { + $import->fetch($id); + + if ($import->fk_soc > 0) { + // Get Datanorm settings + $markup = getDolGlobalString('IMPORTZUGFERD_DATANORM_MARKUP', 30); + $searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0); + + // Load supplier + $supplier = new Societe($db); + $supplier->fetch($import->fk_soc); + $supplierPrefix = strtoupper(substr(preg_replace('/[^a-zA-Z]/', '', $supplier->name), 0, 3)); + + // Get all lines without product + $lines = $importLine->fetchAllByImport($import->id); + $datanorm = new Datanorm($db); + + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php'; + + foreach ($lines as $lineObj) { + // Skip lines that already have a product + if ($lineObj->fk_product > 0) { + continue; + } + + // Skip lines without supplier_ref + if (empty($lineObj->supplier_ref)) { + continue; + } + + // Search in Datanorm database - get ALL supplier alternatives + $results = $datanorm->searchByArticleNumber($lineObj->supplier_ref, $import->fk_soc, $searchAll, 10); + + if (empty($results) && !empty($lineObj->ean)) { + $results = $datanorm->searchByArticleNumber($lineObj->ean, $import->fk_soc, $searchAll, 10); + } + + if (!empty($results)) { + // Process the primary result (first = current supplier or cheapest) + $datanormArticle = $results[0]; + $datanorm->fetch($datanormArticle['id']); + + $purchasePrice = $datanorm->price; + if ($datanorm->price_unit > 1) { + $purchasePrice = $datanorm->price / $datanorm->price_unit; + } + + // Get copper surcharge for selling price calculation + $copperSurchargeForPrice = 0; + if (!empty($datanorm->metal_surcharge) && $datanorm->metal_surcharge > 0) { + $copperSurchargeForPrice = $datanorm->metal_surcharge; + if ($datanorm->price_unit > 1) { + $copperSurchargeForPrice = $datanorm->metal_surcharge / $datanorm->price_unit; + } + } elseif (!empty($lineObj->copper_surcharge) && $lineObj->copper_surcharge > 0) { + $copperSurchargeForPrice = $lineObj->copper_surcharge; + if (!empty($lineObj->copper_surcharge_basis_qty) && $lineObj->copper_surcharge_basis_qty > 1) { + $copperSurchargeForPrice = $lineObj->copper_surcharge / $lineObj->copper_surcharge_basis_qty; + } + } + + // Calculate selling price + $sellingPrice = ($purchasePrice + $copperSurchargeForPrice) * (1 + $markup / 100); + + // Check if product already exists in Dolibarr + $existingProductId = 0; + $productAction = 'create'; // 'create' or 'assign' + + // 1. Check by supplier reference (ProductFournisseur) + $sqlCheck = "SELECT DISTINCT pf.fk_product FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pf"; + $sqlCheck .= " WHERE pf.fk_soc = ".(int)$import->fk_soc; + $sqlCheck .= " AND pf.ref_fourn = '".$db->escape($datanorm->article_number)."'"; + $sqlCheck .= " AND pf.entity IN (".getEntity('product').")"; + $resqlCheck = $db->query($sqlCheck); + if ($resqlCheck && $db->num_rows($resqlCheck) > 0) { + $objCheck = $db->fetch_object($resqlCheck); + $existingProductId = $objCheck->fk_product; + $productAction = 'assign'; + } + + // 2. Check by product reference pattern + if ($existingProductId <= 0) { + $expectedRef = 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number; + $existingProduct = new Product($db); + $fetchResult = $existingProduct->fetch(0, $expectedRef); + if ($fetchResult > 0) { + $existingProductId = $existingProduct->id; + $productAction = 'assign'; + } + } + + // 3. Check by EAN if available + if ($existingProductId <= 0 && !empty($datanorm->ean)) { + $sqlEan = "SELECT rowid FROM ".MAIN_DB_PREFIX."product"; + $sqlEan .= " WHERE barcode = '".$db->escape($datanorm->ean)."'"; + $sqlEan .= " AND entity IN (".getEntity('product').")"; + $resqlEan = $db->query($sqlEan); + if ($resqlEan && $db->num_rows($resqlEan) > 0) { + $objEan = $db->fetch_object($resqlEan); + $existingProductId = $objEan->rowid; + $productAction = 'assign'; + } + } + + // Build supplier alternatives array + // Only show suppliers that don't already have a price for this product + $supplierAlternatives = array(); + $existingPriceSuppliers = array(); + + // If product exists, load existing supplier prices + if ($existingProductId > 0) { + $sqlExisting = "SELECT fk_soc FROM ".MAIN_DB_PREFIX."product_fournisseur_price"; + $sqlExisting .= " WHERE fk_product = ".(int)$existingProductId; + $resExisting = $db->query($sqlExisting); + if ($resExisting) { + while ($objEx = $db->fetch_object($resExisting)) { + $existingPriceSuppliers[$objEx->fk_soc] = true; + } + } + } + + foreach ($results as $altResult) { + // Skip if supplier already has a price for this product + if ($existingProductId > 0 && isset($existingPriceSuppliers[$altResult['fk_soc']])) { + continue; + } + + $altSupplier = new Societe($db); + $altSupplier->fetch($altResult['fk_soc']); + + $altPurchasePrice = $altResult['price']; + if ($altResult['price_unit'] > 1) { + $altPurchasePrice = $altResult['price'] / $altResult['price_unit']; + } + + $supplierAlternatives[] = array( + 'datanorm_id' => $altResult['id'], + 'fk_soc' => $altResult['fk_soc'], + 'supplier_name' => $altSupplier->name, + 'article_number' => $altResult['article_number'], + 'short_text1' => $altResult['short_text1'], + 'price' => $altResult['price'], + 'price_unit' => $altResult['price_unit'], + 'purchase_price' => $altPurchasePrice, + 'ean' => $altResult['ean'], + 'manufacturer_ref' => $altResult['manufacturer_ref'], + 'is_invoice_supplier' => ($altResult['fk_soc'] == $import->fk_soc), + ); + } + + // Store match info for preview + $datanormPreviewMatches[] = array( + 'line_id' => $lineObj->id, + 'line_supplier_ref' => $lineObj->supplier_ref, + 'line_product_name' => $lineObj->product_name, + 'line_quantity' => $lineObj->quantity, + 'line_unit_price' => $lineObj->unit_price, + 'datanorm_id' => $datanorm->id, + 'datanorm_article_number' => $datanorm->article_number, + 'datanorm_short_text1' => $datanorm->short_text1, + 'datanorm_short_text2' => $datanorm->short_text2, + 'datanorm_price' => $datanorm->price, + 'datanorm_price_unit' => $datanorm->price_unit, + 'datanorm_ean' => $datanorm->ean, + 'purchase_price' => $purchasePrice, + 'selling_price' => $sellingPrice, + 'copper_surcharge' => $copperSurchargeForPrice, + 'existing_product_id' => $existingProductId, + 'action' => $productAction, + 'new_ref' => 'NEW-'.$supplierPrefix.'-'.$datanorm->article_number, + 'supplier_alternatives' => $supplierAlternatives + ); + } + } + } + $action = 'edit'; +} + +// Create supplier invoice +if ($action == 'createinvoice' && $id > 0) { + $import->fetch($id); + + // Check prerequisites + if ($import->fk_soc <= 0) { + $error++; + setEventMessages($langs->trans('ErrorSupplierRequired'), null, 'errors'); + } else { + // Check all lines have products + $lines = $importLine->fetchAllByImport($id); + $allHaveProducts = true; + foreach ($lines as $line) { + if ($line->fk_product <= 0) { + $allHaveProducts = false; + break; + } + } + + if (!$allHaveProducts) { + $error++; + setEventMessages($langs->trans('ErrorNotAllProductsAssigned'), null, 'errors'); + } else { + // Load supplier to get default values + $supplier = new Societe($db); + $supplier->fetch($import->fk_soc); + + // Create invoice + $invoice = new FactureFournisseur($db); + $invoice->socid = $import->fk_soc; + $invoice->ref_supplier = $import->invoice_number; + $invoice->date = $import->invoice_date; + $invoice->note_private = $langs->trans('ImportedFromZugferd').' ('.$import->ref.')'; + + // Set label to most expensive item (for list display) + $maxTotal = 0; + $mostExpensiveLabel = ''; + foreach ($lines as $line) { + $lineTotal = $line->quantity * $line->unit_price; + if ($lineTotal > $maxTotal) { + $maxTotal = $lineTotal; + $mostExpensiveLabel = $line->product_name; + } + } + if (!empty($mostExpensiveLabel)) { + // Truncate to 255 chars (database field limit) + $invoice->label = dol_trunc($mostExpensiveLabel, 255); + } + + // Use supplier default values for payment + $invoice->cond_reglement_id = $supplier->cond_reglement_supplier_id ?: 1; + $invoice->mode_reglement_id = $supplier->mode_reglement_supplier_id ?: 0; + $invoice->fk_account = $supplier->fk_account ?: 0; + + $db->begin(); + $result = $invoice->create($user); + + if ($result > 0) { + // Add lines + foreach ($lines as $line) { + $res = $invoice->addline( + $line->product_name, + $line->unit_price, + $line->tax_percent, + 0, 0, + $line->quantity, + $line->fk_product, + 0, '', '', + 0, 0, + 'HT', // price_base_type - Netto-Preise aus ZUGFeRD + 0 // type (0=product) + ); + if ($res < 0) { + $error++; + setEventMessages($invoice->error, $invoice->errors, 'errors'); + break; + } + + // Update EAN on product if not set + if (!empty($line->ean) && $line->fk_product > 0) { + $product = new Product($db); + $product->fetch($line->fk_product); + if (empty($product->barcode)) { + $product->barcode = $line->ean; + $product->barcode_type = 2; // EAN13 + $product->update($product->id, $user); + } + } + } + + if (!$error) { + // Invoice stays as draft - user can validate manually + + // Copy PDF to invoice and register in ECM + $source_pdf = $conf->importzugferd->dir_output.'/imports/'.$import->id.'/'.$import->pdf_filename; + if (file_exists($source_pdf)) { + // Relativer Pfad für ECM (ohne DOL_DATA_ROOT Prefix) + $rel_dir = 'fournisseur/facture/'.get_exdir($invoice->id, 2, 0, 0, $invoice, 'invoice_supplier').$invoice->ref; + $dest_dir = $conf->fournisseur->facture->dir_output.'/'.get_exdir($invoice->id, 2, 0, 0, $invoice, 'invoice_supplier').$invoice->ref; + + if (!is_dir($dest_dir)) { + dol_mkdir($dest_dir); + } + + $dest_file = $dest_dir.'/'.$import->pdf_filename; + if (@copy($source_pdf, $dest_file)) { + // In ECM-Datenbank registrieren für korrekte Verknüpfung + require_once DOL_DOCUMENT_ROOT.'/ecm/class/ecmfiles.class.php'; + $ecmfile = new EcmFiles($db); + $ecmfile->filepath = $rel_dir; + $ecmfile->filename = $import->pdf_filename; + $ecmfile->label = md5_file(dol_osencode($dest_file)); + $ecmfile->fullpath_orig = $dest_file; + $ecmfile->gen_or_uploaded = 'uploaded'; + $ecmfile->description = 'ZUGFeRD Import - '.$import->invoice_number; + $ecmfile->src_object_type = 'supplier_invoice'; + $ecmfile->src_object_id = $invoice->id; + $ecmfile->entity = $conf->entity; + $result = $ecmfile->create($user); + if ($result < 0) { + dol_syslog('ImportZugferd: Fehler beim ECM-Eintrag: '.implode(',', $ecmfile->errors), LOG_ERR); + } + } else { + dol_syslog('ImportZugferd: Fehler beim Kopieren der PDF nach '.$dest_dir, LOG_ERR); + } + } + + // Update import record + $import->fk_facture_fourn = $invoice->id; + $import->status = ZugferdImport::STATUS_PROCESSED; + $import->date_import = dol_now(); + $import->update($user); + + $db->commit(); + setEventMessages($langs->trans('InvoiceCreatedSuccessfully'), null, 'mesgs'); + + // Redirect to invoice + header('Location: '.DOL_URL_ROOT.'/fourn/facture/card.php?facid='.$invoice->id); + exit; + } else { + $db->rollback(); + } + } else { + $error++; + setEventMessages($invoice->error, $invoice->errors, 'errors'); + $db->rollback(); + } + } + } + $action = 'edit'; +} + +// Finish import - check for existing invoice and update status +if ($action == 'finishimport' && $id > 0) { + $import->fetch($id); + + // Check all lines have products + $lines = $importLine->fetchAllByImport($id); + $allHaveProducts = true; + foreach ($lines as $line) { + if ($line->fk_product <= 0) { + $allHaveProducts = false; + break; + } + } + + if (!$allHaveProducts) { + $error++; + setEventMessages($langs->trans('ErrorNotAllProductsAssigned'), null, 'errors'); + } elseif ($import->fk_soc <= 0) { + $error++; + setEventMessages($langs->trans('ErrorSupplierRequired'), null, 'errors'); + } else { + // Search for existing supplier invoice with this ref_supplier + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_fourn"; + $sql .= " WHERE fk_soc = ".((int) $import->fk_soc); + $sql .= " AND ref_supplier = '".$db->escape($import->invoice_number)."'"; + $sql .= " LIMIT 1"; + + $resql = $db->query($sql); + if ($resql && $db->num_rows($resql) > 0) { + $obj = $db->fetch_object($resql); + // Found existing invoice - link it + $import->fk_facture_fourn = $obj->rowid; + $import->status = ZugferdImport::STATUS_PROCESSED; + $import->date_import = dol_now(); + $result = $import->update($user); + + if ($result > 0) { + $invoiceLink = ''.$import->invoice_number.''; + setEventMessages($langs->trans('ImportLinkedToExistingInvoice', $invoiceLink), null, 'mesgs'); + } else { + setEventMessages($import->error, null, 'errors'); + } + } else { + // No existing invoice - mark as imported (ready for invoice creation) + $import->status = ZugferdImport::STATUS_IMPORTED; + $result = $import->update($user); + + if ($result > 0) { + setEventMessages($langs->trans('ImportFinished'), null, 'mesgs'); + } else { + setEventMessages($import->error, null, 'errors'); + } + } + } + $action = 'edit'; +} + +// Delete import record +if ($action == 'confirm_delete' && $confirm == 'yes' && $id > 0 && $user->hasRight('importzugferd', 'import', 'delete')) { + $import->fetch($id); + + // Delete lines first + $importLine->deleteAllByImport($id); + + // Delete files + $import_dir = $conf->importzugferd->dir_output.'/imports/'.$import->id; + if (is_dir($import_dir)) { + dol_delete_dir_recursive($import_dir); + } + + // Delete import record + $import->delete($user); + setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs'); + + header('Location: '.$_SERVER['PHP_SELF']); + exit; +} + +/* + * View + */ + +$title = $langs->trans('ZugferdImport'); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-import'); + +print load_fiche_titre($title, '', 'fa-file-import'); + +// Error message +if ($error && !empty($message)) { + setEventMessages($message, null, 'errors'); +} + +/* + * Upload form (shown when no import is being edited) + */ +if (empty($action) || ($action == 'upload' && $error)) { + print '
'; + print ''; + print ''; + + print '
'; + print '
'; + + print '
'; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + + print '
'.$langs->trans('UploadZugferdInvoice').'
'.$langs->trans('File').' (PDF)'; + print ''; + print '
'.$langs->trans('ForceReimport').''; + print ' '; + print ''.$langs->trans('ForceReimportHelp').''; + print '
'; + print '
'; + + print '
'; + print ''; + print '
'; + + print '
'; + + // Show pending imports + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + + $sql = "SELECT i.rowid, i.ref, i.invoice_number, i.seller_name, i.total_ttc, i.status, i.date_creation"; + $sql .= " FROM ".MAIN_DB_PREFIX."importzugferd_import as i"; + $sql .= " WHERE i.entity = ".$conf->entity; + $sql .= " AND i.status IN (".ZugferdImport::STATUS_IMPORTED.", ".ZugferdImport::STATUS_PENDING.")"; + $sql .= " ORDER BY i.date_creation DESC LIMIT 10"; + + $resql = $db->query($sql); + if ($resql) { + $num = $db->num_rows($resql); + if ($num > 0) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + while ($obj = $db->fetch_object($resql)) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + } else { + print ''; + } + } + + print '
'.$langs->trans('PendingImports').'
'.$langs->trans('Ref').''.$langs->trans('InvoiceNumber').''.$langs->trans('Supplier').''.$langs->trans('TotalTTC').''.$langs->trans('Status').'
'.$obj->ref.''.$obj->invoice_number.''.$obj->seller_name.''.price($obj->total_ttc).''; + $tmpimport = new ZugferdImport($db); + print $tmpimport->LibStatut($obj->status, 1); + print '
'.$langs->trans('NoPendingImports').'
'; + print '
'; + print '
'; + + print '
'; + print '
'; +} + +/* + * Delete confirmation dialog + */ +if ($action == 'delete' && $id > 0) { + $import->fetch($id); + $formconfirm = $form->formconfirm( + $_SERVER['PHP_SELF'].'?id='.$import->id, + $langs->trans('DeleteImportRecord'), + $langs->trans('ConfirmDeleteImportRecord', $import->ref), + 'confirm_delete', + '', + 0, + 1 + ); + print $formconfirm; + $action = 'edit'; // Continue showing the edit form +} + +/* + * Edit/Review import + */ +if ($action == 'edit' && $import->id > 0) { + // Fetch lines + $lines = $importLine->fetchAllByImport($import->id); + $missingProducts = $importLine->countLinesWithoutProduct($import->id); + $allComplete = ($missingProducts == 0 && $import->fk_soc > 0); + + // Header info + print '
'; + + // Status banner + if ($import->status == ZugferdImport::STATUS_PENDING) { + print '
'; + print ''; + print $langs->trans('ManualInterventionRequired'); + if ($missingProducts > 0) { + print ' - '.$missingProducts.' '.$langs->trans('ProductsNotAssigned'); + } + if ($import->fk_soc <= 0) { + print ' - '.$langs->trans('SupplierNotAssigned'); + } + print '

'; + } elseif ($allComplete) { + print '
'; + print ''; + print $langs->trans('ReadyToCreateInvoice'); + print '
'; + } + + // Invoice data + print '
'; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + print '
'.$langs->trans('InvoiceData').' - '.$import->ref.'
'.$langs->trans('InvoiceNumber').''.dol_escape_htmltag($import->invoice_number).''.$langs->trans('InvoiceDate').''.dol_print_date($import->invoice_date, 'day').'
'.$langs->trans('Supplier').''.dol_escape_htmltag($import->seller_name).''.$langs->trans('VATIntra').''.dol_escape_htmltag($import->seller_vat).'
'.$langs->trans('BuyerReference').''.dol_escape_htmltag($import->buyer_reference).''.$langs->trans('TotalHT').''.price($import->total_ht).' '.$import->currency.'
'.$langs->trans('Status').''.$import->getLibStatut(1).''.$langs->trans('TotalTTC').''.price($import->total_ttc).' '.$import->currency.'
'; + print '
'; + + // Supplier selection + print '
'; + print '
'; + print ''; + print ''; + print ''; + + print '
'; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + + print '
'.$langs->trans('SupplierAssignment').'
'.$langs->trans('SelectSupplier').' *'; + print $form->select_company($import->fk_soc, 'supplier_id', 's.fournisseur = 1', 'SelectThirdParty', 0, 0, null, 0, 'minwidth300'); + print ' '; + print '
'; + print '
'; + print '
'; + + // Line items + print '
'; + // Form fuer fehlende Lieferantenpreise (ausserhalb der Tabelle, um verschachtelte Forms zu vermeiden) + print ''; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + // Initialize totals for summary row + $totalDolibarrHT = 0; + $totalZugferdHT = 0; + $hasDolibarrPrices = false; + $allProductsMatched = true; + $matchedLinesCount = 0; + $totalLinesCount = count($lines); + $allMissingPrices = array(); // Fehlende Lieferantenpreise sammeln + $hasMissingPrices = false; // Flag für globale Buttons + + foreach ($lines as $line) { + $hasProduct = ($line->fk_product > 0); + $rowStyle = $hasProduct ? 'background-color: #dff0d8;' : ''; // Green for matched products + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + // Dolibarr price column - show supplier price and difference + print ''; + + print ''; + print ''; + + print ''; + print ''; + + // Accumulate ZUGFeRD total + $totalZugferdHT += $line->line_total; + } + + // Summary row with total comparison + // Only show full comparison if ALL products are matched with Dolibarr prices + print ''; + print ''; + + if ($allProductsMatched && $hasDolibarrPrices) { + // Full comparison possible - all products matched with prices + $totalDiff = $totalZugferdHT - $totalDolibarrHT; + $totalDiffPercent = ($totalDolibarrHT > 0) ? (($totalDiff / $totalDolibarrHT) * 100) : 0; + + // Determine colors: green if close match, red if significant difference + $threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10); + $isMatch = (abs($totalDiffPercent) < 0.5); // Less than 0.5% difference = match + $isSignificant = (abs($totalDiffPercent) >= $threshold); + + if ($isMatch) { + $cellStyle = 'background-color: #dff0d8;'; // Green + } elseif ($isSignificant) { + $cellStyle = 'background-color: #f2dede;'; // Red + } else { + $cellStyle = 'background-color: #fcf8e3;'; // Yellow/warning + } + + print ''; + print ''; + print ''; + } else { + // Not all products matched - show totals but no comparison + print ''; + print ''; + print ''; + } + print ''; + + print '
'.$langs->trans('Position').''.$langs->trans('SupplierRef').''.$langs->trans('ProductDescription').''.$langs->trans('Qty').''.$langs->trans('UnitPrice').''.$langs->trans('DolibarrPrice').''.$langs->trans('TotalHT').''.$langs->trans('MatchedProduct').''.$langs->trans('Action').'
'.$line->line_id.''.dol_escape_htmltag($line->supplier_ref).''; + print dol_escape_htmltag($line->product_name); + if (!empty($line->ean) && !$hasProduct) { + print '
EAN: '.dol_escape_htmltag($line->ean).''; + } + print '
'.price2num($line->quantity, 'MS').' '.zugferdGetUnitLabel($line->unit_code).''; + print price($line->unit_price); + if (!empty($line->basis_quantity) && $line->basis_quantity != 1) { + print '
('.price($line->unit_price_raw).'/'.price2num($line->basis_quantity, 'MS').zugferdGetUnitLabel($line->basis_quantity_unit).')'; + } + print '
'; + $lineDolibarrTotal = 0; + if ($hasProduct && $import->fk_soc > 0) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php'; + $productFourn = new ProductFournisseur($db); + // Use line quantity to find price for matching quantity tier (e.g. 100m cables) + $searchQty = max(1, $line->quantity); + $result = $productFourn->find_min_price_product_fournisseur($line->fk_product, $searchQty, $import->fk_soc); + + if ($result > 0 && $productFourn->fourn_unitprice > 0) { + // Use unit price for comparison (per-unit, not per-quantity-tier) + // Note: fourn_unitprice already INCLUDES copper surcharge - it's the total price from invoice + // The extrafield 'kupferzuschlag' is only informational (shows copper portion of price) + // The extrafield 'produktpreis' is only informational (shows material price without copper) + $dolibarrUnitPrice = $productFourn->fourn_unitprice; + $zugferdPrice = $line->unit_price; + $priceDiff = $zugferdPrice - $dolibarrUnitPrice; + $priceDiffPercent = ($dolibarrUnitPrice > 0) ? (($priceDiff / $dolibarrUnitPrice) * 100) : 0; + + // Accumulate for summary + $lineDolibarrTotal = $dolibarrUnitPrice * $line->quantity; + $totalDolibarrHT += $lineDolibarrTotal; + $hasDolibarrPrices = true; + $matchedLinesCount++; + + print price($dolibarrUnitPrice); + + if (abs($priceDiffPercent) >= 0.01) { + $threshold = getDolGlobalInt('IMPORTZUGFERD_PRICE_DIFF_THRESHOLD', 10); + $isSignificant = (abs($priceDiffPercent) >= $threshold); + + print '
'; + if ($priceDiff > 0) { + // ZUGFeRD price is higher + $iconColor = $isSignificant ? 'color: #d9534f;' : 'color: #f0ad4e;'; + print ''; + print ' +'.number_format($priceDiffPercent, 1).'%'; + print ''; + } else { + // ZUGFeRD price is lower + $iconColor = $isSignificant ? 'color: #5cb85c;' : 'color: #5bc0de;'; + print ''; + print ' '.number_format($priceDiffPercent, 1).'%'; + print ''; + } + } else { + print '
'; + } + } else { + print ''.$langs->trans('NoPriceFound').''; + $allProductsMatched = false; // No price found for matched product + } + } else { + print '-'; + $allProductsMatched = false; // Product not matched + } + print '
'.price($line->line_total).''; + + if ($hasProduct) { + $product = new Product($db); + $product->fetch($line->fk_product); + print $product->getNomUrl(1); + if (!empty($line->match_method)) { + print '
'.$langs->trans('MatchMethod').': '.$line->match_method.''; + } + if (!empty($line->ean)) { + print '
'.dol_escape_htmltag($line->ean).''; + } + print ' '; + + // Alle Einkaufspreise des Produktes anzeigen + $sqlPrices = "SELECT pfp.fk_soc, pfp.price, pfp.unitprice, pfp.ref_fourn, pfp.quantity, s.nom as supplier_name"; + $sqlPrices .= " FROM ".MAIN_DB_PREFIX."product_fournisseur_price pfp"; + $sqlPrices .= " LEFT JOIN ".MAIN_DB_PREFIX."societe s ON s.rowid = pfp.fk_soc"; + $sqlPrices .= " WHERE pfp.fk_product = ".(int)$line->fk_product; + $sqlPrices .= " ORDER BY pfp.unitprice ASC"; + $resPrices = $db->query($sqlPrices); + if ($resPrices && $db->num_rows($resPrices) > 0) { + print '
'; + while ($objP = $db->fetch_object($resPrices)) { + $isInvoiceSupplier = ($objP->fk_soc == $import->fk_soc); + $style = $isInvoiceSupplier ? 'font-weight: bold;' : 'color: #666;'; + print '
'; + print dol_escape_htmltag($objP->supplier_name); + print ': '.price($objP->unitprice).''; + if ($objP->quantity > 1) { + print ' ('.price($objP->price).'/'.(int)$objP->quantity.'Stk.)'; + } + if (!empty($objP->ref_fourn)) { + print ' ('.dol_escape_htmltag($objP->ref_fourn).')'; + } + if ($isInvoiceSupplier) { + print ' '; + } + print '
'; + } + print '
'; + } + } else { + print ''.$langs->trans('NoProductMatch').''; + } + print '
'; + if ($hasProduct) { + // Remove assignment button + print ''; + print ''; + print ''; + + // Fehlende Lieferantenpreise aus anderen Katalogen sammeln (Anzeige weiter unten) + if ($import->fk_soc > 0 && getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL')) { + // Alle vorhandenen Lieferantenpreise fuer dieses Produkt laden + $sqlExistingPrices = "SELECT fk_soc, price, unitprice, barcode FROM ".MAIN_DB_PREFIX."product_fournisseur_price"; + $sqlExistingPrices .= " WHERE fk_product = ".(int)$line->fk_product; + $resExistingPrices = $db->query($sqlExistingPrices); + $existingSupplierIds = array(); + $currentSupplierPrice = 0; + $supplierEan = ''; + if ($resExistingPrices) { + while ($objPrice = $db->fetch_object($resExistingPrices)) { + $existingSupplierIds[$objPrice->fk_soc] = true; + // Stueckpreis und EAN vom Rechnungslieferanten merken + if ($objPrice->fk_soc == $import->fk_soc) { + $currentSupplierPrice = $objPrice->unitprice; + if (!empty($objPrice->barcode)) { + $supplierEan = $objPrice->barcode; + } + } + } + } + + // Suche mit Lieferanten-Artikelnummer (die EAN wird intern für Cross-Catalog verwendet) + $datanormSearch = new Datanorm($db); + $allCatalogResults = array(); + + // Suche mit Artikelnummer - die Funktion nutzt dann die EAN für Cross-Catalog + if (!empty($line->supplier_ref)) { + $allCatalogResults = $datanormSearch->searchByArticleNumber($line->supplier_ref, $import->fk_soc, true, 10); + } + + if (!empty($allCatalogResults)) { + + $missingSuppliers = array(); + foreach ($allCatalogResults as $catalogResult) { + if (!isset($existingSupplierIds[$catalogResult['fk_soc']])) { + $altSupplier = new Societe($db); + $altSupplier->fetch($catalogResult['fk_soc']); + + $altPurchasePrice = $catalogResult['price']; + if ($catalogResult['price_unit'] > 1) { + $altPurchasePrice = $catalogResult['price'] / $catalogResult['price_unit']; + } + + $missingSuppliers[] = array( + 'datanorm_id' => $catalogResult['id'], + 'fk_soc' => $catalogResult['fk_soc'], + 'supplier_name' => $altSupplier->name, + 'article_number' => $catalogResult['article_number'], + 'price' => $catalogResult['price'], + 'price_unit' => $catalogResult['price_unit'], + 'purchase_price' => $altPurchasePrice, + 'ean' => $catalogResult['ean'], + ); + } + } + + if (!empty($missingSuppliers)) { + // Inline-Anzeige der fehlenden Lieferantenpreise direkt bei der Produktzeile + $toggleId = 'missing_inline_'.$line->id; + $missingCount = count($missingSuppliers); + + print '
'; + print '
'; + print ''; + print $langs->trans('MissingSupplierPrices'); + print ' '.$missingCount.''; + print ' '; + print '
'; + + // Aufklappbarer Bereich (Standard: sichtbar/aufgeklappt) + print '
'; + + foreach ($missingSuppliers as $missing) { + $priceDiffHtml = ''; + if ($currentSupplierPrice > 0) { + $pDiff = $missing['purchase_price'] - $currentSupplierPrice; + $pDiffPercent = ($pDiff / $currentSupplierPrice) * 100; + if ($pDiff < 0) { + $priceDiffHtml = ' '.number_format(abs($pDiffPercent), 1).'%'; + } elseif ($pDiff > 0) { + $priceDiffHtml = ' +'.number_format($pDiffPercent, 1).'%'; + } else { + $priceDiffHtml = ' ='; + } + } + + // Wert: productId,socId,datanormId + $cbValue = $line->fk_product.','.$missing['fk_soc'].','.$missing['datanorm_id']; + print '
'; + print ''; + print '
'; + } + print '
'; // End toggleable div + print '
'; // End inline box + + // Track for global actions + $hasMissingPrices = true; + } + } + } + } else { + // Product selection form + print '
'; + print ''; + print ''; + print ''; + print ''; + print $form->select_produits('', 'product_id_'.$line->id, '', 0, 0, -1, 2, '', 0, array(), 0, '1', 0, 'minwidth150 maxwidth200', 1, '', 0); + print ' '; + print '
'; + + // Create new product link + $create_url = DOL_URL_ROOT.'/product/card.php?action=create'; + $create_url .= '&label='.urlencode($line->product_name); + $create_url .= '&price='.urlencode($line->unit_price); + $create_desc = ''; + if (!empty($line->supplier_ref)) { + $create_desc .= $langs->trans('SupplierRef').': '.$line->supplier_ref."\n"; + } + if (!empty($line->unit_code)) { + $create_desc .= $langs->trans('Unit').': '.zugferdGetUnitLabel($line->unit_code)."\n"; + } + if (!empty($line->ean)) { + $create_desc .= 'EAN: '.$line->ean."\n"; + } + $create_url .= '&description='.urlencode(trim($create_desc)); + + print '
'; + print ' '.$langs->trans('CreateProduct'); + print ''; + + // Refresh-Button nach Produktanlage + print ' '; + print ''; + print ''; + + // Product template + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + print $form->select_produits('', 'template_product_id_'.$line->id, '', 0, 0, -1, 2, '', 0, array(), 0, '1', 0, 'minwidth100 maxwidth150', 1, '', 0); + print ' '; + print '
'; + + // Datanorm button (only if supplier is set and supplier_ref exists) + if ($import->fk_soc > 0 && !empty($line->supplier_ref)) { + // Check if Datanorm article exists + $datanormCheck = new Datanorm($db); + $searchAll = getDolGlobalString('IMPORTZUGFERD_DATANORM_SEARCH_ALL', 0); + $datanormResults = $datanormCheck->searchByArticleNumber($line->supplier_ref, $import->fk_soc, $searchAll, 1); + + if (!empty($datanormResults)) { + $datanormArticle = $datanormResults[0]; + print '
'; + print ''; + print ''.$langs->trans('CreateFromDatanorm'); + print ''; + // Button to show raw Datanorm data + print ' '; + print ''; + print ''; + // Show comparison: Invoice name vs Datanorm name + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print '
Rechnung:'.dol_trunc($line->product_name, 50).'
Datanorm:'.dol_trunc($datanormArticle['short_text1'], 50).''; + print ' ('.price($datanormArticle['price']).')
'; + print '
'; + } + } + } + print '
'.$langs->trans('Total').' '.$langs->trans('TotalHT').''; + print ''.price($totalDolibarrHT).''; + if (abs($totalDiffPercent) >= 0.01) { + print '
'; + if ($totalDiff > 0) { + print ' +'.number_format($totalDiffPercent, 1).'%'; + } elseif ($totalDiff < 0) { + print ' '.number_format($totalDiffPercent, 1).'%'; + } + } + print '
'.price($totalZugferdHT).''; + if ($isMatch) { + print ' '.$langs->trans('SumValidationOk').''; + } else { + print ' '.$langs->trans('Difference').': '.price($totalDiff).' '.$import->currency.''; + } + print ''; + if ($hasDolibarrPrices) { + print ''.price($totalDolibarrHT).''; + print '
('.$matchedLinesCount.'/'.$totalLinesCount.')'; + } else { + print '-'; + } + print '
'.price($totalZugferdHT).''; + print ' '.$langs->trans('ProductsNotAssigned').''; + print '
'; + print '
'; + + // Aktionsbereich für fehlende Lieferantenpreise (wenn vorhanden) + if ($hasMissingPrices) { + print '
'; + print '
'; + + // Toggle Buttons + print ''.$langs->trans('MissingSupplierPrices').''; + print ''; + print ''.$langs->trans('ExpandAll'); + print ''; + print ''; + print ''.$langs->trans('CollapseAll'); + print ''; + + // Checkbox Buttons + print ''; + print ''; + print ''.$langs->trans('SelectAll'); + print ''; + print ''; + print ''; + print ''.$langs->trans('DeselectAll'); + print ''; + + // Submit Button + print ''; + print ''; + print ''; + + print '
'; + print '
'; + } + + // missing_prices_form ist bereits oben geschlossen (ausgelagert wegen verschachtelter Forms) + + // Datanorm Preview Section (shown when preview action was triggered) + if (!empty($datanormPreviewMatches)) { + print '
'; + print '
'; + print ''.$langs->trans('DatanormPreview'); + print ' '.count($datanormPreviewMatches).' '.$langs->trans('Matches').''; + print '
'; + + print '
'; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + $countCreate = 0; + $countAssign = 0; + foreach ($datanormPreviewMatches as $match) { + $rowClass = ($match['action'] == 'assign') ? 'background-color: #d9edf7;' : 'background-color: #dff0d8;'; + + print ''; + print ''; + print ''; + + // Invoice product name + print ''; + + // Datanorm product name + print ''; + + // Invoice price (from ZUGFeRD) + print ''; + + // Datanorm price - show original price and calculated unit price + print ''; + + // Selling price + print ''; + + // Action + print ''; + print ''; + + // Show supplier alternatives if available (more than 1 supplier found) + if (!empty($match['supplier_alternatives']) && count($match['supplier_alternatives']) > 1) { + $altCount = count($match['supplier_alternatives']); + $toggleId = 'alt_'.$match['line_id']; + print ''; + print ''; // Empty checkbox column + print ''; + print ''; + } + } + + print '
'.$langs->trans('SupplierRef').''.$langs->trans('InvoiceProductName').''.$langs->trans('DatanormProductName').''.$langs->trans('InvoicePrice').''.$langs->trans('DatanormPrice').''.$langs->trans('SellingPrice').''.$langs->trans('Action').'
'.$match['datanorm_article_number'].''; + if (!empty($match['datanorm_ean'])) { + print '
EAN: '.$match['datanorm_ean'].''; + } + print '
'; + print ''.dol_trunc($match['line_product_name'], 40).''; + print ''; + print ''.dol_trunc($match['datanorm_short_text1'], 40).''; + if (!empty($match['datanorm_short_text2'])) { + print '
'.dol_trunc($match['datanorm_short_text2'], 40).''; + } + print '
'; + print ''.price($match['line_unit_price']).''; + print ''; + if ($match['datanorm_price_unit'] > 1) { + // Show original price and price unit + print ''.price($match['datanorm_price']).'/'.$match['datanorm_price_unit'].''; + print '
= '.price($match['purchase_price']).''; + } else { + print ''.price($match['purchase_price']).''; + } + if ($match['copper_surcharge'] > 0) { + print '
+ '.price($match['copper_surcharge']).' Cu'; + } + print '
'.price($match['selling_price']).''; + if ($match['action'] == 'assign') { + print ' '.$langs->trans('Assign').''; + $countAssign++; + } else { + print ' '.$langs->trans('Create').''; + print '
'.$match['new_ref'].''; + $countCreate++; + } + print '
'; + print '
'; + // Header mit Anzahl und Toggle-Button + print '
'; + print ''.$langs->trans('SupplierAlternatives'); + print ' '.$altCount.' '.$langs->trans('Suppliers').''; + print ' '; + print '
'; + // Aufklappbarer Bereich (Standard: sichtbar) + print '
'; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + $lowestPrice = PHP_FLOAT_MAX; + foreach ($match['supplier_alternatives'] as $alt) { + if ($alt['purchase_price'] < $lowestPrice) { + $lowestPrice = $alt['purchase_price']; + } + } + + foreach ($match['supplier_alternatives'] as $altIdx => $alt) { + $isInvoiceSupplier = $alt['is_invoice_supplier']; + $isCheapest = ($alt['purchase_price'] == $lowestPrice); + $rowStyle = ''; + if ($isInvoiceSupplier) { + $rowStyle = 'background-color: #d9edf7;'; // Blue for invoice supplier + } elseif ($isCheapest) { + $rowStyle = 'background-color: #dff0d8;'; // Green for cheapest + } + + print ''; + + // Checkbox for selecting this supplier as purchase source + print ''; + + // Supplier name + print ''; + + // Supplier article number + print ''; + + // EAN + print ''; + + // Manufacturer ref + print ''; + + // Price + print ''; + + // Difference from invoice supplier price + print ''; + + print ''; + } + + print '
'.$langs->trans('Select').''.$langs->trans('Supplier').''.$langs->trans('SupplierRef').''.$langs->trans('EAN').''.$langs->trans('ManufacturerRef').''.$langs->trans('UnitPrice').''.$langs->trans('Difference').'
'; + $checkboxName = 'supplier_prices['.$match['line_id'].']['.$alt['fk_soc'].']'; + $checked = $isInvoiceSupplier ? ' checked' : ''; + print ''; + print ''; + print ''.dol_escape_htmltag($alt['supplier_name']).''; + if ($isInvoiceSupplier) { + print ' '; + } + if ($isCheapest) { + print ' '; + } + print ''.dol_escape_htmltag($alt['article_number']).''; + if (!empty($alt['ean'])) { + print ''.dol_escape_htmltag($alt['ean']).''; + } else { + print '-'; + } + print ''; + if (!empty($alt['manufacturer_ref'])) { + print ''.dol_escape_htmltag($alt['manufacturer_ref']).''; + } else { + print '-'; + } + print ''; + if ($alt['price_unit'] > 1) { + print ''.price($alt['price']).'/'.$alt['price_unit'].'
'; + } + print ''.price($alt['purchase_price']).''; + print '
'; + if (!$isInvoiceSupplier && isset($match['purchase_price'])) { + $diff = $alt['purchase_price'] - $match['purchase_price']; + $diffPercent = ($match['purchase_price'] > 0) ? ($diff / $match['purchase_price'] * 100) : 0; + if ($diff < 0) { + print ' '.price(abs($diff)).' ('.number_format(abs($diffPercent), 1).'%)'; + } elseif ($diff > 0) { + print ' +'.price($diff).' (+'.number_format($diffPercent, 1).'%)'; + } else { + print '='; + } + } else { + print '-'; + } + print '
'; + print '
'; + print ' '.$langs->trans('SelectSuppliersForPurchasePrices'); + print '
'; + print '
'; // End toggleable div + print '
'; // End white box + print '
'; + + // Summary and confirm button + print '
'; + print '
'; + if ($countCreate > 0) { + print ' '.$countCreate.' '.$langs->trans('ToCreate').''; + } + if ($countAssign > 0) { + print ' '.$countAssign.' '.$langs->trans('ToAssign').''; + } + print '
'; + print ''; + print '   '; + print ''; + print ''.$langs->trans('Cancel'); + print ''; + print '
'; + + print '
'; + print '
'; + + // JavaScript for select all checkbox + print ''; + } + + // Action buttons + print '
'; + + if ($allComplete) { + print ''; + print ''.$langs->trans('CreateSupplierInvoice'); + print ''; + print '   '; + } + + // Finish import button - shown when pending status and all products assigned + if ($import->status == ZugferdImport::STATUS_PENDING && $allComplete) { + print ''; + print ''.$langs->trans('FinishImport'); + print ''; + print '   '; + } + + // Datanorm buttons - show when products are missing and supplier is set + if ($missingProducts > 0 && $import->fk_soc > 0 && empty($datanormPreviewMatches)) { + // "Alle zuordnen" - creates all products from Datanorm + print ''; + print ''.$langs->trans('AssignAllFromDatanorm'); + print ''; + print '   '; + + // "Datanorm Vorschau" - preview what will be created + print ''; + print ''.$langs->trans('PreviewDatanormMatches'); + print ''; + print '   '; + } + + print ''.$langs->trans('BackToList').''; + + // Delete button - show for pending imports or imports without linked invoice + $canDelete = ($import->status == ZugferdImport::STATUS_PENDING) || + ($import->status == ZugferdImport::STATUS_IMPORTED && $import->fk_facture_fourn <= 0); + if ($canDelete) { + print '   '; + print ''; + print ''.$langs->trans('Delete'); + print ''; + } + + print '
'; + + print '
'; + + // Modal CSS and HTML for raw Datanorm data + print ''; + + // Modal for raw data + print '
'; + print '
'; + print '×'; + print '

Rohdaten:

'; + print '
'; + print '

Laden...

'; + print '
'; + print '
'; + print '
'; + + print ''; +} + +llxFooter(); +$db->close(); diff --git a/importzugferdindex.php b/importzugferdindex.php new file mode 100755 index 0000000..651ceb3 --- /dev/null +++ b/importzugferdindex.php @@ -0,0 +1,194 @@ + + * + * 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 importzugferdindex.php + * \ingroup importzugferd + * \brief Home page of the ZUGFeRD Import module + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; + +dol_include_once('/importzugferd/class/zugferdimport.class.php'); +dol_include_once('/importzugferd/lib/importzugferd.lib.php'); + +// Load translation files +$langs->loadLangs(array("importzugferd@importzugferd")); + +// Security check +if (!isModEnabled('importzugferd')) { + accessforbidden('Module not enabled'); +} +if (!$user->hasRight('importzugferd', 'import', 'read')) { + accessforbidden(); +} + +/* + * View + */ + +$form = new Form($db); + +$title = $langs->trans('ModuleImportZugferdName'); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-index'); + +print load_fiche_titre($title, '', 'fa-file-import'); + +print '
'; + +// Statistics box +print '
'; + +print '
'; +print ''; +print ''; +print ''; +print ''; + +// Count imports by status +$sql = "SELECT status, COUNT(*) as nb FROM ".MAIN_DB_PREFIX."importzugferd_import"; +$sql .= " WHERE entity = ".(int)$conf->entity; +$sql .= " GROUP BY status"; + +$stats = array(0 => 0, 1 => 0, 2 => 0); +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $stats[$obj->status] = $obj->nb; + } +} + +$import = new ZugferdImport($db); + +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans('Statistics').'
'.$langs->trans('TotalImported').''.array_sum($stats).'
'.$import->LibStatut(0, 1).''.$stats[0].'
'.$import->LibStatut(1, 1).''.$stats[1].'
'.$import->LibStatut(2, 1).''.$stats[2].'
'; +print '
'; + +print '
'; // fichethirdleft + +// Quick actions and recent imports +print '
'; + +print '
'; +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; + +print '
'.$langs->trans('QuickActions').'
'; +print ''; +print ' '.$langs->trans('ZugferdImport'); +print ''; +print '   '; +print ''; +print ' '.$langs->trans('ImportList'); +print ''; +print '   '; +print ''; +print ' '.$langs->trans('ProductMapping'); +print ''; +print '
'; +print '
'; + +// Recent imports +print '
'; +print '
'; +print ''; +print ''; +print ''; +print ''; + +$sql = "SELECT i.rowid, i.ref, i.invoice_number, i.invoice_date, i.seller_name, i.total_ttc, i.status"; +$sql .= " FROM ".MAIN_DB_PREFIX."importzugferd_import as i"; +$sql .= " WHERE i.entity = ".(int)$conf->entity; +$sql .= " ORDER BY i.date_creation DESC"; +$sql .= " LIMIT 10"; + +$resql = $db->query($sql); +if ($resql) { + $num = $db->num_rows($resql); + if ($num > 0) { + while ($obj = $db->fetch_object($resql)) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + } else { + print ''; + } +} + +print '
'.$langs->trans('RecentImports').'
'.$obj->ref.''.dol_escape_htmltag($obj->invoice_number).''.dol_print_date($db->jdate($obj->invoice_date), 'day').''.dol_escape_htmltag($obj->seller_name).''.price($obj->total_ttc).' EUR'.$import->LibStatut($obj->status, 0).'
'.$langs->trans('NoRecordFound').'
'; +print '
'; + +print '
'; // fichetwothirdright + +print '
'; // fichecenter + +print '
'; + +llxFooter(); +$db->close(); diff --git a/langs/de_DE/importzugferd.lang b/langs/de_DE/importzugferd.lang new file mode 100755 index 0000000..297951f --- /dev/null +++ b/langs/de_DE/importzugferd.lang @@ -0,0 +1,497 @@ +# Übersetzungsdatei für ImportZugferd Modul + +# +# Allgemein +# +ModuleImportZugferdName = ZUGFeRD Import +ModuleImportZugferdDesc = Import von ZUGFeRD/Factur-X Rechnungen als Lieferantenrechnungen + +# +# Admin-Seite +# +ImportZugferdSetup = ZUGFeRD Import Einstellungen +Settings = Einstellungen +ImportZugferdSetupPage = Konfiguration des ZUGFeRD Import Moduls + +# E-Mail Einstellungen +IMPORTZUGFERD_IMAP_HOST = IMAP Server +IMPORTZUGFERD_IMAP_HOSTTooltip = IMAP Server Hostname (z.B. imap.example.com) +IMPORTZUGFERD_IMAP_PORT = IMAP Port +IMPORTZUGFERD_IMAP_PORTTooltip = IMAP Server Port (993 für SSL, 143 für STARTTLS) +IMPORTZUGFERD_IMAP_USER = IMAP Benutzername +IMPORTZUGFERD_IMAP_USERTooltip = E-Mail-Adresse oder Benutzername für IMAP Login +IMPORTZUGFERD_IMAP_PASSWORD = IMAP Passwort +IMPORTZUGFERD_IMAP_PASSWORDTooltip = Passwort für IMAP Login +IMPORTZUGFERD_IMAP_FOLDER = Postfach-Ordner +IMPORTZUGFERD_IMAP_FOLDERTooltip = Ordner für Rechnungs-E-Mails (Standard: INBOX) +IMPORTZUGFERD_IMAP_SSL = SSL verwenden +IMPORTZUGFERD_IMAP_SSLTooltip = SSL-Verschlüsselung für IMAP-Verbindung aktivieren +IMPORTZUGFERD_AUTO_CREATE_INVOICE = Rechnungen automatisch erstellen +IMPORTZUGFERD_AUTO_CREATE_INVOICETooltip = Lieferantenrechnungen beim Import automatisch erstellen + +# Ordner Import Einstellungen +FolderImportSettings = Ordner Import Einstellungen +IMPORTZUGFERD_WATCH_FOLDER = Überwachungsordner +IMPORTZUGFERD_WATCH_FOLDERTooltip = Ordner für eingehende ZUGFeRD-Rechnungen (lokaler Pfad) +IMPORTZUGFERD_ARCHIVE_FOLDER = Archivordner +IMPORTZUGFERD_ARCHIVE_FOLDERTooltip = Ordner für erfolgreich importierte Rechnungen +IMPORTZUGFERD_ERROR_FOLDER = Fehlerordner +IMPORTZUGFERD_ERROR_FOLDERTooltip = Ordner für fehlerhafte Rechnungen (nicht ZUGFeRD oder Importfehler) +IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER = IMAP Archivordner +IMPORTZUGFERD_IMAP_ARCHIVE_FOLDERTooltip = IMAP-Ordner für archivierte E-Mails nach Import + +# +# Über-Seite +# +About = Über +ImportZugferdAbout = Über ZUGFeRD Import +ImportZugferdAboutPage = Dieses Modul ermöglicht den Import von ZUGFeRD/Factur-X Rechnungen aus PDF-Dateien. + +# +# Menü +# +ZugferdImport = Rechnung importieren +ImportList = Import-Liste +ProductMapping = Artikelzuordnung + +# +# Import-Seite +# +UploadZugferdInvoice = ZUGFeRD Rechnung hochladen +InvoiceData = Rechnungsdaten +InvoiceNumber = Rechnungsnummer +InvoiceDate = Rechnungsdatum +BuyerReference = Käuferreferenz (Kundennummer) +DueDate = Fälligkeitsdatum +SupplierAssignment = Lieferantenzuordnung +SelectSupplier = Lieferant auswählen +AutomaticallyDetected = automatisch erkannt +CreateSupplierInvoice = Lieferantenrechnung erstellen +CreateSupplierInvoiceAfterImport = Lieferantenrechnung nach Import erstellen +MatchedProduct = Zugeordnetes Produkt +MatchMethod = Zuordnungsmethode +NoProductMatch = Kein Produkt gefunden +CreateProduct = Produkt anlegen +ImportSuccessful = Rechnung erfolgreich importiert +ImportAnother = Weitere importieren +ViewInvoice = Rechnung anzeigen +ImportedFromZugferd = Importiert aus ZUGFeRD + +# +# Status +# +StatusImported = Importiert +StatusProcessed = Verarbeitet +StatusError = Fehler +Imported = Importiert +Processed = Verarbeitet +Error = Fehler + +# +# Zuordnung +# +AddMapping = Zuordnung hinzufügen +SupplierRef = Lieferanten-Artikelnr. +ManufacturerRef = Hersteller-Artikelnr. +MappingCreated = Zuordnung erstellt +MappingDeleted = Zuordnung gelöscht +DeleteMapping = Zuordnung löschen +ConfirmDeleteMapping = Möchten Sie diese Zuordnung wirklich löschen? +NoMappingsFound = Keine Zuordnungen für diesen Lieferanten gefunden +Active = Aktiv +Inactive = Inaktiv + +# +# Extrafeld +# +SupplierCustomerNumber = Kundennummer beim Lieferant +SupplierCustomerNumberHelp = Ihre Kundennummer bei diesem Lieferanten (für automatische Lieferantenerkennung) + +# +# Cronjob +# +ImportZugferdFromMailbox = ZUGFeRD Rechnungen aus Postfach importieren +ImportZugferdScheduled = ZUGFeRD geplanter Import (Ordner und E-Mail) + +# +# Fehler +# +ErrorSupplierRequired = Bitte wählen Sie einen Lieferanten aus +ErrorNoFileUploaded = Keine Datei hochgeladen +ErrorFileUploadFailed = Datei-Upload fehlgeschlagen +ErrorDuplicateInvoice = Rechnung wurde bereits importiert (Duplikat erkannt) +ErrorProductNotFound = Produkt nicht gefunden +ErrorLineNotFound = Rechnungsposition nicht gefunden + +# +# Statistiken / Startseite +# +Statistics = Statistiken +TotalImported = Gesamt importiert +QuickActions = Schnellaktionen +RecentImports = Letzte Importe +ImportRecord = Import-Datensatz + +# +# Admin +# +IMAPSettings = IMAP Einstellungen +ImportSettings = Import Einstellungen +TestConnection = Verbindung testen +ConnectionSuccessful = Verbindung erfolgreich +ConnectionFailed = Verbindung fehlgeschlagen +ClickTestToCheck = Klicken Sie auf "Verbindung testen" um die Einstellungen zu prüfen +SelectFolder = Ordner auswählen +FolderSelected = Ordner ausgewählt +FoundFolders = Gefundene Ordner +IMAPExtensionNotInstalled = PHP IMAP-Erweiterung ist nicht installiert +IMAPExtensionHelp = Bitte installieren Sie die PHP IMAP-Erweiterung: sudo pacman -S php-imap (Arch) oder sudo apt install php-imap (Debian/Ubuntu) + +# +# Validierung +# +ValidationResult = Validierung +SumValidationOk = OK +SumValidationError = Summenabweichung: ZUGFeRD %s € / Dolibarr %s € (Differenz: %s €) +BasisQuantityInfo = Preis für %s %s +Difference = Differenz +ImportResult = Import Ergebnis + +# +# Karte / Löschen +# +DeleteImportRecord = Import-Datensatz löschen +ConfirmDeleteImportRecord = Möchten Sie den Import-Datensatz %s wirklich löschen? Dies ermöglicht das erneute Importieren der gleichen Rechnung. +RecordDeleted = Datensatz gelöscht +XMLContent = XML-Inhalt +ClickToExpand = Klicken zum Anzeigen +ErrorMessage = Fehlermeldung +ForceReimport = Erneuter Import erzwingen +ForceReimportHelp = Aktivieren, um Duplikatsprüfung zu umgehen (falls Rechnung bereits importiert wurde) + +# +# Produkt Vorlage +# +ProductTemplate = Vorlage +ProductTemplateHelp = Bestehendes Produkt als Vorlage duplizieren und ZUGFeRD-Daten übernehmen +ProductCreated = Produkt erfolgreich erstellt + +# +# Batch Import +# +BatchImport = Stapel-Import +SelectSource = Quelle auswählen +ImportFromFolder = Import aus Ordner +ImportFromIMAP = Import aus E-Mail Postfach +StartImport = Import starten +Files = Dateien +BatchImportComplete = Import abgeschlossen: %s erfolgreich, %s fehlerhaft, %s übersprungen +BatchImportNotConfigured = Kein Überwachungsordner oder IMAP konfiguriert +ConfigureModule = Modul konfigurieren +ErrorWatchFolderNotConfigured = Überwachungsordner nicht konfiguriert oder nicht vorhanden +ErrorIMAPNotConfigured = IMAP nicht konfiguriert +NoFilesFound = Keine PDF-Dateien gefunden +NoEmailsFound = Keine E-Mails gefunden +Success = Erfolgreich +Skipped = Übersprungen +Archived = Archiviert + +# +# Manueller Workflow +# +StatusPending = Manueller Eingriff +PendingImports = Ausstehende Importe +NoPendingImports = Keine ausstehenden Importe +ManualInterventionRequired = Manueller Eingriff erforderlich +ProductsNotAssigned = Produkte nicht zugeordnet +SupplierNotAssigned = Lieferant nicht zugeordnet +ReadyToCreateInvoice = Bereit zur Rechnungserstellung +AssignProduct = Produkt zuordnen +ProductAssigned = Produkt zugeordnet +ProductRemoved = Produktzuordnung entfernt +SupplierUpdated = Lieferant aktualisiert +ManualAssignment = Manuelle Zuordnung +InvoiceCreatedSuccessfully = Rechnung erfolgreich erstellt +ImportRecordCreated = Import-Datensatz erstellt +ErrorNotAllProductsAssigned = Nicht alle Produkte zugeordnet +BackToList = Zurück zur Liste +ErrorRecordNotFound = Datensatz nicht gefunden +FinishImport = Abschließen +ImportFinished = Import abgeschlossen +ImportLinkedToExistingInvoice = Import mit bestehender Rechnung %s verknüpft + +# +# Datanorm +# +DatanormCatalogs = Datanorm Kataloge +DatanormSettings = Datanorm Einstellungen +IMPORTZUGFERD_DATANORM_MARKUP = Preisaufschlag (%) +IMPORTZUGFERD_DATANORM_MARKUPTooltip = Prozentualer Aufschlag auf den Datanorm-Einkaufspreis für den Verkaufspreis +IMPORTZUGFERD_DATANORM_SEARCH_ALL = In allen Lieferanten-Katalogen suchen +IMPORTZUGFERD_DATANORM_SEARCH_ALLTooltip = Bei Aktivierung wird nicht nur im Katalog des aktuellen Lieferanten gesucht, sondern in allen Datanorm-Katalogen + +# Accounting Settings (Standard-Konten für neue Produkte) +AccountingSettings = Buchungskonten für neue Produkte +IMPORTZUGFERD_ACCOUNTING_CODE_SELL = Erlöskonto (Verkauf) +IMPORTZUGFERD_ACCOUNTING_CODE_SELLTooltip = Standard-Erlöskonto für neue Produkte aus Datanorm (z.B. 700000) +IMPORTZUGFERD_ACCOUNTING_CODE_SELL_INTRA = Erlöskonto (innergemeinschaftlich) +IMPORTZUGFERD_ACCOUNTING_CODE_SELL_INTRATooltip = Erlöskonto für innergemeinschaftliche Lieferungen (z.B. 700100) +IMPORTZUGFERD_ACCOUNTING_CODE_SELL_EXPORT = Erlöskonto (Export) +IMPORTZUGFERD_ACCOUNTING_CODE_SELL_EXPORTTooltip = Erlöskonto für Exporte außerhalb EU (z.B. 700200) +IMPORTZUGFERD_ACCOUNTING_CODE_BUY = Aufwandskonto (Einkauf) +IMPORTZUGFERD_ACCOUNTING_CODE_BUYTooltip = Standard-Aufwandskonto für neue Produkte aus Datanorm (z.B. 400000) +IMPORTZUGFERD_ACCOUNTING_CODE_BUY_INTRA = Aufwandskonto (innergemeinschaftlich) +IMPORTZUGFERD_ACCOUNTING_CODE_BUY_INTRATooltip = Aufwandskonto für innergemeinschaftliche Erwerbe (z.B. 400100) +IMPORTZUGFERD_ACCOUNTING_CODE_BUY_EXPORT = Aufwandskonto (Import) +IMPORTZUGFERD_ACCOUNTING_CODE_BUY_EXPORTTooltip = Aufwandskonto für Importe von außerhalb EU (z.B. 400200) + +UploadDatanorm = Datanorm hochladen +DatanormFiles = Datanorm Dateien +DatanormFileHelp = DATANORM.001, DATANORM.WRG oder XML-Dateien (Datanorm 4.0/5.0) +DeleteExisting = Vorhandene Artikel löschen +DeleteExistingHelp = Löscht alle vorhandenen Artikel dieses Lieferanten vor dem Import +DatanormImportSuccess = %s Artikel erfolgreich importiert +DatanormImportFailed = Datanorm Import fehlgeschlagen +DatanormNoArticlesFound = Keine Artikel in der Datanorm-Datei gefunden +NoDatanormData = Keine Datanorm-Daten vorhanden +DatanormDeleted = %s Artikel gelöscht +DatanormDeleteFailed = Löschen fehlgeschlagen +DeleteDatanorm = Datanorm-Katalog löschen +ConfirmDeleteDatanorm = Möchten Sie alle Datanorm-Artikel von %s löschen? +DatanormArticles = Datanorm Artikel +ArticleNumber = Artikelnummer +ArticleCount = Artikelanzahl +LastImport = Letzter Import +ViewArticles = Artikel anzeigen +TotalArticles = Gesamtanzahl Artikel +DatanormSettingsInfo = Preisaufschlag und Suchverhalten können in den Moduleinstellungen konfiguriert werden: +CreateFromDatanorm = Aus Datanorm +CreateFromDatanormHelp = Neues Produkt aus Datanorm-Daten anlegen +ProductCreatedFromDatanorm = Produkt %s aus Datanorm erstellt +DatanormArticleNotFound = Kein Datanorm-Artikel für Artikelnummer '%s' gefunden +CreateAllFromDatanorm = Alle aus Datanorm +CreateAllFromDatanormHelp = Alle fehlenden Produkte aus Datanorm-Daten anlegen +DatanormBatchCreated = %s Produkte aus Datanorm erstellt +DatanormBatchAssigned = %s vorhandene Produkte zugeordnet +DatanormBatchErrors = %s Produkte konnten nicht erstellt werden +DatanormBatchNoMatches = Keine passenden Datanorm-Artikel gefunden +PreviewDatanormMatches = Datanorm Vorschau +DatanormPreview = Datanorm Vorschau - Gefundene Übereinstimmungen +Matches = Treffer +InvoiceProductName = Rechnung Bezeichnung +DatanormProductName = Datanorm Bezeichnung +InvoicePrice = Rechnungspreis +DatanormPrice = Datanorm EK +PurchasePrice = Einkaufspreis +SellingPrice = Verkaufspreis +ProductAlreadyExists = Produkt existiert bereits +Assign = Zuordnen +Create = Anlegen +ToCreate = anzulegen +ToAssign = zuzuordnen +ConfirmAndCreateProducts = Bestätigen und Produkte anlegen +CreateAllWithoutPreview = Direkt anlegen +ConfirmCreateAllWithoutPreview = Alle passenden Produkte aus Datanorm anlegen (ohne Vorschau)? +AssignAllFromDatanorm = Alle zuordnen +ConfirmAssignAllFromDatanorm = Alle vorhandenen Produkte aus Datanorm zuordnen? +NoProductsToAssign = Keine vorhandenen Produkte zum Zuordnen gefunden +ProductsAssignedFromDatanorm = %s Produkte wurden aus Datanorm zugeordnet +DatanormMatchesFoundNotAssigned = %s Datanorm-Treffer gefunden (Produkte können mit "Direkt anlegen" erstellt werden) +ShowRawDatanorm = Rohdaten anzeigen + +# +# Scheduling +# +SchedulingSettings = Zeitplanung +IMPORTZUGFERD_IMPORT_FREQUENCY = Import-Häufigkeit +IMPORTZUGFERD_IMPORT_FREQUENCYTooltip = Wie oft sollen Ordner und E-Mails automatisch auf neue Rechnungen geprüft werden +FrequencyManual = Nur manuell +FrequencyHourly = Stündlich +FrequencyDaily = Täglich +FrequencyWeekly = Wöchentlich +ManualImportTrigger = Manueller Import + +# +# Folder Browser +# +FolderBrowser = Ordner-Auswahl +Browse = Durchsuchen +SelectFolder = Ordner auswählen +SelectThisFolder = Diesen Ordner wählen +CurrentPath = Aktueller Pfad +ParentFolder = Übergeordneter Ordner +NoSubfolders = Keine Unterordner +NotConfigured = Nicht konfiguriert +ErrorFolderNotFound = Ordner nicht gefunden +Go = Los +QuickLinks = Schnellzugriff + +# +# Folder Validation +# +FolderValidation = Ordner-Prüfung +FolderOK = OK +FolderNotFound = Ordner nicht gefunden +FolderNotReadable = Ordner nicht lesbar +FolderNotWritable = Ordner nicht beschreibbar + +# +# Email Notifications +# +NotificationSettings = E-Mail-Benachrichtigungen +IMPORTZUGFERD_NOTIFY_ENABLED = Benachrichtigungen aktivieren +IMPORTZUGFERD_NOTIFY_ENABLEDTooltip = E-Mail-Benachrichtigungen für Import-Ereignisse aktivieren +IMPORTZUGFERD_NOTIFY_EMAIL = Benachrichtigungs-E-Mail +IMPORTZUGFERD_NOTIFY_EMAILTooltip = E-Mail-Adresse für Import-Benachrichtigungen +IMPORTZUGFERD_NOTIFY_MANUAL = Bei manuellem Eingriff +IMPORTZUGFERD_NOTIFY_MANUALTooltip = E-Mail senden wenn ein Import manuellen Eingriff benötigt +IMPORTZUGFERD_NOTIFY_ERROR = Bei Fehlern +IMPORTZUGFERD_NOTIFY_ERRORTooltip = E-Mail senden wenn beim Import ein Fehler auftritt +IMPORTZUGFERD_NOTIFY_PRICE_DIFF = Bei Preisabweichungen +IMPORTZUGFERD_NOTIFY_PRICE_DIFFTooltip = E-Mail senden wenn Produktpreise um mehr als den Schwellenwert abweichen +IMPORTZUGFERD_PRICE_DIFF_THRESHOLD = Preisabweichung Schwelle (%) +IMPORTZUGFERD_PRICE_DIFF_THRESHOLDTooltip = Prozentuale Preisabweichung ab der eine Benachrichtigung gesendet wird + +# Email content +NotifySubjectManualIntervention = Manueller Eingriff erforderlich: Rechnung %s +NotifySubjectError = Import-Fehler: %s +NotifySubjectPriceDiff = Preisabweichungen erkannt: Rechnung %s (%s Produkte) +NotifyBodyManualIntervention = Der Import der Rechnung %s von %s erfordert manuellen Eingriff. +NotifyBodyError = Beim Import der Rechnung/Datei %s ist ein Fehler aufgetreten. +NotifyBodyPriceDiff = Bei der Rechnung %s von %s wurden Preisabweichungen von mehr als %s%% erkannt. +NotifyLinkToImport = Link zum Import +OldPrice = Alter Preis +NewPrice = Neuer Preis +File = Datei + +# Price comparison +DolibarrPrice = Dolibarr Preis +PriceIncrease = Preiserhöhung +PriceDecrease = Preissenkung +NoPriceFound = Kein Preis + +# Test Email +TestEmailNotification = E-Mail-Benachrichtigung testen +SendTestEmail = Test-E-Mail senden +TestEmailSent = Test-E-Mail erfolgreich gesendet an %s +TestEmailFailed = Test-E-Mail konnte nicht gesendet werden +SendTo = Senden an +NotifySubjectTest = Test-E-Mail Benachrichtigung +NotifyBodyTest = Dies ist eine Test-E-Mail vom ZUGFeRD Import Modul. +NotifyTestInfo = Diese E-Mail bestätigt, dass die E-Mail-Benachrichtigungen korrekt konfiguriert sind. +NotifyTestSuccess = Die E-Mail-Konfiguration funktioniert einwandfrei! +CurrentSettings = Aktuelle Einstellungen +NotificationsNotEnabled = Benachrichtigungen sind nicht aktiviert oder keine E-Mail-Adresse konfiguriert +NotifyEmail = Empfänger-E-Mail + +# +# Datanorm Massenaktualisierung +# +DatanormMassUpdate = Datanorm Massenaktualisierung +SelectSupplier = Lieferant auswählen +SelectASupplier = -- Lieferant wählen -- +SearchMode = Suchmodus +SearchBySupplierProducts = Nach Lieferanten-Produkten suchen +ManualSearch = Manuelle Suche +SearchTerm = Suchbegriff +ArticleNumberOrName = Artikelnummer oder Name +AdditionalSearchOptions = Zusätzliche Suchoptionen +AlsoSearchByName = Auch nach Name suchen +AlsoSearchByEAN = Auch nach EAN suchen +AlsoSearchByRef = Auch nach Artikelref. suchen +FieldsToCompare = Felder zum Vergleichen +OnlyShowDifferences = Nur Unterschiede anzeigen +CurrentPrice = Aktueller Preis +DatanormPrice = Datanorm Preis +CurrentDescription = Aktuelle Beschreibung +DatanormDescription = Datanorm Beschreibung +CurrentLabel = Aktueller Name +DatanormLabel = Datanorm Name +DatanormArticle = Datanorm Artikel +ProductNotInDatabase = Produkt nicht in Datenbank +ApplyChanges = Änderungen übernehmen +AddToPending = Zur Liste hinzufügen +Pending = Ausstehend +PendingChanges = Ausstehende Änderungen +NoChanges = Keine Änderungen +ApplyAllPendingChanges = Alle ausstehenden Änderungen übernehmen +ClearPendingChanges = Ausstehende Änderungen löschen +AddedToPendingChanges = Zur Liste hinzugefügt +PendingChangesCleared = Ausstehende Änderungen gelöscht +ConfirmMassUpdate = Massenaktualisierung bestätigen +FollowingProductsWillBeUpdated = Folgende Produkte werden aktualisiert +Changes = Änderungen +DatanormMassUpdateComplete = Massenaktualisierung abgeschlossen: %s erfolgreich, %s Fehler +ProductUpdated = Produkt aktualisiert +ErrorUpdatingProduct = Fehler beim Aktualisieren des Produkts +NoResultsFound = Keine Ergebnisse gefunden +Results = Ergebnisse +WithDifferences = mit Unterschieden +OnlyShowingDifferences = Nur Unterschiede werden angezeigt + +# +# Änderungsprotokoll +# +DatanormChangeLog = Änderungsprotokoll +ChangeHistory = Änderungsverlauf +FieldChanged = Geändertes Feld +OldValue = Alter Wert +NewValue = Neuer Wert +DateChange = Änderungsdatum +ChangedBy = Geändert von +BatchUpdate = Stapelaktualisierung +ViewChangeLog = Änderungsprotokoll anzeigen +NoChangesRecorded = Keine Änderungen protokolliert +PriceChange = Preisänderung +DescriptionChange = Beschreibungsänderung +LabelChange = Namensänderung + +# +# Kupferzuschlag / Metallzuschlag +# +Kupferzuschlag = Kupferzuschlag +KupferzuschlagHelp = Metallzuschlag pro Einheit (wird aus Rechnungen extrahiert) +Produktpreis = Produktpreis +ProduktpreisHelp = Reiner Materialpreis ohne Kupferzuschlag (nur bei Kabeln) +Preiseinheit = Preiseinheit +PreiseinheitHelp = Anzahl Einheiten pro Preis (z.B. 100 = Preis pro 100 Stück) +Warengruppe = Warengruppe +WarengruppeHelp = Produktgruppe aus Datanorm (für Rabattsteuerung und Kategorisierung) +MetalSurchargeDetected = Metallzuschlag erkannt +MetalSurchargeUpdated = Kupferzuschlag aktualisiert auf %s €/Einheit +AddAllWithDifferences = Alle mit Unterschieden hinzufügen +AddedAllToPendingChanges = %s Produkte zur Aktualisierungsliste hinzugefügt +ConfirmAddAllToPending = Alle Produkte mit Unterschieden zur Aktualisierungsliste hinzufügen? +Kupfergehalt = Kupfergehalt (kg/km) +KupfergehaltHelp = Kupfergewicht pro Kilometer Kabel (konstant je Kabeltyp) +CopperSurchargeFromInvoice = Kupferzuschlag aus Rechnung +CopperSurchargePerUnit = Kupferzuschlag/Einheit + +# +# Widget / Dashboard Box +# +BoxNewProductsToReview = Neue Produkte prüfen +NewProductsToReview = Neue Produkte prüfen +NoNewProductsToReview = Keine neuen Produkte zur Überprüfung +ShowAll = Alle anzeigen + +# +# Multi-Supplier Alternatives +# +SupplierAlternatives = Lieferanten-Alternativen +Suppliers = Lieferanten +AddAsPurchasePrice = Als Einkaufspreis hinzufügen +SelectSuppliersForPurchasePrices = Wählen Sie die Lieferanten aus, bei denen ein Einkaufspreis hinterlegt werden soll +ManufacturerRef = Hersteller-Art.Nr. +MissingSupplierPrices = Fehlende Lieferantenpreise +AddSelectedPrices = Ausgewählte hinzufügen +SupplierPricesAdded = %s Lieferantenpreise hinzugefügt +CheaperBy = %s%% günstiger +MoreExpensiveBy = %s%% teurer +RefreshProductListHelp = Produktlisten neu laden (nach Anlage neuer Produkte) +SelectAll = Alle auswählen +DeselectAll = Keine auswählen + +# UI Buttons +ExpandAll = Alle aufklappen +CollapseAll = Alle zuklappen diff --git a/langs/en_US/importzugferd.lang b/langs/en_US/importzugferd.lang new file mode 100755 index 0000000..aa7a7b8 --- /dev/null +++ b/langs/en_US/importzugferd.lang @@ -0,0 +1,428 @@ +# Translation file for ImportZugferd module + +# +# Generic +# +ModuleImportZugferdName = ZUGFeRD Import +ModuleImportZugferdDesc = Import ZUGFeRD/Factur-X invoices as supplier invoices + +# +# Admin page +# +ImportZugferdSetup = ZUGFeRD Import Setup +Settings = Settings +ImportZugferdSetupPage = ZUGFeRD Import module configuration + +# Email settings +IMPORTZUGFERD_IMAP_HOST = IMAP Server +IMPORTZUGFERD_IMAP_HOSTTooltip = IMAP server hostname (e.g. imap.example.com) +IMPORTZUGFERD_IMAP_PORT = IMAP Port +IMPORTZUGFERD_IMAP_PORTTooltip = IMAP server port (993 for SSL, 143 for STARTTLS) +IMPORTZUGFERD_IMAP_USER = IMAP Username +IMPORTZUGFERD_IMAP_USERTooltip = Email address or username for IMAP login +IMPORTZUGFERD_IMAP_PASSWORD = IMAP Password +IMPORTZUGFERD_IMAP_PASSWORDTooltip = Password for IMAP login +IMPORTZUGFERD_IMAP_FOLDER = Mailbox Folder +IMPORTZUGFERD_IMAP_FOLDERTooltip = Folder to monitor for invoices (default: INBOX) +IMPORTZUGFERD_IMAP_SSL = Use SSL +IMPORTZUGFERD_IMAP_SSLTooltip = Enable SSL encryption for IMAP connection +IMPORTZUGFERD_AUTO_CREATE_INVOICE = Auto-create invoices +IMPORTZUGFERD_AUTO_CREATE_INVOICETooltip = Automatically create supplier invoices when importing from mailbox + +# Folder Import Settings +FolderImportSettings = Folder Import Settings +IMPORTZUGFERD_WATCH_FOLDER = Watch Folder +IMPORTZUGFERD_WATCH_FOLDERTooltip = Folder for incoming ZUGFeRD invoices (local path) +IMPORTZUGFERD_ARCHIVE_FOLDER = Archive Folder +IMPORTZUGFERD_ARCHIVE_FOLDERTooltip = Folder for successfully imported invoices +IMPORTZUGFERD_ERROR_FOLDER = Error Folder +IMPORTZUGFERD_ERROR_FOLDERTooltip = Folder for failed invoices (not ZUGFeRD or import errors) +IMPORTZUGFERD_IMAP_ARCHIVE_FOLDER = IMAP Archive Folder +IMPORTZUGFERD_IMAP_ARCHIVE_FOLDERTooltip = IMAP folder for archived emails after import + +# +# About page +# +About = About +ImportZugferdAbout = About ZUGFeRD Import +ImportZugferdAboutPage = This module allows importing ZUGFeRD/Factur-X invoices from PDF files. + +# +# Menu +# +ZugferdImport = Import Invoice +ImportList = Import List +ProductMapping = Product Mapping + +# +# Import page +# +UploadZugferdInvoice = Upload ZUGFeRD Invoice +InvoiceData = Invoice Data +InvoiceNumber = Invoice Number +InvoiceDate = Invoice Date +BuyerReference = Buyer Reference (Customer No.) +DueDate = Due Date +SupplierAssignment = Supplier Assignment +SelectSupplier = Select Supplier +AutomaticallyDetected = automatically detected +CreateSupplierInvoice = Create Supplier Invoice +CreateSupplierInvoiceAfterImport = Create supplier invoice after import +MatchedProduct = Matched Product +MatchMethod = Match method +NoProductMatch = No product match +CreateProduct = Create Product +ImportSuccessful = Invoice imported successfully +ImportAnother = Import Another +ViewInvoice = View Invoice +ImportedFromZugferd = Imported from ZUGFeRD + +# +# Status +# +StatusImported = Imported +StatusProcessed = Processed +StatusError = Error +Imported = Imported +Processed = Processed +Error = Error + +# +# Mapping +# +AddMapping = Add Mapping +SupplierRef = Supplier Article No. +ManufacturerRef = Manufacturer Ref +MappingCreated = Mapping created +MappingDeleted = Mapping deleted +DeleteMapping = Delete Mapping +ConfirmDeleteMapping = Are you sure you want to delete this mapping? +NoMappingsFound = No mappings found for this supplier +Active = Active +Inactive = Inactive + +# +# Extrafield +# +SupplierCustomerNumber = Customer No. at Supplier +SupplierCustomerNumberHelp = Your customer number at this supplier (used for automatic supplier detection) + +# +# Cronjob +# +ImportZugferdFromMailbox = Import ZUGFeRD invoices from mailbox +ImportZugferdScheduled = ZUGFeRD scheduled import (folder and email) + +# +# Errors +# +ErrorSupplierRequired = Please select a supplier +ErrorNoFileUploaded = No file uploaded +ErrorFileUploadFailed = File upload failed +ErrorDuplicateInvoice = Invoice already imported (duplicate detected) +ErrorProductNotFound = Product not found +ErrorLineNotFound = Invoice line not found + +# +# Statistics / Index +# +Statistics = Statistics +TotalImported = Total Imported +QuickActions = Quick Actions +RecentImports = Recent Imports +ImportRecord = Import Record + +# +# Admin +# +IMAPSettings = IMAP Settings +ImportSettings = Import Settings +TestConnection = Test Connection +ConnectionSuccessful = Connection successful +ConnectionFailed = Connection failed +ClickTestToCheck = Click "Test Connection" to verify settings +SelectFolder = Select Folder +FolderSelected = Folder selected +FoundFolders = Found folders +IMAPExtensionNotInstalled = PHP IMAP extension is not installed +IMAPExtensionHelp = Please install the PHP IMAP extension: sudo apt install php-imap (Debian/Ubuntu) or sudo pacman -S php-imap (Arch) + +# +# Validation +# +ValidationResult = Validation +SumValidationOk = OK +SumValidationError = Sum mismatch: ZUGFeRD %s € / Dolibarr %s € (Difference: %s €) +BasisQuantityInfo = Price for %s %s +Difference = Difference +ImportResult = Import Result + +# +# Card / Delete +# +DeleteImportRecord = Delete import record +ConfirmDeleteImportRecord = Are you sure you want to delete import record %s? This will allow re-importing the same invoice. +RecordDeleted = Record deleted +XMLContent = XML Content +ClickToExpand = Click to expand +ErrorMessage = Error message +ForceReimport = Force reimport +ForceReimportHelp = Enable to bypass duplicate check (if invoice was already imported) + +# +# Product Template +# +ProductTemplate = Template +ProductTemplateHelp = Duplicate existing product as template and apply ZUGFeRD data +ProductCreated = Product created successfully + +# +# Batch Import +# +BatchImport = Batch Import +SelectSource = Select Source +ImportFromFolder = Import from Folder +ImportFromIMAP = Import from Email Mailbox +StartImport = Start Import +Files = Files +BatchImportComplete = Import completed: %s successful, %s failed, %s skipped +BatchImportNotConfigured = No watch folder or IMAP configured +ConfigureModule = Configure Module +ErrorWatchFolderNotConfigured = Watch folder not configured or not found +ErrorIMAPNotConfigured = IMAP not configured +NoFilesFound = No PDF files found +NoEmailsFound = No emails found +Success = Success +Skipped = Skipped +Archived = Archived + +# +# Manual Workflow +# +StatusPending = Manual Review +PendingImports = Pending Imports +NoPendingImports = No pending imports +ManualInterventionRequired = Manual intervention required +ProductsNotAssigned = products not assigned +SupplierNotAssigned = Supplier not assigned +ReadyToCreateInvoice = Ready to create invoice +AssignProduct = Assign product +ProductAssigned = Product assigned +ProductRemoved = Product assignment removed +SupplierUpdated = Supplier updated +ManualAssignment = Manual assignment +InvoiceCreatedSuccessfully = Invoice created successfully +ImportRecordCreated = Import record created +ErrorNotAllProductsAssigned = Not all products assigned +BackToList = Back to list +ErrorRecordNotFound = Record not found +FinishImport = Finish Import +ImportFinished = Import finished +ImportLinkedToExistingInvoice = Import linked to existing invoice %s + +# +# Datanorm +# +DatanormCatalogs = Datanorm Catalogs +DatanormSettings = Datanorm Settings +IMPORTZUGFERD_DATANORM_MARKUP = Price Markup (%) +IMPORTZUGFERD_DATANORM_MARKUPTooltip = Percentage markup on Datanorm purchase price for selling price +IMPORTZUGFERD_DATANORM_SEARCH_ALL = Search in all supplier catalogs +IMPORTZUGFERD_DATANORM_SEARCH_ALLTooltip = When enabled, search all Datanorm catalogs, not just the current supplier + +# Accounting Settings (Default accounts for new products) +AccountingSettings = Accounting Codes for New Products +IMPORTZUGFERD_ACCOUNTING_CODE_SELL = Sales Account (Domestic) +IMPORTZUGFERD_ACCOUNTING_CODE_SELLTooltip = Default sales account for new products from Datanorm (e.g. 700000) +IMPORTZUGFERD_ACCOUNTING_CODE_SELL_INTRA = Sales Account (Intra-EU) +IMPORTZUGFERD_ACCOUNTING_CODE_SELL_INTRATooltip = Sales account for intra-community deliveries (e.g. 700100) +IMPORTZUGFERD_ACCOUNTING_CODE_SELL_EXPORT = Sales Account (Export) +IMPORTZUGFERD_ACCOUNTING_CODE_SELL_EXPORTTooltip = Sales account for exports outside EU (e.g. 700200) +IMPORTZUGFERD_ACCOUNTING_CODE_BUY = Purchase Account (Domestic) +IMPORTZUGFERD_ACCOUNTING_CODE_BUYTooltip = Default purchase account for new products from Datanorm (e.g. 400000) +IMPORTZUGFERD_ACCOUNTING_CODE_BUY_INTRA = Purchase Account (Intra-EU) +IMPORTZUGFERD_ACCOUNTING_CODE_BUY_INTRATooltip = Purchase account for intra-community acquisitions (e.g. 400100) +IMPORTZUGFERD_ACCOUNTING_CODE_BUY_EXPORT = Purchase Account (Import) +IMPORTZUGFERD_ACCOUNTING_CODE_BUY_EXPORTTooltip = Purchase account for imports from outside EU (e.g. 400200) + +UploadDatanorm = Upload Datanorm +DatanormFiles = Datanorm Files +DatanormFileHelp = DATANORM.001, DATANORM.WRG or XML files (Datanorm 4.0/5.0) +DeleteExisting = Delete existing articles +DeleteExistingHelp = Deletes all existing articles for this supplier before import +DatanormImportSuccess = %s articles imported successfully +DatanormImportFailed = Datanorm import failed +DatanormNoArticlesFound = No articles found in Datanorm file +NoDatanormData = No Datanorm data available +DatanormDeleted = %s articles deleted +DatanormDeleteFailed = Deletion failed +DeleteDatanorm = Delete Datanorm catalog +ConfirmDeleteDatanorm = Are you sure you want to delete all Datanorm articles from %s? +DatanormArticles = Datanorm Articles +ArticleNumber = Article Number +ArticleCount = Article Count +LastImport = Last Import +ViewArticles = View Articles +TotalArticles = Total Articles +DatanormSettingsInfo = Price markup and search behavior can be configured in module settings: +CreateFromDatanorm = From Datanorm +CreateFromDatanormHelp = Create new product from Datanorm data +ProductCreatedFromDatanorm = Product %s created from Datanorm +DatanormArticleNotFound = No Datanorm article found for article number '%s' +CreateAllFromDatanorm = All from Datanorm +CreateAllFromDatanormHelp = Create all missing products from Datanorm data +DatanormBatchCreated = %s products created from Datanorm +DatanormBatchAssigned = %s existing products assigned +DatanormBatchErrors = %s products could not be created +DatanormBatchNoMatches = No matching Datanorm articles found +PreviewDatanormMatches = Datanorm Preview +DatanormPreview = Datanorm Preview - Found Matches +Matches = matches +InvoiceProductName = Invoice Product Name +DatanormProductName = Datanorm Product Name +InvoicePrice = Invoice Price +DatanormPrice = Datanorm Price +PurchasePrice = Purchase Price +SellingPrice = Selling Price +ProductAlreadyExists = Product already exists +Assign = Assign +Create = Create +ToCreate = to create +ToAssign = to assign +ConfirmAndCreateProducts = Confirm and Create Products +CreateAllWithoutPreview = Create directly +ConfirmCreateAllWithoutPreview = Create all matching products from Datanorm (without preview)? +AssignAllFromDatanorm = Assign all +ConfirmAssignAllFromDatanorm = Assign all existing products from Datanorm? +NoProductsToAssign = No existing products found to assign +ProductsAssignedFromDatanorm = %s products have been assigned from Datanorm +DatanormMatchesFoundNotAssigned = %s Datanorm matches found (products can be created with "Create directly") +ShowRawDatanorm = Show raw data + +# +# Scheduling +# +SchedulingSettings = Scheduling +IMPORTZUGFERD_IMPORT_FREQUENCY = Import Frequency +IMPORTZUGFERD_IMPORT_FREQUENCYTooltip = How often should folders and emails be checked for new invoices automatically +FrequencyManual = Manual only +FrequencyHourly = Hourly +FrequencyDaily = Daily +FrequencyWeekly = Weekly +ManualImportTrigger = Manual Import + +# +# Folder Browser +# +FolderBrowser = Folder Selection +Browse = Browse +SelectFolder = Select Folder +SelectThisFolder = Select This Folder +CurrentPath = Current Path +ParentFolder = Parent Folder +NoSubfolders = No subfolders +NotConfigured = Not configured +ErrorFolderNotFound = Folder not found +Go = Go +QuickLinks = Quick links + +# +# Folder Validation +# +FolderValidation = Folder Validation +FolderOK = OK +FolderNotFound = Folder not found +FolderNotReadable = Folder not readable +FolderNotWritable = Folder not writable + +# +# Email Notifications +# +NotificationSettings = Email Notifications +IMPORTZUGFERD_NOTIFY_ENABLED = Enable notifications +IMPORTZUGFERD_NOTIFY_ENABLEDTooltip = Enable email notifications for import events +IMPORTZUGFERD_NOTIFY_EMAIL = Notification email +IMPORTZUGFERD_NOTIFY_EMAILTooltip = Email address for import notifications +IMPORTZUGFERD_NOTIFY_MANUAL = On manual intervention +IMPORTZUGFERD_NOTIFY_MANUALTooltip = Send email when an import requires manual intervention +IMPORTZUGFERD_NOTIFY_ERROR = On errors +IMPORTZUGFERD_NOTIFY_ERRORTooltip = Send email when an import error occurs +IMPORTZUGFERD_NOTIFY_PRICE_DIFF = On price differences +IMPORTZUGFERD_NOTIFY_PRICE_DIFFTooltip = Send email when product prices differ by more than the threshold +IMPORTZUGFERD_PRICE_DIFF_THRESHOLD = Price difference threshold (%) +IMPORTZUGFERD_PRICE_DIFF_THRESHOLDTooltip = Percentage price difference that triggers a notification + +# Email content +NotifySubjectManualIntervention = Manual intervention required: Invoice %s +NotifySubjectError = Import error: %s +NotifySubjectPriceDiff = Price differences detected: Invoice %s (%s products) +NotifyBodyManualIntervention = The import of invoice %s from %s requires manual intervention. +NotifyBodyError = An error occurred while importing invoice/file %s. +NotifyBodyPriceDiff = Invoice %s from %s has price differences of more than %s%%. +NotifyLinkToImport = Link to import +OldPrice = Old price +NewPrice = New price +File = File + +# Price comparison +DolibarrPrice = Dolibarr Price +PriceIncrease = Price increase +PriceDecrease = Price decrease +NoPriceFound = No price + +# Test Email +TestEmailNotification = Test Email Notification +SendTestEmail = Send Test Email +TestEmailSent = Test email successfully sent to %s +TestEmailFailed = Failed to send test email +SendTo = Send to +NotifySubjectTest = Test Email Notification +NotifyBodyTest = This is a test email from the ZUGFeRD Import module. +NotifyTestInfo = This email confirms that email notifications are correctly configured. +NotifyTestSuccess = The email configuration is working properly! +CurrentSettings = Current settings +NotificationsNotEnabled = Notifications are not enabled or no email address configured +NotifyEmail = Recipient email + +# +# Metal Surcharge +# +Kupferzuschlag = Copper Surcharge +KupferzuschlagHelp = Metal surcharge per unit (extracted from invoices) +Produktpreis = Material Price +ProduktpreisHelp = Material price without copper surcharge (cables only) +Preiseinheit = Price Unit +PreiseinheitHelp = Number of units per price (e.g. 100 = price per 100 pieces) +Warengruppe = Product Group +WarengruppeHelp = Product group from Datanorm (for discount control and categorization) +MetalSurchargeDetected = Metal surcharge detected +MetalSurchargeUpdated = Metal surcharge updated to %s €/unit + +# +# Widget / Dashboard Box +# +BoxNewProductsToReview = New Products to Review +NewProductsToReview = New Products to Review +NoNewProductsToReview = No new products to review +ShowAll = Show all + +# +# Multi-Supplier Alternatives +# +SupplierAlternatives = Supplier Alternatives +Suppliers = Suppliers +AddAsPurchasePrice = Add as Purchase Price +SelectSuppliersForPurchasePrices = Select suppliers where a purchase price should be stored +ManufacturerRef = Manufacturer Ref +MissingSupplierPrices = Missing Supplier Prices +AddSelectedPrices = Add Selected +SupplierPricesAdded = %s supplier prices added +CheaperBy = %s%% cheaper +MoreExpensiveBy = %s%% more expensive +RefreshProductListHelp = Refresh product lists (after creating new products) +SelectAll = Select all +DeselectAll = Deselect all + +# UI Buttons +ExpandAll = Expand all +CollapseAll = Collapse all diff --git a/lib/importzugferd.lib.php b/lib/importzugferd.lib.php new file mode 100755 index 0000000..aa7c65e --- /dev/null +++ b/lib/importzugferd.lib.php @@ -0,0 +1,173 @@ + + * + * 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. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file importzugferd/lib/importzugferd.lib.php + * \ingroup importzugferd + * \brief Library files with common functions for ImportZugferd + */ + +/** + * Prepare admin pages header + * + * @return array + */ +function importzugferdAdminPrepareHead() +{ + global $langs, $conf; + + // global $db; + // $extrafields = new ExtraFields($db); + // $extrafields->fetch_name_optionals_label('myobject'); + + $langs->load("importzugferd@importzugferd"); + + $h = 0; + $head = array(); + + $head[$h][0] = dol_buildpath("/importzugferd/admin/setup.php", 1); + $head[$h][1] = $langs->trans("Settings"); + $head[$h][2] = 'settings'; + $h++; + + /* + $head[$h][0] = dol_buildpath("/importzugferd/admin/myobject_extrafields.php", 1); + $head[$h][1] = $langs->trans("ExtraFields"); + $nbExtrafields = (isset($extrafields->attributes['myobject']['label']) && is_countable($extrafields->attributes['myobject']['label'])) ? count($extrafields->attributes['myobject']['label']) : 0; + if ($nbExtrafields > 0) { + $head[$h][1] .= '' . $nbExtrafields . ''; + } + $head[$h][2] = 'myobject_extrafields'; + $h++; + + $head[$h][0] = dol_buildpath("/importzugferd/admin/myobjectline_extrafields.php", 1); + $head[$h][1] = $langs->trans("ExtraFieldsLines"); + $nbExtrafields = (isset($extrafields->attributes['myobjectline']['label']) && is_countable($extrafields->attributes['myobjectline']['label'])) ? count($extrafields->attributes['myobject']['label']) : 0; + if ($nbExtrafields > 0) { + $head[$h][1] .= '' . $nbExtrafields . ''; + } + $head[$h][2] = 'myobject_extrafieldsline'; + $h++; + */ + + $head[$h][0] = dol_buildpath("/importzugferd/admin/about.php", 1); + $head[$h][1] = $langs->trans("About"); + $head[$h][2] = 'about'; + $h++; + + // Show more tabs from modules + // Entries must be declared in modules descriptor with line + //$this->tabs = array( + // 'entity:+tabname:Title:@importzugferd:/importzugferd/mypage.php?id=__ID__' + //); // to add new tab + //$this->tabs = array( + // 'entity:-tabname:Title:@importzugferd:/importzugferd/mypage.php?id=__ID__' + //); // to remove a tab + complete_head_from_modules($conf, $langs, null, $head, $h, 'importzugferd@importzugferd'); + + complete_head_from_modules($conf, $langs, null, $head, $h, 'importzugferd@importzugferd', 'remove'); + + return $head; +} + +/** + * Get readable label for UN/ECE Recommendation 20 unit codes + * + * @param string $code UN/ECE unit code (e.g. C62, MTR, LTR) + * @return string Readable label or original code if not found + */ +function zugferdGetUnitLabel($code) +{ + // UN/ECE Recommendation 20 - Common unit codes used in ZUGFeRD/Factur-X + $units = array( + // Pieces / Count + 'C62' => 'Stk.', // One (piece/unit) + 'PCE' => 'Stk.', // Piece + 'EA' => 'Stk.', // Each + 'H87' => 'Stk.', // Piece + 'XPP' => 'Stk.', // Piece + 'NAR' => 'Stk.', // Number of articles + 'NMP' => 'Stk.', // Number of packs + 'NPR' => 'Paar', // Number of pairs + 'SET' => 'Set', // Set + 'PR' => 'Paar', // Pair + + // Length + 'MTR' => 'm', // Metre + 'CMT' => 'cm', // Centimetre + 'MMT' => 'mm', // Millimetre + 'KMT' => 'km', // Kilometre + 'INH' => 'Zoll', // Inch + 'FOT' => 'Fuß', // Foot + 'LM' => 'lfm', // Linear metre + + // Area + 'MTK' => 'm²', // Square metre + 'CMK' => 'cm²', // Square centimetre + 'MMK' => 'mm²', // Square millimetre + + // Volume + 'MTQ' => 'm³', // Cubic metre + 'LTR' => 'l', // Litre + 'MLT' => 'ml', // Millilitre + 'HLT' => 'hl', // Hectolitre + 'CMQ' => 'cm³', // Cubic centimetre + + // Mass / Weight + 'KGM' => 'kg', // Kilogram + 'GRM' => 'g', // Gram + 'MGM' => 'mg', // Milligram + 'TNE' => 't', // Tonne (metric ton) + 'LBR' => 'lb', // Pound + + // Time + 'HUR' => 'Std.', // Hour + 'MIN' => 'Min.', // Minute + 'SEC' => 'Sek.', // Second + 'DAY' => 'Tag', // Day + 'WEE' => 'Woche', // Week + 'MON' => 'Monat', // Month + 'ANN' => 'Jahr', // Year + + // Packaging + 'XBX' => 'Karton', // Box + 'XCT' => 'Karton', // Carton + 'XPK' => 'Paket', // Package + 'XPA' => 'Palette', // Pallet + 'XSA' => 'Sack', // Sack + 'XBG' => 'Beutel', // Bag + 'XBO' => 'Flasche', // Bottle + 'XCA' => 'Dose', // Can + 'XRO' => 'Rolle', // Roll + 'XTU' => 'Tube', // Tube + + // Other + 'P1' => '%', // Percent + 'KWH' => 'kWh', // Kilowatt hour + 'MWH' => 'MWh', // Megawatt hour + 'WTT' => 'W', // Watt + 'KWT' => 'kW', // Kilowatt + ); + + $code = strtoupper(trim($code)); + + if (isset($units[$code])) { + return $units[$code]; + } + + return $code; +} diff --git a/list.php b/list.php new file mode 100755 index 0000000..47b06ea --- /dev/null +++ b/list.php @@ -0,0 +1,311 @@ + 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; + +dol_include_once('/importzugferd/class/zugferdimport.class.php'); +dol_include_once('/importzugferd/lib/importzugferd.lib.php'); + +// Load translation files +$langs->loadLangs(array("importzugferd@importzugferd", "bills", "companies")); + +// Security check +if (!$user->hasRight('importzugferd', 'import', 'read')) { + accessforbidden(); +} + +// Get parameters +$action = GETPOST('action', 'aZ09'); +$massaction = GETPOST('massaction', 'alpha'); +$confirm = GETPOST('confirm', 'alpha'); +$toselect = GETPOST('toselect', 'array'); +$contextpage = GETPOST('contextpage', 'aZ') ? GETPOST('contextpage', 'aZ') : 'zugferdimportlist'; + +// Search parameters +$search_ref = GETPOST('search_ref', 'alpha'); +$search_invoice_number = GETPOST('search_invoice_number', 'alpha'); +$search_seller_name = GETPOST('search_seller_name', 'alpha'); +$search_status = GETPOST('search_status', 'int'); + +$limit = GETPOST('limit', 'int') ? GETPOST('limit', 'int') : $conf->liste_limit; +$sortfield = GETPOST('sortfield', 'aZ09comma'); +$sortorder = GETPOST('sortorder', 'aZ09comma'); +$page = GETPOSTISSET('pageplusone') ? (GETPOST('pageplusone') - 1) : GETPOST("page", 'int'); +if (empty($page) || $page < 0 || GETPOST('button_search', 'alpha') || GETPOST('button_removefilter', 'alpha')) { + $page = 0; +} +$offset = $limit * $page; +$pageprev = $page - 1; +$pagenext = $page + 1; + +if (!$sortfield) { + $sortfield = 'i.date_creation'; +} +if (!$sortorder) { + $sortorder = 'DESC'; +} + +// Initialize objects +$object = new ZugferdImport($db); +$form = new Form($db); +$formother = new FormOther($db); + +/* + * Actions + */ + +if (GETPOST('button_removefilter_x', 'alpha') || GETPOST('button_removefilter.x', 'alpha') || GETPOST('button_removefilter', 'alpha')) { + $search_ref = ''; + $search_invoice_number = ''; + $search_seller_name = ''; + $search_status = ''; + $toselect = array(); +} + +/* + * View + */ + +$title = $langs->trans('ImportList'); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-list'); + +// Build SQL query +$sql = "SELECT i.rowid, i.ref, i.invoice_number, i.invoice_date, i.seller_name, i.seller_vat,"; +$sql .= " i.buyer_reference, i.total_ht, i.total_ttc, i.currency, i.fk_soc, i.fk_facture_fourn,"; +$sql .= " i.status, i.error_message, i.date_creation, i.pdf_filename,"; +$sql .= " s.nom as supplier_name"; +$sql .= " FROM ".MAIN_DB_PREFIX."importzugferd_import as i"; +$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON s.rowid = i.fk_soc"; +$sql .= " WHERE i.entity IN (".getEntity('importzugferd_import').")"; + +if (!empty($search_ref)) { + $sql .= natural_search('i.ref', $search_ref); +} +if (!empty($search_invoice_number)) { + $sql .= natural_search('i.invoice_number', $search_invoice_number); +} +if (!empty($search_seller_name)) { + $sql .= natural_search('i.seller_name', $search_seller_name); +} +if ($search_status !== '' && $search_status >= 0) { + $sql .= " AND i.status = ".(int)$search_status; +} + +// Count total +$nbtotalofrecords = ''; +if (!getDolGlobalInt('MAIN_DISABLE_FULL_SCANLIST')) { + $sqlforcount = preg_replace('/^SELECT[^FROM]*FROM/', 'SELECT COUNT(*) as nbtotalofrecords FROM', $sql); + $sqlforcount = preg_replace('/ORDER BY .*$/', '', $sqlforcount); + $resqlforcount = $db->query($sqlforcount); + if ($resqlforcount) { + $objforcount = $db->fetch_object($resqlforcount); + $nbtotalofrecords = $objforcount->nbtotalofrecords; + } + $db->free($resqlforcount); + + if (($page * $limit) > $nbtotalofrecords) { + $page = 0; + $offset = 0; + } +} + +$sql .= $db->order($sortfield, $sortorder); +$sql .= $db->plimit($limit + 1, $offset); + +$resql = $db->query($sql); +if (!$resql) { + dol_print_error($db); + exit; +} + +$num = $db->num_rows($resql); + +// List header +$param = ''; +if (!empty($search_ref)) $param .= '&search_ref='.urlencode($search_ref); +if (!empty($search_invoice_number)) $param .= '&search_invoice_number='.urlencode($search_invoice_number); +if (!empty($search_seller_name)) $param .= '&search_seller_name='.urlencode($search_seller_name); +if ($search_status !== '') $param .= '&search_status='.urlencode($search_status); + +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +$newcardbutton = dolGetButtonTitle($langs->trans('Import'), '', 'fa fa-plus-circle', dol_buildpath('/importzugferd/import.php', 1)); + +print_barre_liste($title, $page, $_SERVER["PHP_SELF"], $param, $sortfield, $sortorder, '', $num, $nbtotalofrecords, 'fa-file-import', 0, $newcardbutton); + +print '
'; +print ''; + +// Header line +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +// Column headers +print ''; +print_liste_field_titre('Ref', $_SERVER["PHP_SELF"], 'i.ref', '', $param, '', $sortfield, $sortorder); +print_liste_field_titre('InvoiceNumber', $_SERVER["PHP_SELF"], 'i.invoice_number', '', $param, '', $sortfield, $sortorder); +print_liste_field_titre('InvoiceDate', $_SERVER["PHP_SELF"], 'i.invoice_date', '', $param, '', $sortfield, $sortorder); +print_liste_field_titre('Supplier', $_SERVER["PHP_SELF"], 'i.seller_name', '', $param, '', $sortfield, $sortorder); +print_liste_field_titre('SupplierInvoice', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder); +print_liste_field_titre('TotalTTC', $_SERVER["PHP_SELF"], 'i.total_ttc', '', $param, '', $sortfield, $sortorder, 'right '); +print_liste_field_titre('Status', $_SERVER["PHP_SELF"], 'i.status', '', $param, '', $sortfield, $sortorder); +print_liste_field_titre('ValidationResult', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder); +print_liste_field_titre('DateCreation', $_SERVER["PHP_SELF"], 'i.date_creation', '', $param, '', $sortfield, $sortorder, 'center '); +print ''; + +// Data rows +$i = 0; +while ($i < min($num, $limit)) { + $obj = $db->fetch_object($resql); + + print ''; + + // Ref + print ''; + + // Invoice number + print ''; + + // Invoice date + print ''; + + // Seller/Supplier + print ''; + + // Supplier invoice + print ''; + + // Total TTC + print ''; + + // Status + print ''; + + // Validation result / Error message + print ''; + + // Date creation + print ''; + + print ''; + + $i++; +} + +if ($num == 0) { + print ''; +} + +$db->free($resql); + +print '
'; +$arrayofstatus = array(0 => $langs->trans('Imported'), 1 => $langs->trans('Processed'), 2 => $langs->trans('Error'), 3 => $langs->trans('StatusPending')); +print $form->selectarray('search_status', $arrayofstatus, $search_status, 1, 0, 0, '', 0, 0, 0, '', 'minwidth75'); +print ''; +print ''; +print ''; +print '
'; + print ''.$obj->ref.''; + print ''.dol_escape_htmltag($obj->invoice_number).''.dol_print_date($db->jdate($obj->invoice_date), 'day').''; + if ($obj->fk_soc > 0) { + $supplier = new Societe($db); + $supplier->fetch($obj->fk_soc); + print $supplier->getNomUrl(1); + } else { + print ''.dol_escape_htmltag($obj->seller_name).''; + } + print ''; + if ($obj->fk_facture_fourn > 0) { + require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php'; + $invoice = new FactureFournisseur($db); + $invoice->fetch($obj->fk_facture_fourn); + print $invoice->getNomUrl(1); + } else { + print '-'; + } + print ''.price($obj->total_ttc).''; + print $object->LibStatut($obj->status, 1); + print ''; + if ($obj->status == 2 && !empty($obj->error_message)) { + // Error status - show error message in red + print ''; + print ''; + print dol_trunc(dol_escape_htmltag($obj->error_message), 40); + print ''; + } elseif ($obj->status == 1) { + // Processed - show OK + print ''; + print ''; + print $langs->trans('SumValidationOk'); + print ''; + } else { + print '-'; + } + print ''.dol_print_date($db->jdate($obj->date_creation), 'dayhour').'
'.$langs->trans("NoRecordFound").'
'; +print '
'; + +print '
'; + +llxFooter(); +$db->close(); diff --git a/mapping.php b/mapping.php new file mode 100755 index 0000000..5416980 --- /dev/null +++ b/mapping.php @@ -0,0 +1,274 @@ + 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../main.inc.php")) { + $res = @include "../main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; + +dol_include_once('/importzugferd/class/productmapping.class.php'); +dol_include_once('/importzugferd/lib/importzugferd.lib.php'); + +// Load translation files +$langs->loadLangs(array("importzugferd@importzugferd", "products", "companies")); + +// Security check +if (!$user->hasRight('importzugferd', 'mapping', 'write')) { + accessforbidden(); +} + +// Get parameters +$action = GETPOST('action', 'aZ09'); +$confirm = GETPOST('confirm', 'alpha'); +$id = GETPOST('id', 'int'); +$supplier_id = GETPOST('supplier_id', 'int'); + +// Form fields +$supplier_ref = GETPOST('supplier_ref', 'alpha'); +$product_id = GETPOST('product_id', 'int'); +$ean = GETPOST('ean', 'alpha'); +$manufacturer_ref = GETPOST('manufacturer_ref', 'alpha'); +$description = GETPOST('description', 'alpha'); +$priority = GETPOST('priority', 'int'); + +// Initialize objects +$mapping = new ProductMapping($db); +$form = new Form($db); + +$error = 0; + +/* + * Actions + */ + +// Add mapping +if ($action == 'add') { + if (empty($supplier_id) || $supplier_id <= 0) { + setEventMessages($langs->trans('ErrorFieldRequired', $langs->transnoentities('Supplier')), null, 'errors'); + $error++; + } + if (empty($supplier_ref)) { + setEventMessages($langs->trans('ErrorFieldRequired', $langs->transnoentities('SupplierRef')), null, 'errors'); + $error++; + } + if (empty($product_id) || $product_id <= 0) { + setEventMessages($langs->trans('ErrorFieldRequired', $langs->transnoentities('Product')), null, 'errors'); + $error++; + } + + if (!$error) { + $mapping->fk_soc = $supplier_id; + $mapping->supplier_ref = $supplier_ref; + $mapping->fk_product = $product_id; + $mapping->ean = $ean; + $mapping->manufacturer_ref = $manufacturer_ref; + $mapping->description = $description; + $mapping->priority = $priority; + + $result = $mapping->create($user); + if ($result > 0) { + setEventMessages($langs->trans('MappingCreated'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?supplier_id='.$supplier_id); + exit; + } else { + setEventMessages($mapping->error, null, 'errors'); + } + } + $action = ''; +} + +// Delete mapping +if ($action == 'confirm_delete' && $confirm == 'yes') { + $mapping->fetch($id); + $save_supplier_id = $mapping->fk_soc; + + $result = $mapping->delete($user); + if ($result > 0) { + setEventMessages($langs->trans('MappingDeleted'), null, 'mesgs'); + header('Location: '.$_SERVER['PHP_SELF'].'?supplier_id='.$save_supplier_id); + exit; + } else { + setEventMessages($mapping->error, null, 'errors'); + } +} + +/* + * View + */ + +$title = $langs->trans('ProductMapping'); +llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-importzugferd page-mapping'); + +print load_fiche_titre($title, '', 'fa-exchange-alt'); + +// Confirm delete +if ($action == 'delete') { + print $form->formconfirm( + $_SERVER['PHP_SELF'].'?id='.$id.'&supplier_id='.$supplier_id, + $langs->trans('DeleteMapping'), + $langs->trans('ConfirmDeleteMapping'), + 'confirm_delete', + '', + 0, + 1 + ); +} + +// Supplier selection +print '
'; +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print '
'.$langs->trans('SelectSupplier').'
'.$langs->trans('Supplier').''; +print $form->select_company($supplier_id, 'supplier_id', 's.fournisseur = 1', 'SelectThirdParty', 0, 0, null, 0, 'minwidth300'); +print ' '; +print '
'; +print '
'; +print '
'; + +// If supplier selected, show mappings and add form +if ($supplier_id > 0) { + $supplier = new Societe($db); + $supplier->fetch($supplier_id); + + print '
'; + + // Add new mapping form + print '
'; + print ''; + print ''; + print ''; + + print '
'; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + print ''; + print ''; + print ''; + + print '
'.$langs->trans('AddMapping').' - '.$supplier->getNomUrl(1).'
'.$langs->trans('SupplierRef').''.$langs->trans('Product').''.$form->select_produits($product_id, 'product_id', '', 0, 0, -1, 2, '', 0, array(), 0, '1', 0, 'minwidth300', 0, '', null, 1).''.$langs->trans('EAN').'
'.$langs->trans('ManufacturerRef').''.$langs->trans('Description').''.$langs->trans('Priority').'
'; + print ''; + print '
'; + print '
'; + print '
'; + + // Existing mappings + print '
'; + print '
'; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + + $mappings = $mapping->fetchAllBySupplier($supplier_id); + + if (count($mappings) > 0) { + foreach ($mappings as $m) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } + } else { + print ''; + } + + print '
'.$langs->trans('SupplierRef').''.$langs->trans('Product').''.$langs->trans('EAN').''.$langs->trans('ManufacturerRef').''.$langs->trans('Description').''.$langs->trans('Priority').''.$langs->trans('Active').''.$langs->trans('Action').'
'.dol_escape_htmltag($m['supplier_ref']).''; + $product = new Product($db); + $product->fetch($m['fk_product']); + print $product->getNomUrl(1); + print ''.dol_escape_htmltag($m['ean']).''.dol_escape_htmltag($m['manufacturer_ref']).''.dol_escape_htmltag($m['description']).''.$m['priority'].''; + print $m['active'] ? img_picto($langs->trans('Active'), 'statut4') : img_picto($langs->trans('Inactive'), 'statut5'); + print ''; + print ''; + print img_picto($langs->trans('Delete'), 'delete'); + print ''; + print '
'.$langs->trans('NoMappingsFound').'
'; + print '
'; +} + +llxFooter(); +$db->close(); diff --git a/modulebuilder.txt b/modulebuilder.txt new file mode 100755 index 0000000..670a177 --- /dev/null +++ b/modulebuilder.txt @@ -0,0 +1,3 @@ +# DO NOT DELETE THIS FILE MANUALLY +# File to flag module built using official module template. +# When this file is present into a module directory, you can edit it with the module builder tool. \ No newline at end of file diff --git a/new_products.php b/new_products.php new file mode 100755 index 0000000..4bfd86c --- /dev/null +++ b/new_products.php @@ -0,0 +1,291 @@ + + * + * 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. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +/** + * \file new_products.php + * \ingroup importzugferd + * \brief List of products starting with "New" that need review after import + */ + +// Load Dolibarr environment +require '../../main.inc.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/product.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formcompany.class.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php'; +require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; + +// Load translation files +$langs->loadLangs(array('products', 'stocks', 'importzugferd@importzugferd')); + +// Security check +if (!$user->hasRight('produit', 'lire')) { + accessforbidden(); +} + +// Get parameters +$action = GETPOST('action', 'aZ09'); +$massaction = GETPOST('massaction', 'alpha'); +$confirm = GETPOST('confirm', 'alpha'); +$toselect = GETPOST('toselect', 'array'); +$optioncss = GETPOST('optioncss', 'alpha'); + +// Search Criteria +$search_ref = GETPOST("search_ref", 'alpha'); +$search_label = GETPOST("search_label", 'alpha'); +$search_tosell = GETPOST("search_tosell"); +$search_tobuy = GETPOST("search_tobuy"); + +// Load variable for pagination +$limit = GETPOSTINT('limit') ? GETPOSTINT('limit') : $conf->liste_limit; +$sortfield = GETPOST('sortfield', 'aZ09comma'); +$sortorder = GETPOST('sortorder', 'aZ09comma'); +$page = GETPOSTISSET('pageplusone') ? (GETPOSTINT('pageplusone') - 1) : GETPOSTINT("page"); +if (empty($page) || $page < 0 || GETPOST('button_search', 'alpha') || GETPOST('button_removefilter', 'alpha')) { + $page = 0; +} +$offset = $limit * $page; +$pageprev = $page - 1; +$pagenext = $page + 1; +if (!$sortfield) { + $sortfield = "p.datec"; +} +if (!$sortorder) { + $sortorder = "DESC"; +} + +// Initialize objects +$object = new Product($db); +$form = new Form($db); + +$arrayfields = array( + 'p.ref' => array('label' => 'Ref', 'checked' => 1, 'position' => 10), + 'p.label' => array('label' => 'Label', 'checked' => 1, 'position' => 20), + 'p.fk_product_type' => array('label' => 'Type', 'checked' => 1, 'position' => 30), + 'p.price' => array('label' => 'SellingPrice', 'checked' => 1, 'position' => 40), + 'p.price_ttc' => array('label' => 'SellingPriceTTC', 'checked' => 0, 'position' => 41), + 'p.tosell' => array('label' => 'OnSell', 'checked' => 1, 'position' => 50), + 'p.tobuy' => array('label' => 'OnBuy', 'checked' => 1, 'position' => 60), + 'p.datec' => array('label' => 'DateCreation', 'checked' => 1, 'position' => 70), +); + +/* + * Actions + */ + +if (GETPOST('button_removefilter_x', 'alpha') || GETPOST('button_removefilter.x', 'alpha') || GETPOST('button_removefilter', 'alpha')) { + $search_ref = ''; + $search_label = ''; + $search_tosell = ''; + $search_tobuy = ''; + $toselect = array(); + $search_array_options = array(); +} + +/* + * View + */ + +$title = $langs->trans('NewProductsToReview'); +$help_url = ''; + +llxHeader('', $title, $help_url); + +// Build SQL query +$sql = "SELECT p.rowid, p.ref, p.label, p.fk_product_type, p.entity,"; +$sql .= " p.price, p.price_ttc, p.price_base_type, p.tva_tx,"; +$sql .= " p.tosell, p.tobuy, p.datec, p.tms"; +$sql .= " FROM ".MAIN_DB_PREFIX."product as p"; +$sql .= " WHERE p.entity IN (".getEntity('product').")"; +$sql .= " AND p.ref LIKE 'New%'"; + +// Add search filters +if ($search_ref) { + $sql .= natural_search('p.ref', $search_ref); +} +if ($search_label) { + $sql .= natural_search('p.label', $search_label); +} +if ($search_tosell != '' && $search_tosell >= 0) { + $sql .= " AND p.tosell = ".((int) $search_tosell); +} +if ($search_tobuy != '' && $search_tobuy >= 0) { + $sql .= " AND p.tobuy = ".((int) $search_tobuy); +} + +// Count total +$sqlcount = preg_replace('/^SELECT[^F]*FROM/', 'SELECT COUNT(*) as nbtotalofrecords FROM', $sql); +$sqlcount = preg_replace('/ORDER BY.*$/', '', $sqlcount); +$resqlcount = $db->query($sqlcount); +$nbtotalofrecords = 0; +if ($resqlcount) { + $objcount = $db->fetch_object($resqlcount); + $nbtotalofrecords = $objcount->nbtotalofrecords; +} + +// Add sorting +$sql .= $db->order($sortfield, $sortorder); +$sql .= $db->plimit($limit + 1, $offset); + +$resql = $db->query($sql); +if (!$resql) { + dol_print_error($db); + exit; +} + +$num = $db->num_rows($resql); + +$param = ''; +if ($search_ref) { + $param .= '&search_ref='.urlencode($search_ref); +} +if ($search_label) { + $param .= '&search_label='.urlencode($search_label); +} +if ($search_tosell != '') { + $param .= '&search_tosell='.urlencode($search_tosell); +} +if ($search_tobuy != '') { + $param .= '&search_tobuy='.urlencode($search_tobuy); +} +if ($limit > 0 && $limit != $conf->liste_limit) { + $param .= '&limit='.((int) $limit); +} + +// List header +print '
'; +print ''; +print ''; +print ''; +print ''; + +print_barre_liste($title, $page, $_SERVER["PHP_SELF"], $param, $sortfield, $sortorder, '', $num, $nbtotalofrecords, 'product', 0, '', '', $limit, 0, 0, 1); + +// Info box +print '
'; +print $langs->trans('NewProductsToReviewDesc', 'New'); +print '

'; + +print '
'; +print ''; + +// Header row with search fields +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +// Column headers +print ''; +print_liste_field_titre('Ref', $_SERVER["PHP_SELF"], 'p.ref', '', $param, '', $sortfield, $sortorder); +print_liste_field_titre('Label', $_SERVER["PHP_SELF"], 'p.label', '', $param, '', $sortfield, $sortorder); +print_liste_field_titre('Type', $_SERVER["PHP_SELF"], 'p.fk_product_type', '', $param, '', $sortfield, $sortorder); +print_liste_field_titre('SellingPrice', $_SERVER["PHP_SELF"], 'p.price', '', $param, '', $sortfield, $sortorder, 'right '); +print_liste_field_titre('OnSell', $_SERVER["PHP_SELF"], 'p.tosell', '', $param, '', $sortfield, $sortorder, 'center '); +print_liste_field_titre('OnBuy', $_SERVER["PHP_SELF"], 'p.tobuy', '', $param, '', $sortfield, $sortorder, 'center '); +print_liste_field_titre('DateCreation', $_SERVER["PHP_SELF"], 'p.datec', '', $param, '', $sortfield, $sortorder, 'center '); +print_liste_field_titre('', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder, 'center '); +print ''; + +// Data rows +$i = 0; +while ($i < min($num, $limit)) { + $obj = $db->fetch_object($resql); + if (!$obj) { + break; + } + + $product_static = new Product($db); + $product_static->id = $obj->rowid; + $product_static->ref = $obj->ref; + $product_static->label = $obj->label; + $product_static->type = $obj->fk_product_type; + $product_static->entity = $obj->entity; + $product_static->status = $obj->tosell; + $product_static->status_buy = $obj->tobuy; + + print ''; + + // Ref + print ''; + + // Label + print ''; + + // Type + print ''; + + // Price + print ''; + + // On sell + print ''; + + // On buy + print ''; + + // Date creation + print ''; + + // Action column + print ''; + + print ''; + $i++; +} + +if ($num == 0) { + print ''; +} + +print '
'.$form->selectyesno('search_tosell', $search_tosell, 1, false, 1, 1).''.$form->selectyesno('search_tobuy', $search_tobuy, 1, false, 1, 1).''; +print ''; +print ''; +print '
'; + print $product_static->getNomUrl(1); + print ''.dol_escape_htmltag($obj->label).''; + if ($obj->fk_product_type == 0) { + print $langs->trans('Product'); + } else { + print $langs->trans('Service'); + } + print ''; + if ($obj->price_base_type == 'TTC') { + print ''.price($obj->price_ttc).''; + } else { + print ''.price($obj->price).''; + } + print ''; + print $product_static->LibStatut($obj->tosell, 5, 0); + print ''; + print $product_static->LibStatut($obj->tobuy, 5, 1); + print ''; + print dol_print_date($db->jdate($obj->datec), 'dayhour'); + print ''; + print ''; + print img_picto($langs->trans('Edit'), 'edit'); + print ''; + print '
'.$langs->trans("NoRecordFound").'
'; +print '
'; +print '
'; + +// End of page +llxFooter(); +$db->close(); diff --git a/sql/dolibarr_allversions.sql b/sql/dolibarr_allversions.sql new file mode 100755 index 0000000..fa4f112 --- /dev/null +++ b/sql/dolibarr_allversions.sql @@ -0,0 +1,19 @@ +-- +-- Script run when an upgrade of Dolibarr is done. Whatever is the Dolibarr version. +-- + +-- Add copper surcharge fields to import_line table (v2.8) +ALTER TABLE llx_importzugferd_import_line ADD COLUMN copper_surcharge double(24,8) DEFAULT NULL AFTER ean; +ALTER TABLE llx_importzugferd_import_line ADD COLUMN copper_surcharge_basis_qty double(24,8) DEFAULT NULL AFTER copper_surcharge; + +-- Add fk_datanorm field to import_line table (v2.9) +ALTER TABLE llx_importzugferd_import_line ADD COLUMN fk_datanorm integer DEFAULT NULL AFTER fk_product; + +-- Add missing datanorm fields (v3.0) +ALTER TABLE llx_importzugferd_datanorm ADD COLUMN price_unit_code tinyint DEFAULT 0 AFTER price_unit; +ALTER TABLE llx_importzugferd_datanorm ADD COLUMN price_type tinyint DEFAULT 1 AFTER price_unit_code; +ALTER TABLE llx_importzugferd_datanorm ADD COLUMN metal_surcharge double(24,8) DEFAULT 0 AFTER price_type; +ALTER TABLE llx_importzugferd_datanorm ADD COLUMN vpe integer DEFAULT NULL AFTER metal_surcharge; +ALTER TABLE llx_importzugferd_datanorm ADD COLUMN action_code char(1) DEFAULT 'N' AFTER datanorm_version; + +-- Note: kaufmenge extrafield is created programmatically in modImportZugferd.class.php init() diff --git a/sql/llx_importzugferd_datanorm.key.sql b/sql/llx_importzugferd_datanorm.key.sql new file mode 100755 index 0000000..fd1a842 --- /dev/null +++ b/sql/llx_importzugferd_datanorm.key.sql @@ -0,0 +1,13 @@ +-- ============================================================================ +-- Copyright (C) 2026 ZUGFeRD Import Module +-- ============================================================================ + +ALTER TABLE llx_importzugferd_datanorm ADD INDEX idx_datanorm_fk_soc (fk_soc); +ALTER TABLE llx_importzugferd_datanorm ADD INDEX idx_datanorm_article_number (article_number); +ALTER TABLE llx_importzugferd_datanorm ADD INDEX idx_datanorm_ean (ean); +ALTER TABLE llx_importzugferd_datanorm ADD INDEX idx_datanorm_manufacturer_ref (manufacturer_ref); +ALTER TABLE llx_importzugferd_datanorm ADD INDEX idx_datanorm_matchcode (matchcode); +ALTER TABLE llx_importzugferd_datanorm ADD UNIQUE INDEX uk_datanorm_soc_article (fk_soc, article_number, entity); + +ALTER TABLE llx_importzugferd_datanorm ADD CONSTRAINT fk_datanorm_fk_soc FOREIGN KEY (fk_soc) REFERENCES llx_societe(rowid); +ALTER TABLE llx_importzugferd_datanorm ADD CONSTRAINT fk_datanorm_fk_user_creat FOREIGN KEY (fk_user_creat) REFERENCES llx_user(rowid); diff --git a/sql/llx_importzugferd_datanorm.sql b/sql/llx_importzugferd_datanorm.sql new file mode 100755 index 0000000..e926b1f --- /dev/null +++ b/sql/llx_importzugferd_datanorm.sql @@ -0,0 +1,39 @@ +-- ============================================================================ +-- Copyright (C) 2026 ZUGFeRD Import Module +-- +-- Datanorm-Artikeltabelle: Importierte Artikeldaten aus Datanorm-Dateien +-- ============================================================================ + +CREATE TABLE llx_importzugferd_datanorm ( + rowid integer AUTO_INCREMENT PRIMARY KEY NOT NULL, + fk_soc integer NOT NULL, -- Lieferant + article_number varchar(128) NOT NULL, -- Artikelnummer (Typ A Feld 2) + short_text1 varchar(255), -- Kurztext 1 (Typ A Feld 4) + short_text2 varchar(255), -- Kurztext 2 (Typ A Feld 5) + long_text text, -- Langtext (Typ B) + ean varchar(32), -- EAN/GTIN (Typ A Feld 17) + manufacturer_ref varchar(128), -- Hersteller-Artikelnummer (Typ A Feld 15) + manufacturer_name varchar(128), -- Herstellername (Typ A Feld 16) + unit_code varchar(8), -- Mengeneinheit (Typ A Feld 6) + price double(24,8) DEFAULT 0, -- Listenpreis/Materialpreis (Typ P) + price_unit integer DEFAULT 1, -- Preiseinheit (Stück pro Preis) - konvertiert aus PE-Code + price_unit_code tinyint DEFAULT 0, -- Original PE-Code (0=1, 1=10, 2=100, 3=1000) + price_type tinyint DEFAULT 1, -- Preiskennzeichen (1=Brutto, 2=Netto) + metal_surcharge double(24,8) DEFAULT 0, -- Metallzuschlag/Kupferzuschlag (Typ P) + vpe integer DEFAULT NULL, -- VPE aus B-Satz (Verpackungseinheit) + discount_group varchar(32), -- Rabattgruppe (Typ A Feld 8) + product_group varchar(64), -- Warengruppe (Typ A Feld 9) + alt_unit varchar(8), -- Alternative Mengeneinheit + alt_unit_factor double(10,4) DEFAULT 1, -- Umrechnungsfaktor + weight double(10,4), -- Gewicht in kg + matchcode varchar(128), -- Matchcode für Suche (Typ A Feld 3) + datanorm_version varchar(8), -- Datanorm Version (4.0, 5.0) + action_code char(1) DEFAULT 'N', -- Aktionscode (N=Neu, A=Ändern, L=Löschen) + import_date datetime NOT NULL, -- Importzeitpunkt + active tinyint DEFAULT 1, -- Aktiv/Inaktiv (0 bei action_code='L') + date_creation datetime NOT NULL, + tms timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + fk_user_creat integer, + fk_user_modif integer, + entity integer DEFAULT 1 NOT NULL +) ENGINE=innodb; diff --git a/sql/llx_importzugferd_datanorm_log.key.sql b/sql/llx_importzugferd_datanorm_log.key.sql new file mode 100755 index 0000000..abb01fb --- /dev/null +++ b/sql/llx_importzugferd_datanorm_log.key.sql @@ -0,0 +1,8 @@ +-- 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. + +-- No additional keys needed, all defined in main SQL file diff --git a/sql/llx_importzugferd_datanorm_log.sql b/sql/llx_importzugferd_datanorm_log.sql new file mode 100755 index 0000000..c5d7b59 --- /dev/null +++ b/sql/llx_importzugferd_datanorm_log.sql @@ -0,0 +1,25 @@ +-- 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. + +CREATE TABLE llx_importzugferd_datanorm_log ( + rowid integer AUTO_INCREMENT PRIMARY KEY, + fk_product integer NOT NULL, + fk_soc integer NOT NULL, + fk_user integer NOT NULL, + datanorm_ref varchar(100), + field_changed varchar(50) NOT NULL, + old_value text, + new_value text, + date_change datetime NOT NULL, + batch_id varchar(50), + entity integer DEFAULT 1 NOT NULL +) ENGINE=innodb; + +ALTER TABLE llx_importzugferd_datanorm_log ADD INDEX idx_datanorm_log_product (fk_product); +ALTER TABLE llx_importzugferd_datanorm_log ADD INDEX idx_datanorm_log_soc (fk_soc); +ALTER TABLE llx_importzugferd_datanorm_log ADD INDEX idx_datanorm_log_date (date_change); +ALTER TABLE llx_importzugferd_datanorm_log ADD INDEX idx_datanorm_log_batch (batch_id); diff --git a/sql/llx_importzugferd_import.key.sql b/sql/llx_importzugferd_import.key.sql new file mode 100755 index 0000000..86c7ee0 --- /dev/null +++ b/sql/llx_importzugferd_import.key.sql @@ -0,0 +1,14 @@ +-- ============================================================================ +-- Copyright (C) 2026 ZUGFeRD Import Module +-- ============================================================================ + +ALTER TABLE llx_importzugferd_import ADD INDEX idx_importzugferd_import_ref (ref); +ALTER TABLE llx_importzugferd_import ADD INDEX idx_importzugferd_import_invoice (invoice_number); +ALTER TABLE llx_importzugferd_import ADD UNIQUE INDEX uk_importzugferd_import_hash (file_hash, entity); +ALTER TABLE llx_importzugferd_import ADD INDEX idx_importzugferd_import_fk_soc (fk_soc); +ALTER TABLE llx_importzugferd_import ADD INDEX idx_importzugferd_import_fk_facture (fk_facture_fourn); +ALTER TABLE llx_importzugferd_import ADD INDEX idx_importzugferd_import_status (status); +ALTER TABLE llx_importzugferd_import ADD INDEX idx_importzugferd_import_buyer_ref (buyer_reference); + +ALTER TABLE llx_importzugferd_import ADD CONSTRAINT fk_importzugferd_import_fk_soc FOREIGN KEY (fk_soc) REFERENCES llx_societe(rowid); +ALTER TABLE llx_importzugferd_import ADD CONSTRAINT fk_importzugferd_import_fk_user_creat FOREIGN KEY (fk_user_creat) REFERENCES llx_user(rowid); diff --git a/sql/llx_importzugferd_import.sql b/sql/llx_importzugferd_import.sql new file mode 100755 index 0000000..5df706e --- /dev/null +++ b/sql/llx_importzugferd_import.sql @@ -0,0 +1,40 @@ +-- ============================================================================ +-- 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. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU General Public License for more details. +-- ============================================================================ + +CREATE TABLE llx_importzugferd_import ( + rowid integer AUTO_INCREMENT PRIMARY KEY NOT NULL, + ref varchar(128) NOT NULL, -- Interne Referenz + invoice_number varchar(128) NOT NULL, -- Rechnungsnummer aus ZUGFeRD + invoice_date date NOT NULL, -- Rechnungsdatum + seller_name varchar(255), -- Lieferantenname aus Rechnung + seller_vat varchar(50), -- USt-ID Lieferant + buyer_reference varchar(128), -- Kundennummer beim Lieferanten + total_ht double(24,8) DEFAULT 0, -- Nettobetrag + total_ttc double(24,8) DEFAULT 0, -- Bruttobetrag + currency varchar(3) DEFAULT 'EUR', -- Währung + fk_soc integer, -- Zugeordneter Lieferant + fk_facture_fourn integer, -- Erstellte Lieferantenrechnung + xml_content mediumtext, -- Original XML-Inhalt + pdf_filename varchar(255), -- Original PDF-Dateiname + file_hash varchar(64), -- SHA256 Hash für Duplikatserkennung + status integer DEFAULT 0, -- 0=importiert, 1=verarbeitet, 2=fehler + error_message text, -- Fehlermeldung falls status=2 + date_creation datetime NOT NULL, -- Erstellungsdatum + date_import datetime, -- Importdatum der Rechnung + tms timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + fk_user_creat integer, -- Ersteller + fk_user_modif integer, -- Letzter Bearbeiter + import_key varchar(14), -- Import-Batch-Key + entity integer DEFAULT 1 NOT NULL -- Multi-company +) ENGINE=innodb; diff --git a/sql/llx_importzugferd_import_line.key.sql b/sql/llx_importzugferd_import_line.key.sql new file mode 100755 index 0000000..ed8d770 --- /dev/null +++ b/sql/llx_importzugferd_import_line.key.sql @@ -0,0 +1,7 @@ +-- ============================================================================ +-- Copyright (C) 2026 ZUGFeRD Import Module +-- ============================================================================ + +ALTER TABLE llx_importzugferd_import_line ADD INDEX idx_importzugferd_import_line_fk_import (fk_import); +ALTER TABLE llx_importzugferd_import_line ADD INDEX idx_importzugferd_import_line_fk_product (fk_product); +ALTER TABLE llx_importzugferd_import_line ADD CONSTRAINT fk_importzugferd_import_line_import FOREIGN KEY (fk_import) REFERENCES llx_importzugferd_import (rowid); diff --git a/sql/llx_importzugferd_import_line.sql b/sql/llx_importzugferd_import_line.sql new file mode 100755 index 0000000..f46c097 --- /dev/null +++ b/sql/llx_importzugferd_import_line.sql @@ -0,0 +1,38 @@ +-- ============================================================================ +-- 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. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU General Public License for more details. +-- ============================================================================ + +CREATE TABLE llx_importzugferd_import_line ( + rowid integer AUTO_INCREMENT PRIMARY KEY NOT NULL, + fk_import integer NOT NULL, -- Referenz zum Import + line_id varchar(50), -- Position/Zeilen-ID aus ZUGFeRD + supplier_ref varchar(128), -- Lieferanten-Artikelnummer + product_name varchar(255), -- Artikelbezeichnung aus ZUGFeRD + description text, -- Zusätzliche Beschreibung + quantity double(24,8) DEFAULT 1, -- Menge + unit_code varchar(10), -- UN/ECE Einheitencode (C62, MTR, etc.) + unit_price double(24,8) DEFAULT 0, -- Einzelpreis (berechnet) + unit_price_raw double(24,8) DEFAULT 0, -- Original-Einzelpreis + basis_quantity double(24,8) DEFAULT 1, -- Basismenge für Preis + basis_quantity_unit varchar(10), -- Einheit der Basismenge + line_total double(24,8) DEFAULT 0, -- Zeilensumme netto + tax_percent double(24,8) DEFAULT 0, -- MwSt-Satz + ean varchar(20), -- EAN/GTIN falls vorhanden + copper_surcharge double(24,8) DEFAULT NULL, -- Kupferzuschlag pro Einheit + copper_surcharge_basis_qty double(24,8) DEFAULT NULL, -- Basismenge für Kupferzuschlag + fk_product integer, -- Zugeordnetes Dolibarr-Produkt + fk_datanorm integer, -- Zugeordneter Datanorm-Artikel + match_method varchar(50), -- Wie wurde Produkt gefunden + date_creation datetime NOT NULL, + tms timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=innodb; diff --git a/sql/llx_importzugferd_productmapping.key.sql b/sql/llx_importzugferd_productmapping.key.sql new file mode 100755 index 0000000..7be7ebd --- /dev/null +++ b/sql/llx_importzugferd_productmapping.key.sql @@ -0,0 +1,13 @@ +-- ============================================================================ +-- Copyright (C) 2026 ZUGFeRD Import Module +-- ============================================================================ + +ALTER TABLE llx_importzugferd_productmapping ADD INDEX idx_productmapping_fk_soc (fk_soc); +ALTER TABLE llx_importzugferd_productmapping ADD INDEX idx_productmapping_fk_product (fk_product); +ALTER TABLE llx_importzugferd_productmapping ADD INDEX idx_productmapping_supplier_ref (supplier_ref); +ALTER TABLE llx_importzugferd_productmapping ADD INDEX idx_productmapping_ean (ean); +ALTER TABLE llx_importzugferd_productmapping ADD UNIQUE INDEX uk_productmapping_soc_ref (fk_soc, supplier_ref, entity); + +ALTER TABLE llx_importzugferd_productmapping ADD CONSTRAINT fk_productmapping_fk_soc FOREIGN KEY (fk_soc) REFERENCES llx_societe(rowid); +ALTER TABLE llx_importzugferd_productmapping ADD CONSTRAINT fk_productmapping_fk_product FOREIGN KEY (fk_product) REFERENCES llx_product(rowid); +ALTER TABLE llx_importzugferd_productmapping ADD CONSTRAINT fk_productmapping_fk_user_creat FOREIGN KEY (fk_user_creat) REFERENCES llx_user(rowid); diff --git a/sql/llx_importzugferd_productmapping.sql b/sql/llx_importzugferd_productmapping.sql new file mode 100755 index 0000000..fb75d4d --- /dev/null +++ b/sql/llx_importzugferd_productmapping.sql @@ -0,0 +1,22 @@ +-- ============================================================================ +-- Copyright (C) 2026 ZUGFeRD Import Module +-- +-- Artikelmapping-Tabelle: Zuordnung Lieferanten-Artikelnummern zu Produkten +-- ============================================================================ + +CREATE TABLE llx_importzugferd_productmapping ( + rowid integer AUTO_INCREMENT PRIMARY KEY NOT NULL, + fk_soc integer NOT NULL, -- Lieferant + supplier_ref varchar(128) NOT NULL, -- Lieferanten-Artikelnummer (SellerAssignedID) + fk_product integer NOT NULL, -- Dolibarr Produkt + ean varchar(32), -- EAN/GTIN (GlobalID) + manufacturer_ref varchar(128), -- Hersteller-Artikelnummer + description varchar(255), -- Optionale Beschreibung + priority integer DEFAULT 0, -- Priorität bei mehreren Mappings + active tinyint DEFAULT 1, -- Aktiv/Inaktiv + date_creation datetime NOT NULL, + tms timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + fk_user_creat integer, + fk_user_modif integer, + entity integer DEFAULT 1 NOT NULL +) ENGINE=innodb;