Revision d472ce56
Von Johannes Grassler vor mehr als 1 Jahr hinzugefügt
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 |
use SL::SessionFile; |
... | ... | |
21 | 23 |
$self->render('zugferd/form', title => $::locale->text('Factur-X/ZUGFeRD import')); |
22 | 24 |
} |
23 | 25 |
|
24 |
sub action_import_zugferd { |
|
25 |
my ($self, %params) = @_; |
|
26 |
my $file = $::form->{file}; |
|
27 |
my $file_name = $::form->{file_name}; |
|
26 |
sub find_vendor_by_taxnumber { |
|
27 |
my $taxnumber = shift @_; |
|
28 | 28 |
|
29 |
die t8("missing file for action import") unless $file; |
|
30 |
die t8("can only parse a pdf file") unless $file =~ m/^%PDF/; |
|
29 |
# 1.1 check if we a have a vendor with this tax number (vendor.taxnumber) |
|
30 |
my $vendor = SL::DB::Manager::Vendor->find_by( |
|
31 |
taxnumber => $taxnumber, |
|
32 |
or => [ |
|
33 |
obsolete => undef, |
|
34 |
obsolete => 0, |
|
35 |
]); |
|
36 |
|
|
37 |
if (!$vendor) { |
|
38 |
# 1.2 If no vendor with the exact VAT ID number is found, the |
|
39 |
# number might be stored slightly different in the database |
|
40 |
# (e.g. with spaces breaking up groups of numbers). Iterate over |
|
41 |
# all existing vendors with VAT ID numbers, normalize their |
|
42 |
# representation and compare those. |
|
31 | 43 |
|
32 |
my $info = SL::ZUGFeRD->extract_from_pdf($file); |
|
44 |
my $vendors = SL::DB::Manager::Vendor->get_all( |
|
45 |
where => [ |
|
46 |
'!taxnumber' => undef, |
|
47 |
'!taxnumber' => '', |
|
48 |
or => [ |
|
49 |
obsolete => undef, |
|
50 |
obsolete => 0, |
|
51 |
], |
|
52 |
]); |
|
33 | 53 |
|
34 |
if ($info->{result} != SL::ZUGFeRD::RES_OK()) { |
|
35 |
# An error occurred; log message from parser: |
|
36 |
$::lxdebug->message(LXDebug::DEBUG1(), "Could not extract ZUGFeRD data, error message: " . $info->{message}); |
|
37 |
die t8("Could not extract Factur-X/ZUGFeRD data, data and error message:") . $info->{message}; |
|
54 |
foreach my $other_vendor (@{ $vendors }) { |
|
55 |
next unless $other_vendor->taxnumber eq $taxnumber; |
|
56 |
|
|
57 |
$vendor = $other_vendor; |
|
58 |
last; |
|
59 |
} |
|
38 | 60 |
} |
39 |
# valid ZUGFeRD metadata |
|
40 |
my $dom = XML::LibXML->load_xml(string => $info->{invoice_xml}); |
|
61 |
} |
|
41 | 62 |
|
42 |
# 1. check if ZUGFeRD SellerTradeParty has a VAT-ID |
|
43 |
my $ustid = $dom->findnodes('//ram:SellerTradeParty/ram:SpecifiedTaxRegistration')->string_value; |
|
44 |
die t8("No VAT Info for this Factur-X/ZUGFeRD invoice," . |
|
45 |
" please ask your vendor to add this for his Factur-X/ZUGFeRD data.") unless $ustid; |
|
63 |
sub find_vendor_by_ustid { |
|
64 |
my $ustid = shift @_; |
|
46 | 65 |
|
47 | 66 |
$ustid = SL::VATIDNr->normalize($ustid); |
48 | 67 |
|
49 | 68 |
# 1.1 check if we a have a vendor with this VAT-ID (vendor.ustid) |
50 |
my $vc = $dom->findnodes('//ram:SellerTradeParty/ram:Name')->string_value; |
|
51 | 69 |
my $vendor = SL::DB::Manager::Vendor->find_by( |
52 | 70 |
ustid => $ustid, |
53 | 71 |
or => [ |
... | ... | |
80 | 98 |
} |
81 | 99 |
} |
82 | 100 |
|
83 |
die t8("Please add a valid VAT-ID for this vendor: #1", $vc) unless (ref $vendor eq 'SL::DB::Vendor'); |
|
101 |
return $vendor; |
|
102 |
} |
|
84 | 103 |
|
85 |
# 2. check if we have a ap record template for this vendor (TODO only the oldest template is choosen)
|
|
86 |
my $template_ap = SL::DB::Manager::RecordTemplate->get_first(where => [vendor_id => $vendor->id]);
|
|
87 |
die t8("No AP Record Template for this vendor found, please add one") unless (ref $template_ap eq 'SL::DB::RecordTemplate');
|
|
104 |
sub find_vendor {
|
|
105 |
my ($ustid, $taxnumber) = @_;
|
|
106 |
my $vendor;
|
|
88 | 107 |
|
108 |
if ( $ustid ) { |
|
109 |
$vendor = find_vendor_by_ustid($ustid); |
|
110 |
} |
|
89 | 111 |
|
90 |
# 3. parse the zugferd data and fill the ap record template |
|
91 |
# -> no need to check sign (credit notes will be negative) just record thei ZUGFeRD type in ap.notes |
|
92 |
# -> check direct debit (defaults to no) |
|
93 |
# -> set amount (net amount) and unset taxincluded |
|
94 |
# (template and user cares for tax and if there is more than one booking accno) |
|
95 |
# -> date (can be empty) |
|
96 |
# -> duedate (may be empty) |
|
97 |
# -> compare record iban and generate a warning if this differs from vendor's master data iban |
|
98 |
my $total = $dom->findnodes('//ram:SpecifiedTradeSettlementHeaderMonetarySummation' . |
|
99 |
'/ram:TaxBasisTotalAmount')->string_value; |
|
112 |
if (ref $vendor eq 'SL::DB::Vendor') { return $vendor; } |
|
100 | 113 |
|
101 |
my $invnumber = $dom->findnodes('//rsm:ExchangedDocument/ram:ID')->string_value; |
|
114 |
if ( $taxnumber ) { |
|
115 |
$vendor = find_vendor_by_taxnumber($taxnumber); |
|
116 |
} |
|
102 | 117 |
|
103 |
# parse dates to kivi if set/valid |
|
104 |
my ($transdate, $duedate, $dt_to_kivi, $due_dt_to_kivi); |
|
105 |
$transdate = $dom->findnodes('//ram:IssueDateTime')->string_value; |
|
106 |
$duedate = $dom->findnodes('//ram:DueDateDateTime')->string_value; |
|
107 |
$transdate =~ s/^\s+|\s+$//g; |
|
108 |
$duedate =~ s/^\s+|\s+$//g; |
|
109 |
|
|
110 |
if ($transdate =~ /^[0-9]{8}$/) { |
|
111 |
$dt_to_kivi = DateTime->new(year => substr($transdate,0,4), |
|
112 |
month => substr ($transdate,4,2), |
|
113 |
day => substr($transdate,6,2))->to_kivitendo; |
|
118 |
if (ref $vendor eq 'SL::DB::Vendor') { return $vendor; } |
|
119 |
|
|
120 |
return undef; |
|
121 |
} |
|
122 |
|
|
123 |
sub action_import_zugferd { |
|
124 |
my ($self, %params) = @_; |
|
125 |
|
|
126 |
my $file = $::form->{file}; |
|
127 |
my $file_name = $::form->{file_name}; |
|
128 |
|
|
129 |
my %res; # result data structure returned by SL::ZUGFeRD->extract_from_{pdf,xml}() |
|
130 |
my $parser; # SL::XMLInvoice object created by SL::ZUGFeRD->extract_from_{pdf,xml}() |
|
131 |
my $dom; # DOM object for parsed XML data |
|
132 |
my $template_ap; # SL::DB::RecordTemplate object |
|
133 |
my $vendor; # SL::DB::Vendor object |
|
134 |
|
|
135 |
my $ibanmessage; # Message to display if vendor's database and invoice IBANs don't match up |
|
136 |
|
|
137 |
die t8("missing file for action import") unless $file; |
|
138 |
die t8("can only parse a pdf or xml file") unless $file =~ m/^%PDF|<\?xml/; |
|
139 |
|
|
140 |
if ( $::form->{file} =~ m/^%PDF/ ) { |
|
141 |
%res = %{SL::ZUGFeRD->extract_from_pdf($::form->{file})} |
|
142 |
} else { |
|
143 |
%res = %{SL::ZUGFeRD->extract_from_xml($::form->{file})}; |
|
114 | 144 |
} |
115 |
if ($duedate =~ /^[0-9]{8}$/) { |
|
116 |
$due_dt_to_kivi = DateTime->new(year => substr($duedate,0,4), |
|
117 |
month => substr ($duedate,4,2), |
|
118 |
day => substr($duedate,6,2))->to_kivitendo; |
|
145 |
|
|
146 |
if ($res{'result'} != SL::ZUGFeRD::RES_OK()) { |
|
147 |
# An error occurred; log message from parser: |
|
148 |
$::lxdebug->message(LXDebug::DEBUG1(), "Could not extract ZUGFeRD data, error message: " . $res{'message'}); |
|
149 |
die(t8("Could not extract Factur-X/ZUGFeRD data, data and error message:") . " $res{'message'}"); |
|
119 | 150 |
} |
120 | 151 |
|
121 |
my $type = $dom->findnodes('//rsm:ExchangedDocument/ram:TypeCode')->string_value; |
|
152 |
$parser = $res{'invoice_xml'}; |
|
153 |
|
|
154 |
# Shouldn't be neccessary with SL::XMLInvoice doing the heavy lifting, but |
|
155 |
# let's grab it, just in case. |
|
156 |
$dom = $parser->{dom}; |
|
157 |
|
|
158 |
my %metadata = %{$parser->metadata}; |
|
159 |
my @items = @{$parser->items}; |
|
122 | 160 |
|
123 |
my $dd = $dom->findnodes('//ram:ApplicableHeaderTradeSettlement' . |
|
124 |
'/ram:SpecifiedTradeSettlementPaymentMeans/ram:TypeCode')->string_value; |
|
125 |
my $direct_debit = $dd == 59 ? 1 : 0; |
|
161 |
my $iban = $metadata{'iban'}; |
|
162 |
my $invnumber = $metadata{'invnumber'}; |
|
126 | 163 |
|
127 |
my $iban = $dom->findnodes('//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementPaymentMeans' . |
|
128 |
'/ram:PayeePartyCreditorFinancialAccount/ram:IBANID')->string_value; |
|
129 |
my $ibanmessage; |
|
164 |
if ( ! ($metadata{'ustid'} or $metadata{'taxnumber'}) ) { |
|
165 |
die t8("Cannot process this invoice: neither VAT ID nor tax ID present."); |
|
166 |
} |
|
167 |
|
|
168 |
$vendor = find_vendor($metadata{'ustid'}, $metadata{'taxnumber'}); |
|
169 |
|
|
170 |
die t8("Please add a valid VAT ID or tax number for this vendor: #1", $metadata{'vendor_name'}) unless $vendor; |
|
171 |
|
|
172 |
|
|
173 |
# Create a record template for this imported invoice |
|
174 |
$template_ap = SL::DB::RecordTemplate->new( |
|
175 |
vendor_id=>$vendor->id, |
|
176 |
); |
|
177 |
|
|
178 |
# Check IBAN specified on bill matches the one we've got in |
|
179 |
# the database for this vendor. |
|
130 | 180 |
$ibanmessage = $iban ne $vendor->iban ? "Record IBAN $iban doesn't match vendor IBAN " . $vendor->iban : $iban if $iban; |
131 | 181 |
|
132 | 182 |
# save the zugferd file to session file for reuse in ap.pl |
... | ... | |
134 | 184 |
$session_file->fh->print($file); |
135 | 185 |
$session_file->fh->close; |
136 | 186 |
|
187 |
# Use invoice creation date as due date if there's no due date |
|
188 |
$metadata{'duedate'} = $metadata{'transdate'} unless defined $metadata{'duedate'}; |
|
189 |
|
|
190 |
# parse dates to kivi if set/valid |
|
191 |
foreach my $key ( qw(transdate duedate) ) { |
|
192 |
next unless defined $metadata{$key}; |
|
193 |
$metadata{$key} =~ s/^\s+|\s+$//g; |
|
194 |
|
|
195 |
if ($metadata{$key} =~ /^([0-9]{4})-?([0-9]{2})-?([0-9]{2})$/) { |
|
196 |
$metadata{$key} = DateTime->new(year => $1, |
|
197 |
month => $2, |
|
198 |
day => $3)->to_kivitendo; |
|
199 |
} |
|
200 |
} |
|
201 |
|
|
202 |
# Try to fill in AP account to book against |
|
203 |
my $ap_chart_id = $::instance_conf->get_ap_chart_id; |
|
204 |
|
|
205 |
unless ( defined $ap_chart_id ) { |
|
206 |
# If no default account is configured, just use the first AP account found. |
|
207 |
my $ap_chart = SL::DB::Manager::Chart->get_all( |
|
208 |
where => [ link => 'AP' ], |
|
209 |
sort_by => [ 'accno' ], |
|
210 |
); |
|
211 |
$ap_chart_id = ${$ap_chart}[0]->id; |
|
212 |
} |
|
213 |
|
|
214 |
my $currency = SL::DB::Manager::Currency->find_by( |
|
215 |
name => $metadata{'currency'}, |
|
216 |
); |
|
217 |
|
|
218 |
$template_ap->assign_attributes( |
|
219 |
template_name => "Faktur-X/ZUGFeRD/XRechnung Import $vendor->name, $invnumber", |
|
220 |
template_type => 'ap_transaction', |
|
221 |
direct_debit => $metadata{'direct_debit'}, |
|
222 |
notes => "Faktur-X/ZUGFeRD/XRechnung Import. Type: $metadata{'type'}\nIBAN: " . $ibanmessage, |
|
223 |
taxincluded => 0, |
|
224 |
currency_id => $currency->id, |
|
225 |
ar_ap_chart_id => $ap_chart_id, |
|
226 |
); |
|
227 |
|
|
228 |
$template_ap->save; |
|
229 |
|
|
230 |
my $default_ap_amount_chart = SL::DB::Manager::Chart->find_by(charttype => 'A'); |
|
231 |
|
|
232 |
foreach my $i ( @items ) |
|
233 |
{ |
|
234 |
my %item = %{$i}; |
|
235 |
|
|
236 |
my $net_total = $item{'subtotal'}; |
|
237 |
my $desc = $item{'description'}; |
|
238 |
my $tax_rate = $item{'tax_rate'} / 100; # XML data is usually in percent |
|
239 |
|
|
240 |
my $taxes = SL::DB::Manager::Tax->get_all( |
|
241 |
where => [ chart_categories => { like => '%' . $default_ap_amount_chart->category . '%' }, |
|
242 |
rate => $tax_rate, |
|
243 |
], |
|
244 |
); |
|
245 |
|
|
246 |
# If we really can't find any tax definition (a simple rounding error may |
|
247 |
# be sufficient for that to happen), grab the first tax fitting the default |
|
248 |
# category, just like the AP form would do it for manual entry. |
|
249 |
if ( scalar @{$taxes} == 0 ) { |
|
250 |
$taxes = SL::D::ManagerTax->get_all( |
|
251 |
where => [ chart_categories => { like => '%' . $default_ap_amount_chart->category . '%' } ], |
|
252 |
); |
|
253 |
} |
|
254 |
|
|
255 |
my $tax = ${$taxes}[0]; |
|
256 |
|
|
257 |
my $item_obj = SL::DB::RecordTemplateItem |
|
258 |
->new(amount1 => $net_total, |
|
259 |
record_template_id => $template_ap->id, |
|
260 |
chart_id => $default_ap_amount_chart->id, |
|
261 |
tax_id => $tax->id, |
|
262 |
); |
|
263 |
$item_obj->save; |
|
264 |
} |
|
265 |
|
|
137 | 266 |
$self->redirect_to( |
138 | 267 |
controller => 'ap.pl', |
139 | 268 |
action => 'load_record_template', |
140 | 269 |
id => $template_ap->id, |
141 |
'form_defaults.amount_1' => $::form->format_amount(\%::myconfig, $total, 2), |
|
142 |
'form_defaults.transdate' => $dt_to_kivi, |
|
143 |
'form_defaults.invnumber' => $invnumber, |
|
144 |
'form_defaults.duedate' => $due_dt_to_kivi, |
|
145 | 270 |
'form_defaults.no_payment_bookings' => 0, |
146 |
'form_defaults.paid_1_suggestion' => $::form->format_amount(\%::myconfig, $total, 2), |
|
147 |
'form_defaults.notes' => "ZUGFeRD Import. Type: $type\nIBAN: " . $ibanmessage, |
|
271 |
'form_defaults.paid_1_suggestion' => $::form->format_amount(\%::myconfig, $metadata{'total'}, 2), |
|
272 |
'form_defaults.invnumber' => $invnumber, |
|
273 |
'form_defaults.duedate' => $metadata{'duedate'}, |
|
274 |
'form_defaults.transdate' => $metadata{'transdate'}, |
|
275 |
'form_defaults.notes' => "ZUGFeRD Import. Type: $metadata{'type'}\nIBAN: " . $ibanmessage, |
|
148 | 276 |
'form_defaults.taxincluded' => 0, |
149 |
'form_defaults.direct_debit' => $direct_debit,
|
|
277 |
'form_defaults.direct_debit' => $metadata{'direct_debit'},
|
|
150 | 278 |
'form_defaults.zugferd_session_file' => $file_name, |
151 | 279 |
); |
152 | 280 |
|
... | ... | |
189 | 317 |
|
190 | 318 |
=head1 NAME |
191 | 319 |
|
192 |
SL::Controller::ZUGFeRD |
|
193 |
Controller for importing ZUGFeRD pdf files to kivitendo |
|
320 |
SL::Controller::ZUGFeRD - Controller for importing ZUGFeRD PDF files or XML invoices to kivitendo |
|
194 | 321 |
|
195 | 322 |
=head1 FUNCTIONS |
196 | 323 |
|
... | ... | |
200 | 327 |
|
201 | 328 |
Creates a web from with a single upload dialog. |
202 | 329 |
|
203 |
=item C<action_import_zugferd $pdf>
|
|
330 |
=item C<action_import_zugferd $file>
|
|
204 | 331 |
|
205 |
Expects a single pdf with ZUGFeRD 2.0 metadata. |
|
206 |
Checks if the param <C$pdf> is set and a valid pdf file. |
|
207 |
Calls helper functions to validate and extract the ZUGFeRD data. |
|
208 |
Needs a valid VAT ID (EU) for this vendor and |
|
209 |
expects one ap template for this vendor in kivitendo. |
|
332 |
Expects a single PDF with ZUGFeRD, Factur-X or XRechnung |
|
333 |
metadata. Alternatively, it can also process said data as a |
|
334 |
standalone XML file. |
|
210 | 335 |
|
211 |
Parses some basic ZUGFeRD data (invnumber, total net amount, |
|
212 |
transdate, duedate, vendor VAT ID, IBAN) and uses the first |
|
213 |
found ap template for this vendor to fill this template with |
|
214 |
ZUGFeRD data. |
|
215 |
If the vendor's master data contain a IBAN and the |
|
216 |
ZUGFeRD record has a IBAN also these values will be compared. |
|
217 |
If they don't match a warning will be writte in ap.notes. |
|
218 |
Furthermore the ZUGFeRD type code will be written to ap.notes. |
|
219 |
No callback implemented. |
|
336 |
Checks if the param <C$pdf> is set and a valid PDF or XML |
|
337 |
file. Calls helper functions to validate and extract the |
|
338 |
ZUGFeRD/Factur-X/XRechnung data. The invoice needs to have a |
|
339 |
valid VAT ID (EU) or tax number (Germany) and a vendor with |
|
340 |
the same VAT ID or tax number enrolled in Kivitendo. |
|
341 |
|
|
342 |
It parses some basic ZUGFeRD data (invnumber, total net amount, |
|
343 |
transdate, duedate, vendor VAT ID, IBAN, etc.) and also |
|
344 |
extracts the invoice's items. |
|
345 |
|
|
346 |
If the invoice has a IBAN also, it will be be compared to the |
|
347 |
IBAN saved for the vendor (if any). If they don't match a |
|
348 |
warning will be writte in ap.notes. Furthermore the ZUGFeRD |
|
349 |
type code will be written to ap.notes. No callback |
|
350 |
implemented. |
|
220 | 351 |
|
221 | 352 |
=back |
222 | 353 |
|
223 |
=head1 TODO and CAVEAT |
|
354 |
=head1 CAVEAT |
|
355 |
|
|
356 |
This is just a very basic Parser for ZUGFeRD/Factur-X/XRechnung invoices. |
|
357 |
We assume that the invoice's creator is a company with a valid |
|
358 |
European VAT ID or German tax number and enrolled in |
|
359 |
Kivitendo. Currently, implementation is a bit hacky because |
|
360 |
invoice import uses AP record templates as a vessel for |
|
361 |
generating the AP record form with the imported data filled |
|
362 |
in. |
|
363 |
|
|
364 |
=head1 TODO |
|
365 |
|
|
366 |
This implementation could be improved as follows: |
|
367 |
|
|
368 |
=over 4 |
|
369 |
|
|
370 |
=item Direct creation of the filled in AP record form |
|
371 |
|
|
372 |
Creating an AP record template in the database is not |
|
373 |
very elegant, since it will spam the database with record |
|
374 |
templates that become redundant once the invoice has been |
|
375 |
booked. It would be preferable to fill in the form directly. |
|
376 |
|
|
377 |
=item Automatic upload of invoice |
|
378 |
|
|
379 |
Right now, one has to use the "Book and upload" button to |
|
380 |
upload the raw invoice document to WebDAV or DMS and attach it |
|
381 |
to the invoice. This should be a simple matter of setting a |
|
382 |
check box when uploading. |
|
383 |
|
|
384 |
=item Handling of vendor invoices |
|
385 |
|
|
386 |
There is no reason this functionality could not be used to |
|
387 |
import vendor invoices as well. Since these tend to be very |
|
388 |
lengthy, the ability to import them would be very beneficial. |
|
224 | 389 |
|
225 |
This is just a very basic Parser for ZUGFeRD data. |
|
226 |
We assume that the ZUGFeRD generator is a company with a |
|
227 |
valid European VAT ID. Furthermore this vendor needs only |
|
228 |
one and just noe ap template (the first match will be used). |
|
390 |
=item Automatic handling of payment purpose |
|
229 | 391 |
|
230 |
The ZUGFeRD data should also be extracted in the helper package |
|
231 |
and maybe a model should be used for this. |
|
232 |
The user should set one ap template as a default for ZUGFeRD. |
|
233 |
The ZUGFeRD pdf should be written to WebDAV or DMS. |
|
234 | 392 |
If the ZUGFeRD data has a payment purpose set, this should |
235 | 393 |
be the default for the SEPA-XML export. |
236 | 394 |
|
395 |
=back |
|
396 |
|
|
397 |
=head1 AUTHORS |
|
398 |
|
|
399 |
=over 4 |
|
400 |
|
|
401 |
=item Jan Büren E<lt>jan@kivitendo-premium.deE<gt>, |
|
237 | 402 |
|
238 |
=head1 AUTHOR
|
|
403 |
=item Johannes Graßler E<lt>info@computer-grassler.deE<gt>,
|
|
239 | 404 |
|
240 |
Jan Büren E<lt>jan@kivitendo-premium.deE<gt>,
|
|
405 |
=back
|
|
241 | 406 |
|
242 | 407 |
=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', onchange='kivi.ZUGFeRD.update_file_name();') %] |
|
13 |
[% L.input_tag('file', '', type => 'file', accept => '.pdf,.xml', onchange='kivi.ZUGFeRD.update_file_name();') %]
|
|
14 | 14 |
[% L.hidden_tag('file_name', '') %] |
15 | 15 |
</form> |
Auch abrufbar als: Unified diff
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:
unterstuetzt.
Warnungen, saemtliche XML-Anhaenge an PDF-Dateien werden
automatisch erkannt und verarbeitet (egal ob es sich um
ZUGFeRD/Faktur-X- oder XRechnung-Daten handelt).
Rechnungen im XRechnung-Format wichtig).
Formular eingetragen, nicht mehr nur Rechnungsnummer und
Datum.
existieren. Eine eigene Belegvorlage wird fuer jede
importierte Rechnung automatisch angelegt.
aus den Verbindlichkeitskonten ausgewaehlt.
Enthaelt die Rechnung keines, wird es auf das Rechnungsdatum
gesetzt.