From 9077dc27c713758d631fe6d08c548613d4d2dde8 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Jan=20B=C3=BCren?= Date: Tue, 10 Mar 2020 12:24:48 +0100 Subject: [PATCH] ZUGFeRD: ZUGFeRD-Controller der minimal ZUGFeRD PDF parst MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Simples Upload Formular für ein PDF. Falls das PDF gültige ZUGFeRD Daten hat und ein Lieferant mit UST-ID in kivi gefunden wird, werden die Formulardaten in der ersten gefunden Kreditorenbelegvorlage des Lieferanten angezeigt. Details und TODO, s.a. perldoc --- SL/Controller/ZUGFeRD.pm | 193 +++++++++++++++++++++++++++ locale/de/all | 9 ++ menus/user/00-erp.yaml | 8 ++ templates/webpages/zugferd/form.html | 15 +++ 4 files changed, 225 insertions(+) create mode 100644 SL/Controller/ZUGFeRD.pm create mode 100644 templates/webpages/zugferd/form.html diff --git a/SL/Controller/ZUGFeRD.pm b/SL/Controller/ZUGFeRD.pm new file mode 100644 index 000000000..f03b51789 --- /dev/null +++ b/SL/Controller/ZUGFeRD.pm @@ -0,0 +1,193 @@ +package SL::Controller::ZUGFeRD; +use strict; +use parent qw(SL::Controller::Base); + +use SL::DB::RecordTemplate; +use SL::Locale::String qw(t8); +use SL::Helper::DateTime; +use SL::ZUGFeRD; + +use XML::LibXML; + + +__PACKAGE__->run_before('check_auth'); + +sub action_upload_zugferd { + my ($self, %params) = @_; + + $self->setup_zugferd_action_bar; + $self->render('zugferd/form', title => $::locale->text('ZUGFeRD import')); +} + +sub action_import_zugferd { + my ($self, %params) = @_; + + die t8("missing file for action import") unless $::form->{file}; + die t8("can only parse a pdf file") unless $::form->{file} =~ m/^%PDF/; + + my $info = SL::ZUGFeRD->extract_from_pdf($::form->{file}); + + if ($info->{result} != SL::ZUGFeRD::RES_OK()) { + # An error occurred; log message from parser: + $::lxdebug->message(LXDebug::DEBUG1(), "Could not extract ZUGFeRD data, error message: " . $info->{message}); + die t8("Could not extract ZUGFeRD data, data and error message:") . $info->{message}; + } + # valid ZUGFeRD metadata + my $dom = XML::LibXML->load_xml(string => $info->{invoice_xml}); + + # 1. check if ZUGFeRD SellerTradeParty has a VAT-ID + my $ustid = $dom->findnodes('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration')->string_value; + die t8("No VAT Info for this ZUGFeRD invoice," . + " please ask your vendor to add this for his ZUGFeRD data.") unless $ustid; + + $ustid =~ s/^\s+|\s+$//g; + + # 1.1 check if we a have a vendor with this VAT-ID (vendor.ustid) + my $vc = $dom->findnodes('//ram:SellerTradeParty/ram:Name')->string_value; + my $vendor = SL::DB::Manager::Vendor->find_by(ustid => $ustid); + die t8("Please add a valid VAT-ID for this vendor: " . $vc) unless (ref $vendor eq 'SL::DB::Vendor'); + + # 2. check if we have a ap record template for this vendor (TODO only the oldest template is choosen) + my $template_ap = SL::DB::Manager::RecordTemplate->get_first(where => [vendor_id => $vendor->id]); + die t8("No AP Record Template for this vendor found, please add one") unless (ref $template_ap eq 'SL::DB::RecordTemplate'); + + + # 3. parse the zugferd data and fill the ap record template + # -> no need to check sign (credit notes will be negative) just record thei ZUGFeRD type in ap.notes + # -> check direct debit (defaults to no) + # -> set amount (net amount) and unset taxincluded + # (template and user cares for tax and if there is more than one booking accno) + # -> date (can be empty) + # -> duedate (may be empty) + # -> compare record iban and generate a warning if this differs from vendor's master data iban + my $total = $dom->findnodes('//ram:SpecifiedTradeSettlementHeaderMonetarySummation' . + '/ram:TaxBasisTotalAmount')->string_value; + + my $invnumber = $dom->findnodes('//rsm:ExchangedDocument/ram:ID')->string_value; + + # parse dates to kivi if set/valid + my ($transdate, $duedate, $dt_to_kivi, $due_dt_to_kivi); + $transdate = $dom->findnodes('//ram:IssueDateTime')->string_value; + $duedate = $dom->findnodes('//ram:DueDateDateTime')->string_value; + $transdate =~ s/^\s+|\s+$//g; + $duedate =~ s/^\s+|\s+$//g; + + if ($transdate =~ /^[0-9]{8}$/) { + $dt_to_kivi = DateTime->new(year => substr($transdate,0,4), + month => substr ($transdate,4,2), + day => substr($transdate,6,2))->to_kivitendo; + } + if ($duedate =~ /^[0-9]{8}$/) { + $due_dt_to_kivi = DateTime->new(year => substr($duedate,0,4), + month => substr ($duedate,4,2), + day => substr($duedate,6,2))->to_kivitendo; + } + + my $type = $dom->findnodes('//rsm:ExchangedDocument/ram:TypeCode')->string_value; + + my $dd = $dom->findnodes('//ram:ApplicableHeaderTradeSettlement' . + '/ram:SpecifiedTradeSettlementPaymentMeans/ram:TypeCode')->string_value; + my $direct_debit = $dd == 59 ? 1 : 0; + + my $iban = $dom->findnodes('//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementPaymentMeans' . + '/ram:PayeePartyCreditorFinancialAccount/ram:IBANID')->string_value; + my $ibanmessage; + $ibanmessage = $iban ne $vendor->iban ? "Record IBAN $iban doesn't match vendor IBAN " . $vendor->iban : $iban if $iban; + + my $url = $self->url_for( + controller => 'ap.pl', + action => 'load_record_template', + id => $template_ap->id, + 'form_defaults.amount_1' => $::form->format_amount(\%::myconfig, $total, 2), + 'form_defaults.transdate' => $dt_to_kivi, + 'form_defaults.invnumber' => $invnumber, + 'form_defaults.duedate' => $due_dt_to_kivi, + 'form_defaults.no_payment_bookings' => 0, + 'form_defaults.paid_1_suggestion' => $::form->format_amount(\%::myconfig, $total, 2), + 'form_defaults.notes' => "ZUGFeRD Import. Type: $type\nIBAN: " . $ibanmessage, + 'form_defaults.taxincluded' => 0, + 'form_defaults.direct_debit' => $direct_debit, + ); + + $self->redirect_to($url); + +} + +sub check_auth { + $::auth->assert('ap_transactions'); +} +sub setup_zugferd_action_bar { + my ($self) = @_; + + for my $bar ($::request->layout->get('actionbar')) { + $bar->add( + action => [ + $::locale->text('Import'), + submit => [ '#form', { action => 'ZUGFeRD/import_zugferd' } ], + accesskey => 'enter', + ], + ); + } +} + + +1; +__END__ + +=pod + +=encoding utf8 + +=head1 NAME + +SL::Controller::ZUGFeRD +Controller for importing ZUGFeRD pdf files to kivitendo + +=head1 FUNCTIONS + +=over 4 + +=item C + +Creates a web from with a single upload dialog. + +=item C + +Expects a single pdf with ZUGFeRD 2.0 metadata. +Checks if the param is set and a valid pdf file. +Calls helper functions to validate and extract the ZUGFeRD data. +Needs a valid VAT ID (EU) for this vendor and +expects one ap template for this vendor in kivitendo. + +Parses some basic ZUGFeRD data (invnumber, total net amount, +transdate, duedate, vendor VAT ID, IBAN) and uses the first +found ap template for this vendor to fill this template with +ZUGFeRD data. +If the vendor's master data contain a IBAN and the +ZUGFeRD record has a IBAN also these values will be compared. +If they don't match a warning will be writte in ap.notes. +Furthermore the ZUGFeRD type code will be written to ap.notes. +No callback implemented. + +=back + +=head1 TODO and CAVEAT + +This is just a very basic Parser for ZUGFeRD data. +We assume that the ZUGFeRD generator is a company with a +valid European VAT ID. Furthermore this vendor needs only +one and just noe ap template (the first match will be used). + +The ZUGFeRD data should also be extracted in the helper package +and maybe a model should be used for this. +The user should set one ap template as a default for ZUGFeRD. +The ZUGFeRD pdf should be written to WebDAV or DMS. +If the ZUGFeRD data has a payment purpose set, this should +be the default for the SEPA-XML export. + + +=head1 AUTHOR + +Jan Büren Ejan@kivitendo-premium.deE, + +=cut diff --git a/locale/de/all b/locale/de/all index 72f7aa969..675fa1ffb 100755 --- a/locale/de/all +++ b/locale/de/all @@ -690,6 +690,7 @@ $self->{texts} = { 'Correct taxkey' => 'Richtiger Steuerschlüssel', 'Cost Center' => 'Kostenstelle', 'Costs' => 'Kosten', + 'Could not extract ZUGFeRD data, data and error message:' => 'Konnte keine ZUGFeRD Daten extrahieren, folgende Fehlermeldung und das PDF:', 'Could not find an entry for this part in the pricegroup.' => 'Konnte keine Eintrag für diesen Artikel in der Preisgruppe finden.', 'Could not load class #1 (#2): "#3"' => 'Konnte Klasse #1 (#2) nicht laden: "#3"', 'Could not load class #1, #2' => 'Konnte Klasse #1 nicht laden: "#2"', @@ -1626,6 +1627,7 @@ $self->{texts} = { 'Import CSV' => 'CSV-Import', 'Import Status' => 'Import Status', 'Import a MT940 file:' => 'Laden Sie eine MT940 Datei hoch:', + 'Import a ZUGFeRD file:' => 'Eine ZUGFeRD-Datei importieren', 'Import all' => 'Importiere Alle', 'Import documents from #1' => 'Importiere Dateien von Quelle \'#1\'', 'Import file' => 'Import-Datei', @@ -1990,6 +1992,7 @@ $self->{texts} = { 'Next run at' => 'Nächste Ausführung um', 'No' => 'Nein', 'No 1:n or n:1 relation' => 'Keine 1:n oder n:1 Beziehung', + 'No AP Record Template for this vendor found, please add one' => 'Konnte keine Kreditorenbuchungsvorlage für diesen Lieferanten finden, bitte legen Sie eine an.', 'No AP template was found.' => 'Keine Kreditorenbuchungsvorlage gefunden.', 'No Company Address given' => 'Keine Firmenadresse hinterlegt!', 'No Company Name given' => 'Kein Firmenname hinterlegt!', @@ -1998,6 +2001,7 @@ $self->{texts} = { 'No Journal' => 'Kein Journal', 'No Shopdescription' => 'Keine Shop-Artikelbeschreibung', 'No Shopimages' => 'Keine Shop-Bilder', + 'No VAT Info for this ZUGFeRD invoice, please ask your vendor to add this for his ZUGFeRD data.' => 'Konnte keine UST-ID für diese ZUGFeRD Rechnungen finden, bitte fragen Sie bei Ihren Lieferanten nach, ob dieses Feld im ZUGFeRD Datensatz gesetzt wird.', 'No Vendor was found matching the search parameters.' => 'Zu dem Suchbegriff wurde kein Händler gefunden', 'No action defined.' => 'Keine Aktion definiert.', 'No article has been selected yet.' => 'Es wurde noch kein Artikel ausgewählt.', @@ -2312,6 +2316,7 @@ $self->{texts} = { 'Pictures for search parts' => 'Bilder für Warensuche', 'Please Check the bank information for each customer:' => 'Bitte überprüfen Sie die Bankinformationen der Kunden:', 'Please Check the bank information for each vendor:' => 'Bitte überprüfen Sie die Kontoinformationen der Lieferanten:', + 'Please add a valid VAT-ID for this vendor: ' => 'Bitte prüfen Sie ob dieser Lieferant eine valide UST-ID (Großschreibungen und Leerzeichen beachten) besitzt:', 'Please ask your administrator to create warehouses and bins.' => 'Bitten Sie Ihren Administrator, dass er Lager und Lagerplätze anlegt.', 'Please change the partnumber of the following parts and run the update again:' => 'Bitte ändern Sie daher die Artikelnummer folgender Artikel:', 'Please choose a part.' => 'Bitte wählen Sie einen Artikel aus.', @@ -4003,6 +4008,7 @@ $self->{texts} = { 'Your download does not exist anymore. Please re-run the DATEV export assistant.' => 'Ihr Download existiert nicht mehr. Bitte starten Sie den DATEV-Exportassistenten erneut.', 'Your import is being processed.' => 'Ihr Import wird verarbeitet', 'Your target quantity will be added to the stocked quantity.' => 'Ihre gezählte Zielmenge wird zum Lagerbestand hinzugezählt.', + 'ZUGFeRD import' => 'ZUGFeRD Import', 'ZUGFeRD invoice' => 'ZUGFeRD-Rechnung', 'ZUGFeRD notes for each invoice' => 'ZUGFeRD-Notizen für jede Rechnung', 'Zeitraum' => 'Zeitraum', @@ -4011,6 +4017,7 @@ $self->{texts} = { 'Zip, City' => 'PLZ, Ort', 'Zipcode' => 'PLZ', 'Zipcode and city' => 'PLZ und Stadt', + 'ZugFeRD Import' => 'ZUGFeRD Import', '[email]' => '[email]', 'absolute' => 'absolut', 'account_description' => 'Beschreibung', @@ -4045,6 +4052,7 @@ $self->{texts} = { 'brutto' => 'brutto', 'building data' => 'Verarbeite Daten', 'building report' => 'Erstelle Bericht', + 'can only parse a pdf file' => 'Kann nur eine gültige PDF-Datei verwenden.', 'cash' => 'Ist-Versteuerung', 'chargenumber #1' => 'Chargennummer #1', 'chart_of_accounts' => 'kontenuebersicht', @@ -4149,6 +4157,7 @@ $self->{texts} = { 'male' => 'männlich', 'max filesize' => 'maximale Dateigröße', 'missing' => 'Fehlbestand', + 'missing file for action import' => 'Es wurde keine Datei zum Hochladen ausgewählt', 'missing_br' => 'Fehl.', 'month' => 'Monatliche Abgabe', 'monthly' => 'monatlich', diff --git a/menus/user/00-erp.yaml b/menus/user/00-erp.yaml index 9f03529f7..3486fa668 100644 --- a/menus/user/00-erp.yaml +++ b/menus/user/00-erp.yaml @@ -641,6 +641,14 @@ access: general_ledger params: action: YearEndTransactions/form +- parent: general_ledger + id: zugferd_import + name: ZugFeRD Import + icon: cbob + order: 485 + access: ap_transactions + params: + action: ZUGFeRD/upload_zugferd - parent: general_ledger id: general_ledger_reports name: Reports diff --git a/templates/webpages/zugferd/form.html b/templates/webpages/zugferd/form.html new file mode 100644 index 000000000..dd8662fd3 --- /dev/null +++ b/templates/webpages/zugferd/form.html @@ -0,0 +1,15 @@ +[%- USE HTML %] +[%- USE LxERP %] +[%- USE L %] +[%- USE T8 %] +[%- INCLUDE 'common/flash.html' %] +
[% FORM.title %]
+ +

+ [% "Import a ZUGFeRD file:" | $T8 %] +

+ +
+ [% L.input_tag('file', '', type => 'file', accept => '.pdf') %] +
+ -- 2.20.1