ZUGFeRD: ZUGFeRD-Controller der minimal ZUGFeRD PDF parst
authorJan Büren <jan@kivitendo.de>
Tue, 10 Mar 2020 11:24:48 +0000 (12:24 +0100)
committerJan Büren <jan@kivitendo.de>
Tue, 10 Mar 2020 11:24:48 +0000 (12:24 +0100)
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 [new file with mode: 0644]
locale/de/all
menus/user/00-erp.yaml
templates/webpages/zugferd/form.html [new file with mode: 0644]

diff --git a/SL/Controller/ZUGFeRD.pm b/SL/Controller/ZUGFeRD.pm
new file mode 100644 (file)
index 0000000..f03b517
--- /dev/null
@@ -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<action_upload_zugferd>
+
+Creates a web from with a single upload dialog.
+
+=item C<action_import_zugferd $pdf>
+
+Expects a single pdf with ZUGFeRD 2.0 metadata.
+Checks if the param <C$pdf> 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 E<lt>jan@kivitendo-premium.deE<gt>,
+
+=cut
index 72f7aa9..675fa1f 100755 (executable)
@@ -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',
index 9f03529..3486fa6 100644 (file)
   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 (file)
index 0000000..dd8662f
--- /dev/null
@@ -0,0 +1,15 @@
+[%- USE HTML %]
+[%- USE LxERP %]
+[%- USE L %]
+[%- USE T8 %]
+[%- INCLUDE 'common/flash.html' %]
+ <div class="listtop">[% FORM.title %]</div>
+
+ <p>
+ [% "Import a ZUGFeRD file:" | $T8 %]
+ </p>
+
+ <form method="post" action="controller.pl" enctype="multipart/form-data" id="form">
+    [% L.input_tag('file', '', type => 'file', accept => '.pdf') %]
+ </form>
+