Projekt

Allgemein

Profil

« Zurück | Weiter » 

Revision 08649f7d

Von Johannes Grassler vor mehr als 1 Jahr hinzugefügt

  • ID 08649f7dc72a515975831ddf4f429ba9bda3df32
  • Vorgänger a888be0c
  • Nachfolger eada6c17

ZUGFeRD-Import auf SL::XMLInvoice umgestellt

Wichtigste Aenderung dieses Commits ist die Umstellung des
ZUGFeRD-Imports in der Finanzbuchhaltung auf das neu
hinzugefuegte Modul SL::XMLInvoice, das auch die Verabeitung
von Rechnungen im XRechnung-Format erlaubt. Darueber hinaus
gibt es einige weitere Aenderungen:

  • Datenformate: Neben ZUGFeRD wird nun auch XRechnung
    unterstuetzt.
  • Fehlertoleranz: Fehlende ZUGFeRD-Metadaten erzeugen nur noch
    Warnungen, saemtliche XML-Anhaenge an PDF-Dateien werden
    automatisch erkannt und verarbeitet (egal ob es sich um
    ZUGFeRD/Faktur-X- oder XRechnung-Daten handelt).
  • Upload von reinen XML-Dateien ist nun auch moeglich (fuer
    Rechnungen im XRechnung-Format wichtig).
  • Die Posten der Rechnung werden nun auch automatisch in das
    Formular eingetragen, nicht mehr nur Rechnungsnummer und
    Datum.
  • Es muss keine Belegvorlage mehr fuer den Lieferanten
    existieren. Eine eigene Belegvorlage wird fuer jede
    importierte Rechnung automatisch angelegt.
  • Es wird automatisch ein Gegenkonto fuer die Buchung
    aus den Verbindlichkeitskonten ausgewaehlt.
  • Das Faelligkeitsdatum der Rechnung wird immer gesetzt.
    Enthaelt die Rechnung keines, wird es auf das Rechnungsdatum
    gesetzt.

Unterschiede anzeigen:

SL/Controller/ZUGFeRD.pm
1 1
package SL::Controller::ZUGFeRD;
2 2
use strict;
3
use warnings;
3 4
use parent qw(SL::Controller::Base);
4 5

  
5 6
use SL::DB::RecordTemplate;
6 7
use SL::Locale::String qw(t8);
7 8
use SL::Helper::DateTime;
9
use SL::XMLInvoice;
8 10
use SL::VATIDNr;
9 11
use SL::ZUGFeRD;
10 12

  
......
20 22
  $self->render('zugferd/form', title => $::locale->text('Factur-X/ZUGFeRD import'));
21 23
}
22 24

  
23
sub action_import_zugferd {
24
  my ($self, %params) = @_;
25
sub find_vendor_by_taxnumber {
26
  my $taxnumber = shift @_;
25 27

  
26
  die t8("missing file for action import") unless $::form->{file};
27
  die t8("can only parse a pdf file")      unless $::form->{file} =~ m/^%PDF/;
28
  # 1.1 check if we a have a vendor with this tax number (vendor.taxnumber)
29
  my $vendor = SL::DB::Manager::Vendor->find_by(
30
    taxnumber => $taxnumber,
31
    or    => [
32
      obsolete => undef,
33
      obsolete => 0,
34
    ]);
28 35

  
29
  my $info = SL::ZUGFeRD->extract_from_pdf($::form->{file});
36
  if (!$vendor) {
37
    # 1.2 If no vendor with the exact VAT ID number is found, the
38
    # number might be stored slightly different in the database
39
    # (e.g. with spaces breaking up groups of numbers). Iterate over
40
    # all existing vendors with VAT ID numbers, normalize their
41
    # representation and compare those.
30 42

  
31
  if ($info->{result} != SL::ZUGFeRD::RES_OK()) {
32
    # An error occurred; log message from parser:
33
    $::lxdebug->message(LXDebug::DEBUG1(), "Could not extract ZUGFeRD data, error message: " . $info->{message});
34
    die t8("Could not extract Factur-X/ZUGFeRD data, data and error message:") . $info->{message};
43
    my $vendors = SL::DB::Manager::Vendor->get_all(
44
      where => [
45
        '!taxnumber' => undef,
46
        '!taxnumber' => '',
47
        or       => [
48
          obsolete => undef,
49
          obsolete => 0,
50
        ],
51
      ]);
52

  
53
    foreach my $other_vendor (@{ $vendors }) {
54
      next unless $other_vendor->taxnumber eq $taxnumber;
55

  
56
      $vendor = $other_vendor;
57
      last;
58
    }
35 59
  }
36
  # valid ZUGFeRD metadata
37
  my $dom   = XML::LibXML->load_xml(string => $info->{invoice_xml});
60
}
38 61

  
39
  # 1. check if ZUGFeRD SellerTradeParty has a VAT-ID
40
  my $ustid = $dom->findnodes('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration')->string_value;
41
  die t8("No VAT Info for this Factur-X/ZUGFeRD invoice," .
42
         " please ask your vendor to add this for his Factur-X/ZUGFeRD data.") unless $ustid;
62
sub find_vendor_by_ustid {
63
  my $ustid = shift @_;
43 64

  
44 65
  $ustid = SL::VATIDNr->normalize($ustid);
45 66

  
46 67
  # 1.1 check if we a have a vendor with this VAT-ID (vendor.ustid)
47
  my $vc     = $dom->findnodes('//ram:SellerTradeParty/ram:Name')->string_value;
48 68
  my $vendor = SL::DB::Manager::Vendor->find_by(
49 69
    ustid => $ustid,
50 70
    or    => [
......
77 97
    }
78 98
  }
79 99

  
80
  die t8("Please add a valid VAT-ID for this vendor: #1", $vc) unless (ref $vendor eq 'SL::DB::Vendor');
100
  return $vendor;
101
}
81 102

  
82
  # 2. check if we have a ap record template for this vendor (TODO only the oldest template is choosen)
83
  my $template_ap = SL::DB::Manager::RecordTemplate->get_first(where => [vendor_id => $vendor->id]);
84
  die t8("No AP Record Template for this vendor found, please add one") unless (ref $template_ap eq 'SL::DB::RecordTemplate');
103
sub find_vendor {
104
  my ($ustid, $taxnumber) = @_;
105
  my $vendor;
85 106

  
107
  if ( $ustid ) {
108
    $vendor = find_vendor_by_ustid($ustid);
109
  }
86 110

  
87
  # 3. parse the zugferd data and fill the ap record template
88
  # -> no need to check sign (credit notes will be negative) just record thei ZUGFeRD type in ap.notes
89
  # -> check direct debit (defaults to no)
90
  # -> set amount (net amount) and unset taxincluded
91
  #    (template and user cares for tax and if there is more than one booking accno)
92
  # -> date (can be empty)
93
  # -> duedate (may be empty)
94
  # -> compare record iban and generate a warning if this differs from vendor's master data iban
95
  my $total     = $dom->findnodes('//ram:SpecifiedTradeSettlementHeaderMonetarySummation' .
96
                                  '/ram:TaxBasisTotalAmount')->string_value;
111
  if (ref $vendor eq 'SL::DB::Vendor') { return $vendor; }
97 112

  
98
  my $invnumber = $dom->findnodes('//rsm:ExchangedDocument/ram:ID')->string_value;
113
  if ( $taxnumber ) {
114
    $vendor = find_vendor_by_taxnumber($taxnumber);
115
  }
99 116

  
100
  # parse dates to kivi if set/valid
101
  my ($transdate, $duedate, $dt_to_kivi, $due_dt_to_kivi);
102
  $transdate = $dom->findnodes('//ram:IssueDateTime')->string_value;
103
  $duedate   = $dom->findnodes('//ram:DueDateDateTime')->string_value;
104
  $transdate =~ s/^\s+|\s+$//g;
105
  $duedate   =~ s/^\s+|\s+$//g;
106

  
107
  if ($transdate =~ /^[0-9]{8}$/) {
108
    $dt_to_kivi = DateTime->new(year  => substr($transdate,0,4),
109
                                month => substr ($transdate,4,2),
110
                                day   => substr($transdate,6,2))->to_kivitendo;
117
  if (ref $vendor eq 'SL::DB::Vendor') { return $vendor; }
118

  
119
  return undef;
120
}
121

  
122
sub action_import_zugferd {
123
  my ($self, %params) = @_;
124

  
125
  my %res;          # result data structure returned by SL::ZUGFeRD->extract_from_{pdf,xml}()
126
  my $parser;       # SL::XMLInvoice object created by SL::ZUGFeRD->extract_from_{pdf,xml}()
127
  my $dom;          # DOM object for parsed XML data
128
  my $template_ap;  # SL::DB::RecordTemplate object
129
  my $vendor;       # SL::DB::Vendor object
130

  
131
  my $ibanmessage;  # Message to display if vendor's database and invoice IBANs don't match up
132

  
133
  die t8("missing file for action import") unless $::form->{file};
134
  die t8("can only parse a pdf or xml file")      unless $::form->{file} =~ m/^%PDF|<\?xml/;
135

  
136
  if ( $::form->{file} =~ m/^%PDF/ ) {
137
    %res = %{SL::ZUGFeRD->extract_from_pdf($::form->{file})}
138
  } else {
139
    %res = %{SL::ZUGFeRD->extract_from_xml($::form->{file})};
111 140
  }
112
  if ($duedate =~ /^[0-9]{8}$/) {
113
    $due_dt_to_kivi = DateTime->new(year  => substr($duedate,0,4),
114
                                    month => substr ($duedate,4,2),
115
                                    day   => substr($duedate,6,2))->to_kivitendo;
141

  
142
  if ($res{'result'} != SL::ZUGFeRD::RES_OK()) {
143
    # An error occurred; log message from parser:
144
    $::lxdebug->message(LXDebug::DEBUG1(), "Could not extract ZUGFeRD data, error message: " . $res{'message'});
145
    die(t8("Could not extract Factur-X/ZUGFeRD data, data and error message:") . " $res{'message'}");
116 146
  }
117 147

  
118
  my $type = $dom->findnodes('//rsm:ExchangedDocument/ram:TypeCode')->string_value;
148
  $parser = $res{'invoice_xml'};
149

  
150
  # Shouldn't be neccessary with SL::XMLInvoice doing the heavy lifting, but
151
  # let's grab it, just in case.
152
  $dom  = $parser->{dom};
153

  
154
  my %metadata = %{$parser->metadata};
155
  my @items = @{$parser->items};
156

  
157
  my $iban = $metadata{'iban'};
158
  my $invnumber = $metadata{'invnumber'};
159

  
160
  if ( ! ($metadata{'ustid'} or $metadata{'taxnumber'}) ) {
161
    die t8("Cannot process this invoice: neither VAT ID nor tax ID present.");
162
    }
163

  
164
  $vendor = find_vendor($metadata{'ustid'}, $metadata{'taxnumber'});
165

  
166
  die t8("Please add a valid VAT ID or tax number for this vendor: #1", $metadata{'vendor_name'}) unless $vendor;
119 167

  
120
  my $dd   = $dom->findnodes('//ram:ApplicableHeaderTradeSettlement' .
121
                             '/ram:SpecifiedTradeSettlementPaymentMeans/ram:TypeCode')->string_value;
122
  my $direct_debit = $dd == 59 ? 1 : 0;
123 168

  
124
  my $iban = $dom->findnodes('//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementPaymentMeans' .
125
                             '/ram:PayeePartyCreditorFinancialAccount/ram:IBANID')->string_value;
126
  my $ibanmessage;
169
  # Create a record template for this imported invoice
170
  $template_ap = SL::DB::RecordTemplate->new(
171
      vendor_id=>$vendor->id,
172
  );
173

  
174
  # Check IBAN specified on bill matches the one we've got in
175
  # the database for this vendor.
127 176
  $ibanmessage = $iban ne $vendor->iban ? "Record IBAN $iban doesn't match vendor IBAN " . $vendor->iban : $iban if $iban;
128 177

  
178
  # Use invoice creation date as due date if there's no due date
179
  $metadata{'duedate'} = $metadata{'transdate'} unless defined $metadata{'duedate'};
180

  
181
  # parse dates to kivi if set/valid
182
  foreach my $key ( qw(transdate duedate) ) {
183
    next unless defined $metadata{$key};
184
    $metadata{$key} =~ s/^\s+|\s+$//g;
185

  
186
    if ($metadata{$key} =~ /^([0-9]{4})-?([0-9]{2})-?([0-9]{2})$/) {
187
    $metadata{$key} = DateTime->new(year  => $1,
188
                                    month => $2,
189
                                    day   => $3)->to_kivitendo;
190
    }
191
  }
192

  
193
  # Try to fill in AP account to book against
194
  my $ap_chart_id = $::instance_conf->get_ap_chart_id;
195

  
196
  unless ( defined $ap_chart_id ) {
197
    # If no default account is configured, just use the first AP account found.
198
    my $ap_chart = SL::DB::Manager::Chart->get_all(
199
      where   => [ link => 'AP' ],
200
      sort_by => [ 'accno' ],
201
    );
202
    $ap_chart_id = ${$ap_chart}[0]->id;
203
  }
204

  
205
  my $currency = SL::DB::Manager::Currency->find_by(
206
    name => $metadata{'currency'},
207
    );
208

  
209
  $template_ap->assign_attributes(
210
    template_name       => "Faktur-X/ZUGFeRD/XRechnung Import $vendor->name, $invnumber",
211
    template_type       => 'ap_transaction',
212
    direct_debit        => $metadata{'direct_debit'},
213
    notes               => "Faktur-X/ZUGFeRD/XRechnung Import. Type: $metadata{'type'}\nIBAN: " . $ibanmessage,
214
    taxincluded         => 0,
215
    currency_id         => $currency->id,
216
    ar_ap_chart_id      => $ap_chart_id,
217
    );
218

  
219
  $template_ap->save;
220

  
221
  my $default_ap_amount_chart = SL::DB::Manager::Chart->find_by(charttype => 'A');
222

  
223
  foreach my $i ( @items )
224
    {
225
    my %item = %{$i};
226

  
227
    my $net_total = $item{'subtotal'};
228
    my $desc = $item{'description'};
229
    my $tax_rate = $item{'tax_rate'} / 100; # XML data is usually in percent
230

  
231
    my $taxes = SL::DB::Manager::Tax->get_all(
232
      where   => [ chart_categories => { like => '%' . $default_ap_amount_chart->category . '%' },
233
                   rate => $tax_rate,
234
                 ],
235
    );
236

  
237
    # If we really can't find any tax definition (a simple rounding error may
238
    # be sufficient for that to happen), grab the first tax fitting the default
239
    # category, just like the AP form would do it for manual entry.
240
    if ( scalar @{$taxes} == 0 ) {
241
      $taxes = SL::D::ManagerTax->get_all(
242
        where   => [ chart_categories => { like => '%' . $default_ap_amount_chart->category . '%' } ],
243
      );
244
    }
245

  
246
    my $tax = ${$taxes}[0];
247

  
248
    my $item_obj = SL::DB::RecordTemplateItem
249
      ->new(amount1 => $net_total,
250
            record_template_id => $template_ap->id,
251
            chart_id      => $default_ap_amount_chart->id,
252
            tax_id      => $tax->id,
253
        );
254
    $item_obj->save;
255
    }
256

  
129 257
  my $url = $self->url_for(
130 258
    controller                           => 'ap.pl',
131 259
    action                               => 'load_record_template',
132 260
    id                                   => $template_ap->id,
133
    'form_defaults.amount_1'             => $::form->format_amount(\%::myconfig, $total, 2),
134
    'form_defaults.transdate'            => $dt_to_kivi,
135
    'form_defaults.invnumber'            => $invnumber,
136
    'form_defaults.duedate'              => $due_dt_to_kivi,
137 261
    'form_defaults.no_payment_bookings'  => 0,
138
    'form_defaults.paid_1_suggestion'    => $::form->format_amount(\%::myconfig, $total, 2),
139
    'form_defaults.notes'                => "ZUGFeRD Import. Type: $type\nIBAN: " . $ibanmessage,
140
    'form_defaults.taxincluded'          => 0,
141
    'form_defaults.direct_debit'          => $direct_debit,
262
    'form_defaults.invnumber'            => $invnumber,
263
    'form_defaults.duedate'              => $metadata{'duedate'},
264
    'form_defaults.transdate'            => $metadata{'transdate'},
142 265
  );
143 266

  
144 267
  $self->redirect_to($url);
145

  
146 268
}
147 269

  
148 270
sub check_auth {
......
172 294

  
173 295
=head1 NAME
174 296

  
175
SL::Controller::ZUGFeRD
176
Controller for importing ZUGFeRD pdf files to kivitendo
297
SL::Controller::ZUGFeRD - Controller for importing ZUGFeRD PDF files or XML invoices to kivitendo
177 298

  
178 299
=head1 FUNCTIONS
179 300

  
......
183 304

  
184 305
Creates a web from with a single upload dialog.
185 306

  
186
=item C<action_import_zugferd $pdf>
307
=item C<action_import_zugferd $file>
308

  
309
Expects a single PDF with ZUGFeRD, Factur-X or XRechnung
310
metadata. Alternatively, it can also process said data as a
311
standalone XML file.
187 312

  
188
Expects a single pdf with ZUGFeRD 2.0 metadata.
189
Checks if the param <C$pdf> is set and a valid pdf file.
190
Calls helper functions to validate and extract the ZUGFeRD data.
191
Needs a valid VAT ID (EU) for this vendor and
192
expects one ap template for this vendor in kivitendo.
313
Checks if the param <C$pdf> is set and a valid PDF or XML
314
file. Calls helper functions to validate and extract the
315
ZUGFeRD/Factur-X/XRechnung data. The invoice needs to have a
316
valid VAT ID (EU) or tax number (Germany) and a vendor with
317
the same VAT ID or tax number enrolled in Kivitendo.
193 318

  
194
Parses some basic ZUGFeRD data (invnumber, total net amount,
195
transdate, duedate, vendor VAT ID, IBAN) and uses the first
196
found ap template for this vendor to fill this template with
197
ZUGFeRD data.
198
If the vendor's master data contain a IBAN and the
199
ZUGFeRD record has a IBAN also these values will be compared.
200
If they  don't match a warning will be writte in ap.notes.
201
Furthermore the ZUGFeRD type code will be written to ap.notes.
202
No callback implemented.
319
It parses some basic ZUGFeRD data (invnumber, total net amount,
320
transdate, duedate, vendor VAT ID, IBAN, etc.) and also
321
extracts the invoice's items.
322

  
323
If the invoice has a IBAN also, it will be be compared to the
324
IBAN saved for the vendor (if any). If they  don't match a
325
warning will be writte in ap.notes. Furthermore the ZUGFeRD
326
type code will be written to ap.notes. No callback
327
implemented.
203 328

  
204 329
=back
205 330

  
206
=head1 TODO and CAVEAT
331
=head1 CAVEAT
332

  
333
This is just a very basic Parser for ZUGFeRD/Factur-X/XRechnung invoices.
334
We assume that the invoice's creator is a company with a valid
335
European VAT ID or German tax number and enrolled in
336
Kivitendo. Currently, implementation is a bit hacky because
337
invoice import uses AP record templates as a vessel for
338
generating the AP record form with the imported data filled
339
in.
340

  
341
=head1 TODO
342

  
343
This implementation could be improved as follows:
344

  
345
=over 4
207 346

  
208
This is just a very basic Parser for ZUGFeRD data.
209
We assume that the ZUGFeRD generator is a company with a
210
valid European VAT ID. Furthermore this vendor needs only
211
one and just noe ap template (the first match will be used).
347
=item Direct creation of the filled in AP record form
348

  
349
Creating an AP record template in the database is not
350
very elegant, since it will spam the database with record
351
templates that become redundant once the invoice has been
352
booked. It would be preferable to fill in the form directly.
353

  
354
=item Automatic upload of invoice
355

  
356
Right now, one has to use the "Book and upload" button to
357
upload the raw invoice document to WebDAV or DMS and attach it
358
to the invoice. This should be a simple matter of setting a
359
check box when uploading.
360

  
361
=item Handling of vendor invoices
362

  
363
There is no reason this functionality could not be used to
364
import vendor invoices as well. Since these tend to be very
365
lengthy, the ability to import them would be very beneficial.
366

  
367
=item Automatic handling of payment purpose
212 368

  
213
The ZUGFeRD data should also be extracted in the helper package
214
and maybe a model should be used for this.
215
The user should set one ap template as a default for ZUGFeRD.
216
The ZUGFeRD pdf should be written to WebDAV or DMS.
217 369
If the ZUGFeRD data has a payment purpose set, this should
218 370
be the default for the SEPA-XML export.
219 371

  
372
=back
373

  
374
=head1 AUTHORS
375

  
376
=over 4
377

  
378
=item Jan Büren E<lt>jan@kivitendo-premium.deE<gt>,
220 379

  
221
=head1 AUTHOR
380
=item Johannes Graßler E<lt>info@computer-grassler.deE<gt>,
222 381

  
223
Jan Büren E<lt>jan@kivitendo-premium.deE<gt>,
382
=back
224 383

  
225 384
=cut
SL/ZUGFeRD.pm
10 10
use XML::LibXML;
11 11

  
12 12
use SL::Locale::String qw(t8);
13
use SL::XMLInvoice;
13 14

  
14 15
use parent qw(Exporter);
15 16
our @EXPORT_PROFILES = qw(PROFILE_FACTURX_EXTENDED PROFILE_XRECHNUNG);
......
20 21
use constant PROFILE_XRECHNUNG        => 1;
21 22

  
22 23
use constant RES_OK                              => 0;
23
use constant RES_ERR_FILE_OPEN                   => 1;
24
use constant RES_ERR_NO_XMP_METADATA             => 2;
25
use constant RES_ERR_NO_XML_INVOICE              => 3;
26
use constant RES_ERR_NOT_ZUGFERD                 => 4;
27
use constant RES_ERR_UNSUPPORTED_ZUGFERD_VERSION => 5;
24
use constant RES_ERR_FILE_OPEN                   => -1;
25
use constant RES_ERR_NO_ATTACHMENT               => -2;
28 26

  
29 27
our @customer_settings = (
30 28
  [ 0,                                  t8('Do not create Factur-X/ZUGFeRD invoices')                                    ],
......
47 45

  
48 46
sub _extract_zugferd_invoice_xml {
49 47
  my $doc        = shift;
50
  my $names_dict = $doc->getValue($doc->getRootDict->{Names}) or return {};
51
  my $files_tree = $names_dict->{EmbeddedFiles}               or return {};
48
  my %res_fail;
49

  
50
  $res_fail{'result'}  = RES_ERR_NO_ATTACHMENT();
51
  $res_fail{'message'} = "PDF does not have a Names dictionary.";
52
  my $names_dict = $doc->getValue($doc->getRootDict->{Names}) or return \%res_fail;
53

  
54
  $res_fail{'message'} = "PDF does not have a EmbeddedFiles tree.";
55
  my $files_tree = $names_dict->{EmbeddedFiles}               or return \%res_fail;
56

  
52 57
  my @agenda     = $files_tree;
53
  my $ret        = {};
58

  
59
  my $parser;  # SL::XMLInvoice object used as return value
60
  my @res;     # Temporary storage for error messages encountered during
61
               # attempts to process attachments.
54 62

  
55 63
  # Hardly ever more than single leaf, but...
56 64

  
......
74 82
        my $obj_node = $doc->dereference($any_num);
75 83
        my $content  = $doc->decodeOne($obj_node->{value}, 0) // '';
76 84

  
77
        #print "1\n";
78

  
79
        next if $content !~ m{<rsm:CrossIndustryInvoice};
80
        #print "2\n";
81

  
82
        my $dom = eval { XML::LibXML->load_xml(string => $content) };
83
        return $content if $dom && ($dom->documentElement->nodeName eq 'rsm:CrossIndustryInvoice');
85
        $parser = $parser = SL::XMLInvoice->new($content);
86

  
87
        # Caveat: this will only ever catch the first attachment looking like
88
        #         an XML invoice.
89
        if ( $parser->{status} == SL::XMLInvoice::RES_OK ){
90
          return $parser;
91
        } else {
92
          push @res, t8("Could not parse PDF embedded attachment #1: #2",
93
                       $k,
94
                       $parser->{result});
95
        }
84 96
      }
85 97
    }
86 98
  }
87 99

  
88
  return undef;
100
  # There's going to be at least one attachment that failed to parse as XML by
101
  # this point - if there were no attachments at all, we would have bailed out
102
  # a lot earlier.
103

  
104
  %res_fail = ( result  => RES_ERR_FILE_OPEN(),
105
                message => join("; ", @res),
106
  );
107

  
108
  return \%res_fail;
89 109
}
90 110

  
91 111
sub _get_xmp_metadata {
......
95 115
  if ($node && $node->{StreamData} && defined($node->{StreamData}->{value})) {
96 116
    return $node->{StreamData}->{value};
97 117
  }
98

  
99 118
  return undef;
100 119
}
101 120

  
102 121
sub extract_from_pdf {
103 122
  my ($self, $file_name) = @_;
123
  my @warnings;
104 124

  
105 125
  my $pdf_doc = CAM::PDF->new($file_name);
106 126

  
107 127
  if (!$pdf_doc) {
108
    return {
128
    return \{
109 129
      result  => RES_ERR_FILE_OPEN(),
110 130
      message => $::locale->text('The file \'#1\' could not be opened for reading.', $file_name),
111 131
    };
112 132
  }
113 133

  
114 134
  my $xmp = _get_xmp_metadata($pdf_doc);
135

  
115 136
  if (!defined $xmp) {
116
    return {
117
      result  => RES_ERR_NO_XMP_METADATA(),
118
      message => $::locale->text('The file \'#1\' does not contain the required XMP meta data.', $file_name),
119
    };
120
  }
137
      push @warnings, $::locale->text('The file \'#1\' does not contain the required XMP meta data.', $file_name);
138
  } else {
139
    my $dom = eval { XML::LibXML->load_xml(string => $xmp) };
121 140

  
122
  my $bad = {
123
    result  => RES_ERR_NO_XMP_METADATA(),
124
    message => $::locale->text('Parsing the XMP metadata failed.'),
125
  };
141
    push @warnings, $::locale->text('Parsing the XMP metadata failed.'), if !$dom;
126 142

  
127
  my $dom = eval { XML::LibXML->load_xml(string => $xmp) };
143
    my $xpc = XML::LibXML::XPathContext->new($dom);
144
    $xpc->registerNs('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
128 145

  
129
  return $bad if !$dom;
146
    my $zugferd_version;
130 147

  
131
  my $xpc = XML::LibXML::XPathContext->new($dom);
132
  $xpc->registerNs('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
148
    my $test = $xpc->findnodes('/x:xmpmeta/rdf:RDF/rdf:Description');
133 149

  
134
  my $zugferd_version;
150
    foreach my $node ($xpc->findnodes('/x:xmpmeta/rdf:RDF/rdf:Description')) {
151
      my $ns = first { ref($_) eq 'XML::LibXML::Namespace' } $node->attributes;
152
      next unless $ns;
135 153

  
136
  foreach my $node ($xpc->findnodes('/x:xmpmeta/rdf:RDF/rdf:Description')) {
137
    my $ns = first { ref($_) eq 'XML::LibXML::Namespace' } $node->attributes;
138
    next unless $ns;
154
      if ($ns->getData =~ m{urn:zugferd:pdfa:CrossIndustryDocument:invoice:2p0}) {
155
        $zugferd_version = 'zugferd:2p0';
156
        last;
157
      }
139 158

  
140
    if ($ns->getData =~ m{urn:zugferd:pdfa:CrossIndustryDocument:invoice:2p0}) {
141
      $zugferd_version = 'zugferd:2p0';
142
      last;
143
    }
159
      if ($ns->getData =~ m{urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0}) {
160
        $zugferd_version = 'factur-x:1p0';
161
        last;
162
      }
144 163

  
145
    if ($ns->getData =~ m{urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0}) {
146
      $zugferd_version = 'factur-x:1p0';
147
      last;
164
      if ($ns->getData =~ m{zugferd|factur-x}i) {
165
        $zugferd_version = 'unsupported';
166
        last;
167
      }
148 168
    }
149 169

  
150
    if ($ns->getData =~ m{zugferd|factur-x}i) {
151
      $zugferd_version = 'unsupported';
152
      last;
170
    if (!$zugferd_version) {
171
        push @warnings, $::locale->text('The XMP metadata does not declare the Factur-X/ZUGFeRD data.'),
153 172
    }
154
  }
155 173

  
156
  if (!$zugferd_version) {
157
    return {
158
      result  => RES_ERR_NOT_ZUGFERD(),
159
      message => $::locale->text('The XMP metadata does not declare the Factur-X/ZUGFeRD data.'),
160
    };
161
  }
162

  
163
  if ($zugferd_version eq 'unsupported') {
164
    return {
165
      result  => RES_ERR_UNSUPPORTED_ZUGFERD_VERSION(),
166
      message => $::locale->text('The Factur-X/ZUGFeRD version used is not supported.'),
167
    };
174
    if ($zugferd_version eq 'unsupported') {
175
        push @warnings, $::locale->text('The Factur-X/ZUGFeRD version used is not supported.'),
176
    }
168 177
  }
169 178

  
170 179
  my $invoice_xml = _extract_zugferd_invoice_xml($pdf_doc);
171 180

  
172
  if (!defined $invoice_xml) {
173
    return {
174
      result  => RES_ERR_NO_XML_INVOICE(),
175
      message => $::locale->text('The Factur-X/ZUGFeRD XML invoice was not found.'),
176
    };
177
  }
181
  my %res;
178 182

  
179
  return {
180
    result       => RES_OK(),
183
  %res = (
184
    result       => $invoice_xml->{result},
185
    message      => $invoice_xml->{message},
181 186
    metadata_xmp => $xmp,
182 187
    invoice_xml  => $invoice_xml,
183
  };
188
    warnings     => \@warnings,
189
  );
190

  
191
  return \%res;
192
}
193

  
194
sub extract_from_xml {
195
  my ($self, $data) = @_;
196

  
197
  my %res;
198

  
199
  my $invoice_xml = SL::XMLInvoice->new($data);
200
  
201
  %res = (
202
    result       => $invoice_xml->{result},
203
    message      => $invoice_xml->{message},
204
    metadata_xmp => undef,
205
    invoice_xml  => $invoice_xml,
206
    warnings     => (),
207
  );
208

  
209
  return \%res;
184 210
}
185 211

  
186 212
1;
......
200 226
    my $pdf  = '/path/to/my.pdf';
201 227
    my $info = SL::ZUGFeRD->extract_from_pdf($pdf);
202 228

  
229
    my $xml  = '<?xml version="1.0" encoding="UTF-8"?> ...';
230
    my $info = SL::ZUGFeRD->extract_from_xml($xml);
231

  
203 232
    if ($info->{result} != SL::ZUGFeRD::RES_OK()) {
204 233
      # An error occurred; log message from parser:
205 234
      $::lxdebug->message(LXDebug::DEBUG1(), "Could not extract ZUGFeRD data from $pdf: " . $info->{message});
206 235
      return;
207 236
    }
208 237

  
209
    # Parse & handle invoice XML:
210
    my $dom = XML::LibXML->load_xml(string => $info->{invoice_xml});
238
    # Access invoice XML data:
239
    my $inv = ${$info}{'invoice_xml};
240
    my %metadata = %{$inv->metadata};
241
    my @items = @{$inv->items};
242
    my $dom = $inv->dom;
211 243

  
212 244

  
213 245
=head1 FUNCTIONS
214 246

  
215
=over 4
216

  
217
=item C<extract_from_pdf> C<$file_name>
247
=head2 extract_from_pdf E<lt>file_nameE<gt>
218 248

  
219
Opens an existing PDF in the file system and tries to extract
220
Factur-X/ZUGFeRD invoice data from it. First it'll parse the XMP
249
Opens an existing PDF file in the file system and tries to extract
250
Factur-X/XRechnung/ZUGFeRD invoice data from it. First it'll parse the XMP
221 251
metadata and look for the Factur-X/ZUGFeRD declaration inside. If the
222
declaration isn't found or the declared version isn't 2p0, an error is
223
returned.
252
declaration isn't found or the declared version isn't 2p0, an warning is
253
recorded in the returned data structure's C<warnings> key.
224 254

  
225
Otherwise it'll continue to look through all embedded files in the
226
PDF. The first embedded XML file with a root node of
227
C<rsm:CrossCountryInvoice> will be returnd.
255
Regardless of metadata presence, it will continue to iterate over all files
256
embedded in the PDF and attempt to parse them with SL::XMLInvoice. If it
257
succeeds, the first SL::XMLInvoice object that indicates successful parsing is
258
returned.
228 259

  
229 260
Always returns a hash ref containing the key C<result>, a number that
230 261
can be one of the following constants:
231 262

  
232 263
=over 4
233 264

  
234
=item C<RES_OK> (0): parsing was OK; the returned hash will also
235
contain the keys C<xmp_metadata> and C<invoice_xml> which will contain
236
the XML text of the metadata & the Factur-X/ZUGFeRD invoice.
265
=item C<RES_OK> (0): parsing was OK.
266

  
267
=item C<RES_ERR_…> (all values E<!=> 0): parsing failed. Values > 0 indicate a failure
268
in C<SL::XMLInvoice>, Values < 0 indicate a failure in C<SL::ZUGFeRD>.
269

  
270
=back
271

  
272
Other than that, the hash ref contains the following keys:
273

  
274
=over 4
275

  
276
=item C<message> - An error message detailing the problem upon nonzero C<result>, undef otherwise.
277

  
278
=item C<metadata_xmp> - The XMP metadata extracted from the Factur-X/ZUGFeRD invoice (if present)
279

  
280
=item C<invoice_xml> - An SL::XMLInvoice object holding the data extracted from the parsed XML invoice.
281

  
282
=item C<warnings> - Warnings encountered upon extracting/parsing XML files (if any)
283

  
284
=back
285

  
286
=head2 extract_from_xml E<lt>stringE<gt>
287

  
288
Takes a string containing an XML document with Factur-X/XRechnung/ZUGFeRD
289
invoice data and attempts to parse it using C<SL::XMLInvoice>.
290

  
291
If parsing is successful, an SL::XMLInvoice object containing the document's
292
parsed data is returned.
293

  
294
This method always returns a hash ref containing the key C<result>, a number that
295
can be one of the following constants:
296

  
297
=over 4
298

  
299
=item C<RES_OK> (0): parsing was OK.
237 300

  
238
=item C<RES_ERR_…> (all values E<gt> 0): parsing failed; the hash will
239
also contain a key C<message> which contains a human-readable
240
information about what exactly failed.
301
=item C<RES_ERR_…> (all values E<!=> 0): parsing failed. Values > 0 indicate a failure
302
in C<SL::XMLInvoice>, Values < 0 indicate a failure in C<SL::ZUGFeRD>.
241 303

  
242 304
=back
243 305

  
306
Other than that, the hash ref contains the following keys:
307

  
308
=over 4
309

  
310
=item C<message> - An error message detailing the problem upon nonzero C<result>, undef otherwise.
311

  
312
=item C<metadata_xmp> - Always undef and only present to let downstream code expecting its presence fail gracefully.
313

  
314
=item C<invoice_xml> - An SL::XMLInvoice object holding the data extracted from the parsed XML invoice.
315

  
316
=item C<warnings> - Warnings encountered upon extracting/parsing XML data (if any)
317

  
244 318
=back
245 319

  
246 320
=head1 BUGS
247 321

  
248 322
Nothing here yet.
249 323

  
250
=head1 AUTHOR
324
=head1 AUTHORS
251 325

  
252
Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
326
=over 4
327

  
328
=item Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
329

  
330
=item Johannes Graßler E<lt>info@computer-grassler.deE<gt>
331

  
332
=back
253 333

  
254 334
=cut
templates/webpages/zugferd/form.html
10 10
 </p>
11 11

  
12 12
 <form method="post" action="controller.pl" enctype="multipart/form-data" id="form">
13
    [% L.input_tag('file', '', type => 'file', accept => '.pdf') %]
13
    [% L.input_tag('file', '', type => 'file', accept => '.pdf,.xml') %]
14 14
 </form>

Auch abrufbar als: Unified diff