| line |
stmt |
bran |
cond |
sub |
pod |
time |
code |
|
1
|
|
|
|
|
|
|
package PDF::FacturX::XML; |
|
2
|
9
|
|
|
9
|
|
1002442
|
use strict; |
|
|
9
|
|
|
|
|
20
|
|
|
|
9
|
|
|
|
|
373
|
|
|
3
|
9
|
|
|
9
|
|
61
|
use warnings; |
|
|
9
|
|
|
|
|
17
|
|
|
|
9
|
|
|
|
|
512
|
|
|
4
|
9
|
|
|
9
|
|
51
|
use utf8; |
|
|
9
|
|
|
|
|
16
|
|
|
|
9
|
|
|
|
|
103
|
|
|
5
|
9
|
|
|
9
|
|
377
|
use Exporter 'import'; |
|
|
9
|
|
|
|
|
21
|
|
|
|
9
|
|
|
|
|
364
|
|
|
6
|
9
|
|
|
9
|
|
4270
|
use XML::LibXML; |
|
|
9
|
|
|
|
|
345705
|
|
|
|
9
|
|
|
|
|
61
|
|
|
7
|
9
|
|
|
9
|
|
1885
|
use Encode qw(decode_utf8); |
|
|
9
|
|
|
|
|
21
|
|
|
|
9
|
|
|
|
|
1176
|
|
|
8
|
9
|
|
|
9
|
|
5835
|
use File::ShareDir qw(dist_dir); |
|
|
9
|
|
|
|
|
331089
|
|
|
|
9
|
|
|
|
|
767
|
|
|
9
|
9
|
|
|
9
|
|
104
|
use File::Spec; |
|
|
9
|
|
|
|
|
28
|
|
|
|
9
|
|
|
|
|
49554
|
|
|
10
|
|
|
|
|
|
|
|
|
11
|
|
|
|
|
|
|
our $VERSION = '0.01'; |
|
12
|
|
|
|
|
|
|
|
|
13
|
|
|
|
|
|
|
our @EXPORT_OK = qw(build_xml guideline_id validate_xml xsd_root_for); |
|
14
|
|
|
|
|
|
|
|
|
15
|
|
|
|
|
|
|
my %NS = ( |
|
16
|
|
|
|
|
|
|
rsm => 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100', |
|
17
|
|
|
|
|
|
|
qdt => 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100', |
|
18
|
|
|
|
|
|
|
ram => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100', |
|
19
|
|
|
|
|
|
|
xsi => 'http://www.w3.org/2001/XMLSchema-instance', |
|
20
|
|
|
|
|
|
|
udt => 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100', |
|
21
|
|
|
|
|
|
|
); |
|
22
|
|
|
|
|
|
|
|
|
23
|
|
|
|
|
|
|
my %GUIDELINE = ( |
|
24
|
|
|
|
|
|
|
minimum => 'urn:factur-x.eu:1p0:minimum', |
|
25
|
|
|
|
|
|
|
basicwl => 'urn:factur-x.eu:1p0:basicwl', |
|
26
|
|
|
|
|
|
|
basic => 'urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic', |
|
27
|
|
|
|
|
|
|
en16931 => 'urn:cen.eu:en16931:2017', |
|
28
|
|
|
|
|
|
|
); |
|
29
|
|
|
|
|
|
|
|
|
30
|
|
|
|
|
|
|
my %VALID_VAT_CAT = map { $_ => 1 } qw(S Z E AE K G L M O); |
|
31
|
|
|
|
|
|
|
|
|
32
|
|
|
|
|
|
|
sub guideline_id { |
|
33
|
2
|
|
|
2
|
1
|
186037
|
my ($profile) = @_; |
|
34
|
2
|
|
50
|
|
|
16
|
return $GUIDELINE{ $profile // 'basic' }; |
|
35
|
|
|
|
|
|
|
} |
|
36
|
|
|
|
|
|
|
|
|
37
|
|
|
|
|
|
|
# Résout le dossier XSD pour un profil donné. En mode installé, utilise |
|
38
|
|
|
|
|
|
|
# File::ShareDir; en mode dev (avant install), tombe sur le `share/` à |
|
39
|
|
|
|
|
|
|
# la racine du checkout via le chemin de ce module. |
|
40
|
|
|
|
|
|
|
sub xsd_root_for { |
|
41
|
6
|
|
|
6
|
1
|
163558
|
my ($profile) = @_; |
|
42
|
6
|
|
50
|
|
|
36
|
$profile //= 'basic'; |
|
43
|
|
|
|
|
|
|
die "Profil Factur-X inconnu: $profile\n" |
|
44
|
6
|
50
|
|
|
|
21
|
unless exists $GUIDELINE{$profile}; |
|
45
|
|
|
|
|
|
|
|
|
46
|
6
|
|
|
|
|
9
|
my $share_dir; |
|
47
|
6
|
50
|
|
|
|
10
|
eval { $share_dir = dist_dir('PDF-FacturX'); 1 } or do { |
|
|
6
|
|
|
|
|
39
|
|
|
|
6
|
|
|
|
|
970
|
|
|
48
|
|
|
|
|
|
|
# Fallback dev : ../../../../share relatif à lib/PDF/FacturX/XML.pm |
|
49
|
0
|
|
|
|
|
0
|
my $here = __FILE__; |
|
50
|
0
|
|
|
|
|
0
|
my @parts = File::Spec->splitpath($here); |
|
51
|
0
|
|
|
|
|
0
|
my $dir = $parts[1]; |
|
52
|
0
|
|
|
|
|
0
|
$share_dir = File::Spec->catdir($dir, '..', '..', '..', 'share'); |
|
53
|
|
|
|
|
|
|
}; |
|
54
|
6
|
|
|
|
|
81
|
return File::Spec->catdir($share_dir, 'xsd', $profile); |
|
55
|
|
|
|
|
|
|
} |
|
56
|
|
|
|
|
|
|
|
|
57
|
|
|
|
|
|
|
############################################################################### |
|
58
|
|
|
|
|
|
|
# build_xml($invoice_hashref, $profile) |
|
59
|
|
|
|
|
|
|
# |
|
60
|
|
|
|
|
|
|
# Génère le XML CrossIndustryInvoice (Factur-X 1.0.8) pour la facture |
|
61
|
|
|
|
|
|
|
# décrite par $invoice. Profil par défaut : 'basic'. Lève une exception |
|
62
|
|
|
|
|
|
|
# si le hash ne respecte pas le minimum requis pour le profil. |
|
63
|
|
|
|
|
|
|
# |
|
64
|
|
|
|
|
|
|
# Format attendu : |
|
65
|
|
|
|
|
|
|
# |
|
66
|
|
|
|
|
|
|
# { |
|
67
|
|
|
|
|
|
|
# number => 'FA-2026-0042', |
|
68
|
|
|
|
|
|
|
# date => '2026-04-19', # ISO YYYY-MM-DD |
|
69
|
|
|
|
|
|
|
# due_date => '2026-05-19', |
|
70
|
|
|
|
|
|
|
# currency => 'EUR', |
|
71
|
|
|
|
|
|
|
# type_code => 380, # 380 = facture ; 381 = avoir |
|
72
|
|
|
|
|
|
|
# notes => '...', |
|
73
|
|
|
|
|
|
|
# |
|
74
|
|
|
|
|
|
|
# seller => { name, address_1, address_2, postcode, city, country, |
|
75
|
|
|
|
|
|
|
# siret, vat }, |
|
76
|
|
|
|
|
|
|
# buyer => { name, address_1, address_2, postcode, city, country }, |
|
77
|
|
|
|
|
|
|
# |
|
78
|
|
|
|
|
|
|
# lines => [ |
|
79
|
|
|
|
|
|
|
# { name, qty, unit, unit_price, |
|
80
|
|
|
|
|
|
|
# vat_rate, vat_cat, # vat_cat : S|Z|E|AE|K|G|L|M|O |
|
81
|
|
|
|
|
|
|
# vat_exemption_reason, # optionnel, par ligne |
|
82
|
|
|
|
|
|
|
# }, ... |
|
83
|
|
|
|
|
|
|
# ], |
|
84
|
|
|
|
|
|
|
# |
|
85
|
|
|
|
|
|
|
# allowances => [ |
|
86
|
|
|
|
|
|
|
# { amount, reason, vat_rate, vat_cat, is_charge => 0|1 }, ... |
|
87
|
|
|
|
|
|
|
# ], |
|
88
|
|
|
|
|
|
|
# |
|
89
|
|
|
|
|
|
|
# vat_exemption_reason => '...', # global, par défaut pour les lignes |
|
90
|
|
|
|
|
|
|
# # exonérées sans motif explicite. |
|
91
|
|
|
|
|
|
|
# |
|
92
|
|
|
|
|
|
|
# payment => { terms, iban, bic }, |
|
93
|
|
|
|
|
|
|
# } |
|
94
|
|
|
|
|
|
|
############################################################################### |
|
95
|
|
|
|
|
|
|
sub build_xml { |
|
96
|
20
|
|
|
20
|
1
|
649575
|
my ($invoice, $profile) = @_; |
|
97
|
20
|
|
50
|
|
|
69
|
$profile //= 'basic'; |
|
98
|
20
|
|
|
|
|
110
|
_validate_invoice($invoice, $profile); |
|
99
|
|
|
|
|
|
|
|
|
100
|
8
|
|
|
|
|
231
|
my $doc = XML::LibXML::Document->new('1.0', 'UTF-8'); |
|
101
|
8
|
|
|
|
|
158
|
my $root = $doc->createElementNS($NS{rsm}, 'rsm:CrossIndustryInvoice'); |
|
102
|
8
|
|
|
|
|
63
|
$root->setNamespace($NS{qdt}, 'qdt', 0); |
|
103
|
8
|
|
|
|
|
259
|
$root->setNamespace($NS{ram}, 'ram', 0); |
|
104
|
8
|
|
|
|
|
97
|
$root->setNamespace($NS{xsi}, 'xsi', 0); |
|
105
|
8
|
|
|
|
|
99
|
$root->setNamespace($NS{udt}, 'udt', 0); |
|
106
|
8
|
|
|
|
|
97
|
$doc->setDocumentElement($root); |
|
107
|
|
|
|
|
|
|
|
|
108
|
8
|
|
|
|
|
176
|
_build_document_context($root, $profile); |
|
109
|
8
|
|
|
|
|
404
|
_build_exchanged_document($root, $invoice); |
|
110
|
8
|
|
|
|
|
171
|
_build_trade_transaction($root, $invoice, $profile); |
|
111
|
|
|
|
|
|
|
|
|
112
|
8
|
|
|
|
|
436
|
return decode_utf8($doc->toString(1)); |
|
113
|
|
|
|
|
|
|
} |
|
114
|
|
|
|
|
|
|
|
|
115
|
|
|
|
|
|
|
############################################################################### |
|
116
|
|
|
|
|
|
|
# validate_xml($xml_string, $profile, $xsd_root?) |
|
117
|
|
|
|
|
|
|
# → (1, 'OK') ou (0, "message d'erreur") |
|
118
|
|
|
|
|
|
|
# |
|
119
|
|
|
|
|
|
|
# Si $xsd_root n'est pas fourni, le résoud automatiquement via File::ShareDir. |
|
120
|
|
|
|
|
|
|
############################################################################### |
|
121
|
|
|
|
|
|
|
sub validate_xml { |
|
122
|
5
|
|
|
5
|
1
|
1039
|
my ($xml_string, $profile, $xsd_root) = @_; |
|
123
|
5
|
|
50
|
|
|
14
|
$profile //= 'basic'; |
|
124
|
5
|
|
33
|
|
|
28
|
$xsd_root //= xsd_root_for($profile); |
|
125
|
5
|
50
|
33
|
|
|
83
|
return (0, "xsd_root introuvable : $xsd_root") |
|
126
|
|
|
|
|
|
|
unless $xsd_root && -d $xsd_root; |
|
127
|
|
|
|
|
|
|
|
|
128
|
|
|
|
|
|
|
# Le fichier racine est celui SANS suffixe `urn_un_unece`. Les 3 autres |
|
129
|
|
|
|
|
|
|
# XSD sont inclus via xs:include / xs:import avec schemaLocation relatif. |
|
130
|
5
|
|
|
|
|
1062
|
my ($main_xsd) = grep { !/urn_un_unece/i } glob("$xsd_root/Factur-X_*.xsd"); |
|
|
20
|
|
|
|
|
91
|
|
|
131
|
5
|
50
|
33
|
|
|
109
|
return (0, "XSD racine introuvable dans $xsd_root") |
|
132
|
|
|
|
|
|
|
unless $main_xsd && -r $main_xsd; |
|
133
|
|
|
|
|
|
|
|
|
134
|
5
|
|
|
|
|
11
|
my $schema = eval { XML::LibXML::Schema->new(location => $main_xsd) }; |
|
|
5
|
|
|
|
|
41
|
|
|
135
|
5
|
50
|
|
|
|
8009
|
return (0, "chargement XSD KO : $@") unless $schema; |
|
136
|
|
|
|
|
|
|
|
|
137
|
5
|
|
|
|
|
11
|
my $doc = eval { XML::LibXML->load_xml(string => $xml_string) }; |
|
|
5
|
|
|
|
|
27
|
|
|
138
|
5
|
50
|
|
|
|
2155
|
return (0, "parse XML KO : $@") unless $doc; |
|
139
|
|
|
|
|
|
|
|
|
140
|
5
|
100
|
|
|
|
30
|
eval { $schema->validate($doc); 1 } or do { |
|
|
5
|
|
|
|
|
392
|
|
|
|
4
|
|
|
|
|
14
|
|
|
141
|
1
|
|
50
|
|
|
150
|
my $err = $@ // 'erreur inconnue'; |
|
142
|
1
|
|
|
|
|
5
|
chomp $err; |
|
143
|
1
|
|
|
|
|
240
|
return (0, $err); |
|
144
|
|
|
|
|
|
|
}; |
|
145
|
4
|
|
|
|
|
403
|
return (1, 'OK'); |
|
146
|
|
|
|
|
|
|
} |
|
147
|
|
|
|
|
|
|
|
|
148
|
|
|
|
|
|
|
############################################################################### |
|
149
|
|
|
|
|
|
|
# Validation maison (couche 1) : champs requis + formats. |
|
150
|
|
|
|
|
|
|
# Lève une exception avec un message FR explicite. Le XSD valide ensuite |
|
151
|
|
|
|
|
|
|
# (couche 2) les contraintes structurelles que ce code ne sait pas exprimer |
|
152
|
|
|
|
|
|
|
# proprement (ordre des éléments, business rules BR-CO-*). |
|
153
|
|
|
|
|
|
|
############################################################################### |
|
154
|
|
|
|
|
|
|
sub _validate_invoice { |
|
155
|
20
|
|
|
20
|
|
37
|
my ($inv, $profile) = @_; |
|
156
|
|
|
|
|
|
|
die "Profil Factur-X inconnu: $profile (attendu: " |
|
157
|
|
|
|
|
|
|
. join('|', sort keys %GUIDELINE) . ")\n" |
|
158
|
20
|
100
|
|
|
|
97
|
unless exists $GUIDELINE{$profile}; |
|
159
|
|
|
|
|
|
|
|
|
160
|
18
|
100
|
|
|
|
72
|
die "invoice doit être un hashref\n" |
|
161
|
|
|
|
|
|
|
unless ref $inv eq 'HASH'; |
|
162
|
|
|
|
|
|
|
|
|
163
|
17
|
|
|
|
|
45
|
for my $field (qw(number date)) { |
|
164
|
|
|
|
|
|
|
die "champ requis manquant : $field\n" |
|
165
|
33
|
100
|
66
|
|
|
163
|
unless defined $inv->{$field} && length $inv->{$field}; |
|
166
|
|
|
|
|
|
|
} |
|
167
|
|
|
|
|
|
|
|
|
168
|
16
|
|
|
|
|
55
|
_check_iso_date('date', $inv->{date}); |
|
169
|
15
|
100
|
|
|
|
82
|
_check_iso_date('due_date', $inv->{due_date}) if defined $inv->{due_date}; |
|
170
|
|
|
|
|
|
|
|
|
171
|
14
|
|
|
|
|
24
|
for my $party (qw(seller buyer)) { |
|
172
|
|
|
|
|
|
|
die "champ requis manquant : $party\n" |
|
173
|
26
|
100
|
|
|
|
76
|
unless ref $inv->{$party} eq 'HASH'; |
|
174
|
|
|
|
|
|
|
die "champ requis manquant : $party.name\n" |
|
175
|
25
|
100
|
66
|
|
|
127
|
unless defined $inv->{$party}{name} && length $inv->{$party}{name}; |
|
176
|
|
|
|
|
|
|
} |
|
177
|
|
|
|
|
|
|
|
|
178
|
11
|
50
|
|
|
|
34
|
if (defined $inv->{currency}) { |
|
179
|
|
|
|
|
|
|
die "currency : code ISO 4217 attendu (3 lettres maj), reçu : $inv->{currency}\n" |
|
180
|
11
|
100
|
|
|
|
70
|
unless $inv->{currency} =~ /^[A-Z]{3}$/; |
|
181
|
|
|
|
|
|
|
} |
|
182
|
|
|
|
|
|
|
|
|
183
|
|
|
|
|
|
|
# Lignes : requises pour profils basic et en16931, optionnelles pour |
|
184
|
|
|
|
|
|
|
# minimum et basicwl. Si présentes, on valide leur structure. |
|
185
|
10
|
|
100
|
|
|
210
|
my $needs_lines = ($profile eq 'basic' || $profile eq 'en16931'); |
|
186
|
10
|
100
|
|
|
|
32
|
if ($needs_lines) { |
|
187
|
|
|
|
|
|
|
die "champ requis manquant pour profil $profile : lines (tableau non vide)\n" |
|
188
|
7
|
100
|
66
|
|
|
51
|
unless ref $inv->{lines} eq 'ARRAY' && @{ $inv->{lines} }; |
|
|
6
|
|
|
|
|
22
|
|
|
189
|
|
|
|
|
|
|
} |
|
190
|
9
|
50
|
|
|
|
30
|
if (ref $inv->{lines} eq 'ARRAY') { |
|
191
|
9
|
|
|
|
|
20
|
my $i = 0; |
|
192
|
9
|
|
|
|
|
17
|
for my $line (@{ $inv->{lines} }) { |
|
|
9
|
|
|
|
|
39
|
|
|
193
|
14
|
|
|
|
|
23
|
$i++; |
|
194
|
14
|
50
|
|
|
|
56
|
die "ligne #$i : doit être un hashref\n" |
|
195
|
|
|
|
|
|
|
unless ref $line eq 'HASH'; |
|
196
|
14
|
|
|
|
|
25
|
for my $f (qw(name qty unit_price)) { |
|
197
|
|
|
|
|
|
|
die "ligne #$i : champ requis manquant : $f\n" |
|
198
|
42
|
50
|
33
|
|
|
152
|
unless defined $line->{$f} && length $line->{$f}; |
|
199
|
|
|
|
|
|
|
} |
|
200
|
14
|
|
50
|
|
|
56
|
my $cat = $line->{vat_cat} // 'S'; |
|
201
|
|
|
|
|
|
|
die "ligne #$i : vat_cat invalide : $cat (attendu: " |
|
202
|
|
|
|
|
|
|
. join('|', sort keys %VALID_VAT_CAT) . ")\n" |
|
203
|
14
|
100
|
|
|
|
62
|
unless $VALID_VAT_CAT{$cat}; |
|
204
|
|
|
|
|
|
|
} |
|
205
|
|
|
|
|
|
|
} |
|
206
|
|
|
|
|
|
|
|
|
207
|
8
|
50
|
|
|
|
70
|
if (ref $inv->{allowances} eq 'ARRAY') { |
|
208
|
0
|
|
|
|
|
0
|
my $i = 0; |
|
209
|
0
|
|
|
|
|
0
|
for my $a (@{ $inv->{allowances} }) { |
|
|
0
|
|
|
|
|
0
|
|
|
210
|
0
|
|
|
|
|
0
|
$i++; |
|
211
|
0
|
0
|
|
|
|
0
|
die "allowance #$i : doit être un hashref\n" |
|
212
|
|
|
|
|
|
|
unless ref $a eq 'HASH'; |
|
213
|
|
|
|
|
|
|
die "allowance #$i : champ requis manquant : amount\n" |
|
214
|
0
|
0
|
|
|
|
0
|
unless defined $a->{amount}; |
|
215
|
0
|
|
0
|
|
|
0
|
my $cat = $a->{vat_cat} // 'S'; |
|
216
|
|
|
|
|
|
|
die "allowance #$i : vat_cat invalide : $cat\n" |
|
217
|
0
|
0
|
|
|
|
0
|
unless $VALID_VAT_CAT{$cat}; |
|
218
|
|
|
|
|
|
|
} |
|
219
|
|
|
|
|
|
|
} |
|
220
|
|
|
|
|
|
|
|
|
221
|
8
|
|
|
|
|
17
|
return 1; |
|
222
|
|
|
|
|
|
|
} |
|
223
|
|
|
|
|
|
|
|
|
224
|
|
|
|
|
|
|
sub _check_iso_date { |
|
225
|
25
|
|
|
25
|
|
48
|
my ($name, $val) = @_; |
|
226
|
25
|
50
|
|
|
|
56
|
return unless defined $val; |
|
227
|
25
|
100
|
|
|
|
160
|
die "$name : format ISO YYYY-MM-DD attendu, reçu : $val\n" |
|
228
|
|
|
|
|
|
|
unless $val =~ /^\d{4}-\d{2}-\d{2}$/; |
|
229
|
|
|
|
|
|
|
} |
|
230
|
|
|
|
|
|
|
|
|
231
|
|
|
|
|
|
|
# ── rsm:ExchangedDocumentContext ───────────────────────────────────────────── |
|
232
|
|
|
|
|
|
|
sub _build_document_context { |
|
233
|
8
|
|
|
8
|
|
21
|
my ($root, $profile) = @_; |
|
234
|
8
|
|
|
|
|
30
|
my $ctx = _el($root, 'rsm:ExchangedDocumentContext'); |
|
235
|
8
|
|
|
|
|
447
|
my $gp = _el($ctx, 'ram:GuidelineSpecifiedDocumentContextParameter'); |
|
236
|
8
|
|
|
|
|
108
|
_el($gp, 'ram:ID', $GUIDELINE{$profile}); |
|
237
|
|
|
|
|
|
|
} |
|
238
|
|
|
|
|
|
|
|
|
239
|
|
|
|
|
|
|
# ── rsm:ExchangedDocument ──────────────────────────────────────────────────── |
|
240
|
|
|
|
|
|
|
sub _build_exchanged_document { |
|
241
|
8
|
|
|
8
|
|
22
|
my ($root, $inv) = @_; |
|
242
|
8
|
|
|
|
|
17
|
my $doc = _el($root, 'rsm:ExchangedDocument'); |
|
243
|
8
|
|
|
|
|
100
|
_el($doc, 'ram:ID', $inv->{number}); |
|
244
|
8
|
|
50
|
|
|
165
|
_el($doc, 'ram:TypeCode', $inv->{type_code} // 380); |
|
245
|
8
|
|
|
|
|
170
|
my $dt = _el($doc, 'ram:IssueDateTime'); |
|
246
|
8
|
|
|
|
|
139
|
_el($dt, 'udt:DateTimeString', _fmt_date($inv->{date}), format => '102'); |
|
247
|
|
|
|
|
|
|
|
|
248
|
8
|
50
|
33
|
|
|
166
|
if (defined $inv->{notes} && length $inv->{notes}) { |
|
249
|
0
|
|
|
|
|
0
|
my $n = _el($doc, 'ram:IncludedNote'); |
|
250
|
0
|
|
|
|
|
0
|
_el($n, 'ram:Content', $inv->{notes}); |
|
251
|
|
|
|
|
|
|
} |
|
252
|
|
|
|
|
|
|
} |
|
253
|
|
|
|
|
|
|
|
|
254
|
|
|
|
|
|
|
# ── rsm:SupplyChainTradeTransaction ────────────────────────────────────────── |
|
255
|
|
|
|
|
|
|
sub _build_trade_transaction { |
|
256
|
8
|
|
|
8
|
|
20
|
my ($root, $inv, $profile) = @_; |
|
257
|
8
|
|
|
|
|
18
|
my $trx = _el($root, 'rsm:SupplyChainTradeTransaction'); |
|
258
|
|
|
|
|
|
|
|
|
259
|
8
|
|
100
|
|
|
103
|
my $with_lines = ($profile eq 'basic' || $profile eq 'en16931'); |
|
260
|
8
|
100
|
|
|
|
33
|
if ($with_lines) { |
|
261
|
5
|
|
|
|
|
11
|
my $i = 0; |
|
262
|
5
|
50
|
|
|
|
24
|
for my $line (@{ $inv->{lines} || [] }) { |
|
|
5
|
|
|
|
|
23
|
|
|
263
|
8
|
|
|
|
|
257
|
$i++; |
|
264
|
8
|
|
|
|
|
25
|
_build_line_item($trx, $line, $i); |
|
265
|
|
|
|
|
|
|
} |
|
266
|
|
|
|
|
|
|
} |
|
267
|
|
|
|
|
|
|
|
|
268
|
8
|
|
|
|
|
477
|
_build_header_agreement($trx, $inv, $profile); |
|
269
|
8
|
|
|
|
|
187
|
_build_header_delivery($trx, $inv, $profile); |
|
270
|
8
|
|
|
|
|
247
|
_build_header_settlement($trx, $inv, $profile); |
|
271
|
|
|
|
|
|
|
} |
|
272
|
|
|
|
|
|
|
|
|
273
|
|
|
|
|
|
|
# Le XSD MINIMUM définit HeaderTradeDeliveryType comme un complexType vide : |
|
274
|
|
|
|
|
|
|
# on ne peut rien y mettre. À partir de BASIC WL le type accepte (notamment) |
|
275
|
|
|
|
|
|
|
# ActualDeliverySupplyChainEvent ; PEPPOL-EN16931-R008 interdit les |
|
276
|
|
|
|
|
|
|
# éléments vides → on pose la date effective (repli date de facture). |
|
277
|
|
|
|
|
|
|
sub _build_header_delivery { |
|
278
|
8
|
|
|
8
|
|
19
|
my ($trx, $inv, $profile) = @_; |
|
279
|
8
|
|
|
|
|
33
|
my $dlv = _el($trx, 'ram:ApplicableHeaderTradeDelivery'); |
|
280
|
8
|
100
|
|
|
|
95
|
return if $profile eq 'minimum'; |
|
281
|
6
|
|
33
|
|
|
33
|
my $date = $inv->{delivery_date} // $inv->{date}; |
|
282
|
6
|
50
|
|
|
|
19
|
return unless $date; |
|
283
|
6
|
|
|
|
|
15
|
my $evt = _el($dlv, 'ram:ActualDeliverySupplyChainEvent'); |
|
284
|
6
|
|
|
|
|
66
|
my $occ = _el($evt, 'ram:OccurrenceDateTime'); |
|
285
|
6
|
|
|
|
|
69
|
_el($occ, 'udt:DateTimeString', _fmt_date($date), format => '102'); |
|
286
|
|
|
|
|
|
|
} |
|
287
|
|
|
|
|
|
|
|
|
288
|
|
|
|
|
|
|
sub _build_line_item { |
|
289
|
8
|
|
|
8
|
|
21
|
my ($trx, $line, $line_id) = @_; |
|
290
|
8
|
|
|
|
|
16
|
my $item = _el($trx, 'ram:IncludedSupplyChainTradeLineItem'); |
|
291
|
|
|
|
|
|
|
|
|
292
|
8
|
|
|
|
|
121
|
my $adl = _el($item, 'ram:AssociatedDocumentLineDocument'); |
|
293
|
8
|
|
|
|
|
98
|
_el($adl, 'ram:LineID', $line_id); |
|
294
|
|
|
|
|
|
|
|
|
295
|
8
|
|
|
|
|
152
|
my $prod = _el($item, 'ram:SpecifiedTradeProduct'); |
|
296
|
8
|
|
|
|
|
102
|
_el($prod, 'ram:Name', $line->{name}); |
|
297
|
|
|
|
|
|
|
|
|
298
|
8
|
|
|
|
|
169
|
my $agr = _el($item, 'ram:SpecifiedLineTradeAgreement'); |
|
299
|
8
|
|
|
|
|
161
|
my $price = _el($agr, 'ram:NetPriceProductTradePrice'); |
|
300
|
8
|
|
|
|
|
114
|
_el($price, 'ram:ChargeAmount', _fmt_amt($line->{unit_price})); |
|
301
|
|
|
|
|
|
|
|
|
302
|
8
|
|
|
|
|
150
|
my $dlv = _el($item, 'ram:SpecifiedLineTradeDelivery'); |
|
303
|
|
|
|
|
|
|
_el($dlv, 'ram:BilledQuantity', _fmt_qty($line->{qty}), |
|
304
|
8
|
|
50
|
|
|
148
|
unitCode => ($line->{unit} || 'C62')); |
|
305
|
|
|
|
|
|
|
|
|
306
|
8
|
|
|
|
|
157
|
my $set = _el($item, 'ram:SpecifiedLineTradeSettlement'); |
|
307
|
8
|
|
|
|
|
94
|
my $tax = _el($set, 'ram:ApplicableTradeTax'); |
|
308
|
8
|
|
50
|
|
|
127
|
my $cat = $line->{vat_cat} || 'S'; |
|
309
|
8
|
|
|
|
|
24
|
_el($tax, 'ram:TypeCode', 'VAT'); |
|
310
|
8
|
|
|
|
|
148
|
_el($tax, 'ram:CategoryCode', $cat); |
|
311
|
|
|
|
|
|
|
# BR-O-05 : pas de RateApplicablePercent pour la catégorie 'O'. |
|
312
|
8
|
50
|
50
|
|
|
200
|
_el($tax, 'ram:RateApplicablePercent', _fmt_amt($line->{vat_rate} || 0)) |
|
313
|
|
|
|
|
|
|
unless $cat eq 'O'; |
|
314
|
|
|
|
|
|
|
|
|
315
|
8
|
|
50
|
|
|
207
|
my $amount = ($line->{qty} || 0) * ($line->{unit_price} || 0); |
|
|
|
|
50
|
|
|
|
|
|
316
|
8
|
|
|
|
|
45
|
my $sum = _el($set, 'ram:SpecifiedTradeSettlementLineMonetarySummation'); |
|
317
|
8
|
|
|
|
|
98
|
_el($sum, 'ram:LineTotalAmount', _fmt_amt($amount)); |
|
318
|
|
|
|
|
|
|
} |
|
319
|
|
|
|
|
|
|
|
|
320
|
|
|
|
|
|
|
sub _build_header_agreement { |
|
321
|
8
|
|
|
8
|
|
22
|
my ($trx, $inv, $profile) = @_; |
|
322
|
8
|
|
|
|
|
52
|
my $agr = _el($trx, 'ram:ApplicableHeaderTradeAgreement'); |
|
323
|
8
|
|
50
|
|
|
113
|
_build_trade_party($agr, 'ram:SellerTradeParty', $inv->{seller} || {}, 1, $profile); |
|
324
|
8
|
|
50
|
|
|
308
|
_build_trade_party($agr, 'ram:BuyerTradeParty', $inv->{buyer} || {}, 0, $profile); |
|
325
|
|
|
|
|
|
|
} |
|
326
|
|
|
|
|
|
|
|
|
327
|
|
|
|
|
|
|
# Ajoute SIRET + n° TVA seulement côté vendeur. Le profil MINIMUM restreint |
|
328
|
|
|
|
|
|
|
# l'adresse postale à CountryID seulement (TradeAddressType minimum). |
|
329
|
|
|
|
|
|
|
sub _build_trade_party { |
|
330
|
16
|
|
|
16
|
|
43
|
my ($parent, $qname, $party, $is_seller, $profile) = @_; |
|
331
|
16
|
|
50
|
|
|
36
|
$profile //= 'basic'; |
|
332
|
16
|
|
|
|
|
36
|
my $p = _el($parent, $qname); |
|
333
|
16
|
|
50
|
|
|
406
|
_el($p, 'ram:Name', $party->{name} || ''); |
|
334
|
|
|
|
|
|
|
|
|
335
|
16
|
100
|
100
|
|
|
306
|
if ($is_seller && $party->{siret}) { |
|
336
|
6
|
|
|
|
|
25
|
my $org = _el($p, 'ram:SpecifiedLegalOrganization'); |
|
337
|
|
|
|
|
|
|
# schemeID 0002 = SIRENE (France) |
|
338
|
6
|
|
|
|
|
70
|
_el($org, 'ram:ID', $party->{siret}, schemeID => '0002'); |
|
339
|
|
|
|
|
|
|
} |
|
340
|
|
|
|
|
|
|
|
|
341
|
16
|
|
|
|
|
155
|
my $addr = _el($p, 'ram:PostalTradeAddress'); |
|
342
|
16
|
100
|
|
|
|
215
|
if ($profile ne 'minimum') { |
|
343
|
12
|
50
|
|
|
|
52
|
_el($addr, 'ram:PostcodeCode', $party->{postcode}) if $party->{postcode}; |
|
344
|
12
|
50
|
|
|
|
252
|
_el($addr, 'ram:LineOne', $party->{address_1}) if $party->{address_1}; |
|
345
|
12
|
50
|
|
|
|
225
|
_el($addr, 'ram:LineTwo', $party->{address_2}) if $party->{address_2}; |
|
346
|
12
|
50
|
|
|
|
248
|
_el($addr, 'ram:CityName', $party->{city}) if $party->{city}; |
|
347
|
|
|
|
|
|
|
} |
|
348
|
16
|
|
50
|
|
|
300
|
_el($addr, 'ram:CountryID', $party->{country} || 'FR'); |
|
349
|
|
|
|
|
|
|
|
|
350
|
16
|
100
|
100
|
|
|
292
|
if ($is_seller && $party->{vat}) { |
|
351
|
6
|
|
|
|
|
10
|
my $tax = _el($p, 'ram:SpecifiedTaxRegistration'); |
|
352
|
|
|
|
|
|
|
# schemeID VA = TVA intracommunautaire |
|
353
|
6
|
|
|
|
|
64
|
_el($tax, 'ram:ID', $party->{vat}, schemeID => 'VA'); |
|
354
|
|
|
|
|
|
|
} |
|
355
|
|
|
|
|
|
|
} |
|
356
|
|
|
|
|
|
|
|
|
357
|
|
|
|
|
|
|
# ── ApplicableHeaderTradeSettlement ────────────────────────────────────────── |
|
358
|
|
|
|
|
|
|
sub _build_header_settlement { |
|
359
|
8
|
|
|
8
|
|
37
|
my ($trx, $inv, $profile) = @_; |
|
360
|
8
|
|
50
|
|
|
849
|
$profile //= 'basic'; |
|
361
|
8
|
|
|
|
|
25
|
my $set = _el($trx, 'ram:ApplicableHeaderTradeSettlement'); |
|
362
|
8
|
|
50
|
|
|
171
|
_el($set, 'ram:InvoiceCurrencyCode', $inv->{currency} || 'EUR'); |
|
363
|
|
|
|
|
|
|
|
|
364
|
|
|
|
|
|
|
# MINIMUM : seulement InvoiceCurrencyCode + MonetarySummation réduit. |
|
365
|
|
|
|
|
|
|
# Pas de PaymentMeans, ApplicableTradeTax, AllowanceCharge, PaymentTerms. |
|
366
|
8
|
100
|
|
|
|
177
|
if ($profile eq 'minimum') { |
|
367
|
2
|
|
|
|
|
9
|
return _build_minimum_summary($set, $inv); |
|
368
|
|
|
|
|
|
|
} |
|
369
|
|
|
|
|
|
|
|
|
370
|
|
|
|
|
|
|
# ORDRE XSD (CII BASIC) : PaymentMeans AVANT ApplicableTradeTax, |
|
371
|
|
|
|
|
|
|
# ApplicableTradeTax avant SpecifiedTradePaymentTerms, et le |
|
372
|
|
|
|
|
|
|
# MonetarySummation ferme le bloc. |
|
373
|
6
|
|
100
|
|
|
25
|
my $pay = $inv->{payment} || {}; |
|
374
|
6
|
100
|
|
|
|
24
|
if ($pay->{iban}) { |
|
375
|
5
|
|
|
|
|
24
|
my $means = _el($set, 'ram:SpecifiedTradeSettlementPaymentMeans'); |
|
376
|
5
|
|
|
|
|
58
|
_el($means, 'ram:TypeCode', '58'); # 58 = SEPA credit transfer |
|
377
|
5
|
|
|
|
|
105
|
my $acct = _el($means, 'ram:PayeePartyCreditorFinancialAccount'); |
|
378
|
5
|
|
|
|
|
58
|
_el($acct, 'ram:IBANID', $pay->{iban}); |
|
379
|
|
|
|
|
|
|
# BIC (BT-86) — interdit par les profils MINIMUM/BASIC WL/BASIC. |
|
380
|
5
|
100
|
100
|
|
|
93
|
if ($pay->{bic} && $profile eq 'en16931') { |
|
381
|
1
|
|
|
|
|
3
|
my $fi = _el($means, 'ram:PayeeSpecifiedCreditorFinancialInstitution'); |
|
382
|
1
|
|
|
|
|
16
|
_el($fi, 'ram:BICID', $pay->{bic}); |
|
383
|
|
|
|
|
|
|
} |
|
384
|
|
|
|
|
|
|
} |
|
385
|
|
|
|
|
|
|
|
|
386
|
|
|
|
|
|
|
# Agrégation TVA par (CategoryCode, taux). Un motif d'exonération est |
|
387
|
|
|
|
|
|
|
# attaché au groupe quand la catégorie est E/AE/O/K/G ; la première |
|
388
|
|
|
|
|
|
|
# ligne non vide gagne, avec repli sur $inv->{vat_exemption_reason}. |
|
389
|
6
|
|
|
|
|
100
|
my %by_rate; |
|
390
|
6
|
|
|
|
|
14
|
my $global_reason = $inv->{vat_exemption_reason}; |
|
391
|
6
|
50
|
|
|
|
9
|
for my $line (@{ $inv->{lines} || [] }) { |
|
|
6
|
|
|
|
|
27
|
|
|
392
|
10
|
|
50
|
|
|
61
|
my $rate = 0 + ($line->{vat_rate} || 0); |
|
393
|
10
|
|
50
|
|
|
30
|
my $cat = $line->{vat_cat} || 'S'; |
|
394
|
10
|
|
50
|
|
|
39
|
my $amt = ($line->{qty} || 0) * ($line->{unit_price} || 0); |
|
|
|
|
50
|
|
|
|
|
|
395
|
10
|
|
|
|
|
56
|
my $key = "$cat:$rate"; |
|
396
|
10
|
|
|
|
|
38
|
$by_rate{$key}{cat} = $cat; |
|
397
|
10
|
|
|
|
|
31
|
$by_rate{$key}{rate} = $rate; |
|
398
|
10
|
|
|
|
|
22
|
$by_rate{$key}{basis} += $amt; |
|
399
|
10
|
|
|
|
|
15
|
my $reason = $line->{vat_exemption_reason}; |
|
400
|
10
|
50
|
33
|
|
|
37
|
$reason = $global_reason if !defined $reason || !length $reason; |
|
401
|
10
|
50
|
0
|
|
|
73
|
$by_rate{$key}{reason} //= $reason if defined $reason && length $reason; |
|
|
|
|
33
|
|
|
|
|
|
402
|
|
|
|
|
|
|
} |
|
403
|
6
|
|
|
|
|
10
|
my $allowance_total = 0; |
|
404
|
6
|
50
|
|
|
|
12
|
for my $a (@{ $inv->{allowances} || [] }) { |
|
|
6
|
|
|
|
|
56
|
|
|
405
|
0
|
|
0
|
|
|
0
|
my $rate = 0 + ($a->{vat_rate} || 0); |
|
406
|
0
|
|
0
|
|
|
0
|
my $cat = $a->{vat_cat} || 'S'; |
|
407
|
0
|
|
0
|
|
|
0
|
my $amt = abs(0 + ($a->{amount} || 0)); |
|
408
|
0
|
|
|
|
|
0
|
my $key = "$cat:$rate"; |
|
409
|
0
|
|
0
|
|
|
0
|
$by_rate{$key}{cat} //= $cat; |
|
410
|
0
|
|
0
|
|
|
0
|
$by_rate{$key}{rate} //= $rate; |
|
411
|
0
|
|
|
|
|
0
|
$by_rate{$key}{basis} -= $amt; |
|
412
|
0
|
|
|
|
|
0
|
$allowance_total += $amt; |
|
413
|
|
|
|
|
|
|
} |
|
414
|
|
|
|
|
|
|
|
|
415
|
6
|
|
|
|
|
18
|
my ($line_total, $tax_total) = (0, 0); |
|
416
|
6
|
|
|
|
|
39
|
for my $key (sort keys %by_rate) { |
|
417
|
10
|
|
|
|
|
108
|
my $r = $by_rate{$key}; |
|
418
|
10
|
|
|
|
|
34
|
my $tax_amt = $r->{basis} * $r->{rate} / 100; |
|
419
|
10
|
|
|
|
|
19
|
$line_total += $r->{basis}; |
|
420
|
10
|
|
|
|
|
19
|
$tax_total += $tax_amt; |
|
421
|
|
|
|
|
|
|
|
|
422
|
10
|
|
|
|
|
21
|
my $tax = _el($set, 'ram:ApplicableTradeTax'); |
|
423
|
|
|
|
|
|
|
# ORDRE IMPOSÉ PAR LE XSD : CalculatedAmount, TypeCode, |
|
424
|
|
|
|
|
|
|
# ExemptionReason?, BasisAmount, CategoryCode, RateApplicablePercent. |
|
425
|
10
|
|
|
|
|
164
|
_el($tax, 'ram:CalculatedAmount', _fmt_amt($tax_amt)); |
|
426
|
10
|
|
|
|
|
197
|
_el($tax, 'ram:TypeCode', 'VAT'); |
|
427
|
10
|
50
|
|
|
|
247
|
if ($r->{cat} =~ /^(E|AE|O|K|G)$/) { |
|
428
|
0
|
|
0
|
|
|
0
|
my $reason = $r->{reason} // $global_reason; |
|
429
|
0
|
0
|
0
|
|
|
0
|
_el($tax, 'ram:ExemptionReason', $reason) |
|
430
|
|
|
|
|
|
|
if defined $reason && length $reason; |
|
431
|
|
|
|
|
|
|
} |
|
432
|
10
|
|
|
|
|
30
|
_el($tax, 'ram:BasisAmount', _fmt_amt($r->{basis})); |
|
433
|
10
|
|
|
|
|
259
|
_el($tax, 'ram:CategoryCode', $r->{cat}); |
|
434
|
|
|
|
|
|
|
# BR-O-05 : pas de RateApplicablePercent pour la catégorie 'O'. |
|
435
|
|
|
|
|
|
|
_el($tax, 'ram:RateApplicablePercent', _fmt_amt($r->{rate})) |
|
436
|
10
|
50
|
|
|
|
208
|
unless $r->{cat} eq 'O'; |
|
437
|
|
|
|
|
|
|
} |
|
438
|
|
|
|
|
|
|
|
|
439
|
|
|
|
|
|
|
# Remises/charges globales (après ApplicableTradeTax, avant PaymentTerms). |
|
440
|
6
|
50
|
|
|
|
232
|
for my $a (@{ $inv->{allowances} || [] }) { |
|
|
6
|
|
|
|
|
57
|
|
|
441
|
0
|
|
|
|
|
0
|
my $ac = _el($set, 'ram:SpecifiedTradeAllowanceCharge'); |
|
442
|
0
|
|
|
|
|
0
|
my $ind = _el($ac, 'ram:ChargeIndicator'); |
|
443
|
|
|
|
|
|
|
# ChargeIndicator: true = charge (ajout), false = allowance (remise). |
|
444
|
0
|
0
|
|
|
|
0
|
_el($ind, 'udt:Indicator', $a->{is_charge} ? 'true' : 'false'); |
|
445
|
0
|
|
0
|
|
|
0
|
_el($ac, 'ram:ActualAmount', _fmt_amt(abs($a->{amount} || 0))); |
|
446
|
0
|
0
|
|
|
|
0
|
_el($ac, 'ram:Reason', $a->{reason}) if $a->{reason}; |
|
447
|
0
|
|
|
|
|
0
|
my $ctt = _el($ac, 'ram:CategoryTradeTax'); |
|
448
|
0
|
|
|
|
|
0
|
_el($ctt, 'ram:TypeCode', 'VAT'); |
|
449
|
0
|
|
0
|
|
|
0
|
_el($ctt, 'ram:CategoryCode', $a->{vat_cat} || 'S'); |
|
450
|
|
|
|
|
|
|
_el($ctt, 'ram:RateApplicablePercent', _fmt_amt($a->{vat_rate} || 0)) |
|
451
|
0
|
0
|
0
|
|
|
0
|
unless ($a->{vat_cat} // 'S') eq 'O'; |
|
|
|
|
0
|
|
|
|
|
|
452
|
|
|
|
|
|
|
} |
|
453
|
|
|
|
|
|
|
|
|
454
|
6
|
|
|
|
|
37
|
my $terms = _el($set, 'ram:SpecifiedTradePaymentTerms'); |
|
455
|
6
|
100
|
|
|
|
99
|
_el($terms, 'ram:Description', $pay->{terms}) if $pay->{terms}; |
|
456
|
6
|
50
|
|
|
|
106
|
if ($inv->{due_date}) { |
|
457
|
6
|
|
|
|
|
15
|
my $d = _el($terms, 'ram:DueDateDateTime'); |
|
458
|
6
|
|
|
|
|
100
|
_el($d, 'udt:DateTimeString', _fmt_date($inv->{due_date}), format => '102'); |
|
459
|
|
|
|
|
|
|
} |
|
460
|
|
|
|
|
|
|
|
|
461
|
|
|
|
|
|
|
# Somme brute des lignes (avant remises). $line_total agrégé plus haut |
|
462
|
|
|
|
|
|
|
# déduisait déjà les allowances — on recalcule le net proprement. |
|
463
|
6
|
|
|
|
|
271
|
my $lines_gross = 0; |
|
464
|
6
|
50
|
|
|
|
12
|
for my $line (@{ $inv->{lines} || [] }) { |
|
|
6
|
|
|
|
|
32
|
|
|
465
|
10
|
|
50
|
|
|
67
|
$lines_gross += ($line->{qty} || 0) * ($line->{unit_price} || 0); |
|
|
|
|
50
|
|
|
|
|
|
466
|
|
|
|
|
|
|
} |
|
467
|
6
|
|
|
|
|
17
|
my $tax_basis = $lines_gross - $allowance_total; |
|
468
|
6
|
|
|
|
|
12
|
my $grand = $tax_basis + $tax_total; |
|
469
|
|
|
|
|
|
|
|
|
470
|
|
|
|
|
|
|
# ORDRE XSD : LineTotal, ChargeTotal, AllowanceTotal, TaxBasis, |
|
471
|
|
|
|
|
|
|
# TaxTotal, GrandTotal, TotalPrepaid, DuePayable. |
|
472
|
6
|
|
|
|
|
14
|
my $sum = _el($set, 'ram:SpecifiedTradeSettlementHeaderMonetarySummation'); |
|
473
|
6
|
|
|
|
|
96
|
_el($sum, 'ram:LineTotalAmount', _fmt_amt($lines_gross)); |
|
474
|
6
|
50
|
|
|
|
115
|
_el($sum, 'ram:AllowanceTotalAmount', _fmt_amt($allowance_total)) |
|
475
|
|
|
|
|
|
|
if $allowance_total > 0; |
|
476
|
6
|
|
|
|
|
17
|
_el($sum, 'ram:TaxBasisTotalAmount', _fmt_amt($tax_basis)); |
|
477
|
|
|
|
|
|
|
_el($sum, 'ram:TaxTotalAmount', _fmt_amt($tax_total), |
|
478
|
6
|
|
50
|
|
|
130
|
currencyID => ($inv->{currency} || 'EUR')); |
|
479
|
6
|
|
|
|
|
122
|
_el($sum, 'ram:GrandTotalAmount', _fmt_amt($grand)); |
|
480
|
6
|
|
|
|
|
119
|
_el($sum, 'ram:DuePayableAmount', _fmt_amt($grand)); |
|
481
|
|
|
|
|
|
|
} |
|
482
|
|
|
|
|
|
|
|
|
483
|
|
|
|
|
|
|
# Profil MINIMUM : MonetarySummation se limite à |
|
484
|
|
|
|
|
|
|
# TaxBasisTotal, TaxTotal?, GrandTotal, DuePayable. |
|
485
|
|
|
|
|
|
|
sub _build_minimum_summary { |
|
486
|
2
|
|
|
2
|
|
5
|
my ($set, $inv) = @_; |
|
487
|
2
|
|
|
|
|
4
|
my $lines_gross = 0; |
|
488
|
2
|
|
|
|
|
3
|
my $tax_total = 0; |
|
489
|
2
|
50
|
|
|
|
4
|
for my $line (@{ $inv->{lines} || [] }) { |
|
|
2
|
|
|
|
|
10
|
|
|
490
|
3
|
|
50
|
|
|
11
|
my $base = ($line->{qty} || 0) * ($line->{unit_price} || 0); |
|
|
|
|
50
|
|
|
|
|
|
491
|
3
|
|
|
|
|
5
|
$lines_gross += $base; |
|
492
|
3
|
|
50
|
|
|
12
|
$tax_total += $base * ($line->{vat_rate} || 0) / 100; |
|
493
|
|
|
|
|
|
|
} |
|
494
|
2
|
|
|
|
|
4
|
my $allowance_total = 0; |
|
495
|
2
|
50
|
|
|
|
3
|
for my $a (@{ $inv->{allowances} || [] }) { |
|
|
2
|
|
|
|
|
23
|
|
|
496
|
0
|
|
0
|
|
|
0
|
$allowance_total += abs($a->{amount} || 0); |
|
497
|
|
|
|
|
|
|
} |
|
498
|
2
|
|
|
|
|
4
|
my $tax_basis = $lines_gross - $allowance_total; |
|
499
|
2
|
|
|
|
|
4
|
my $grand = $tax_basis + $tax_total; |
|
500
|
|
|
|
|
|
|
|
|
501
|
2
|
|
|
|
|
5
|
my $sum = _el($set, 'ram:SpecifiedTradeSettlementHeaderMonetarySummation'); |
|
502
|
2
|
|
|
|
|
26
|
_el($sum, 'ram:TaxBasisTotalAmount', _fmt_amt($tax_basis)); |
|
503
|
|
|
|
|
|
|
_el($sum, 'ram:TaxTotalAmount', _fmt_amt($tax_total), |
|
504
|
2
|
|
50
|
|
|
31
|
currencyID => ($inv->{currency} || 'EUR')); |
|
505
|
2
|
|
|
|
|
30
|
_el($sum, 'ram:GrandTotalAmount', _fmt_amt($grand)); |
|
506
|
2
|
|
|
|
|
31
|
_el($sum, 'ram:DuePayableAmount', _fmt_amt($grand)); |
|
507
|
2
|
|
|
|
|
30
|
return; |
|
508
|
|
|
|
|
|
|
} |
|
509
|
|
|
|
|
|
|
|
|
510
|
|
|
|
|
|
|
############################################################################### |
|
511
|
|
|
|
|
|
|
# HELPERS |
|
512
|
|
|
|
|
|
|
############################################################################### |
|
513
|
|
|
|
|
|
|
|
|
514
|
|
|
|
|
|
|
sub _el { |
|
515
|
533
|
|
|
533
|
|
1027
|
my ($parent, $qname, $text, @attrs) = @_; |
|
516
|
533
|
|
|
|
|
1196
|
my ($prefix) = split /:/, $qname, 2; |
|
517
|
533
|
50
|
|
|
|
1439
|
my $ns = $NS{$prefix} or die "Préfixe XML inconnu: $prefix"; |
|
518
|
533
|
|
|
|
|
1793
|
my $doc = $parent->ownerDocument; |
|
519
|
533
|
|
|
|
|
2919
|
my $el = $doc->createElementNS($ns, $qname); |
|
520
|
533
|
|
|
|
|
1475
|
while (my ($k, $v) = splice(@attrs, 0, 2)) { |
|
521
|
48
|
|
|
|
|
128
|
$el->setAttribute($k, $v); |
|
522
|
|
|
|
|
|
|
} |
|
523
|
533
|
100
|
66
|
|
|
3116
|
$el->appendText($text) if defined $text && length $text; |
|
524
|
533
|
|
|
|
|
2177
|
$parent->appendChild($el); |
|
525
|
533
|
|
|
|
|
1509
|
return $el; |
|
526
|
|
|
|
|
|
|
} |
|
527
|
|
|
|
|
|
|
|
|
528
|
|
|
|
|
|
|
sub _fmt_amt { |
|
529
|
92
|
|
|
92
|
|
178
|
my ($n) = @_; |
|
530
|
92
|
|
50
|
|
|
782
|
return sprintf('%.2f', 0 + ($n // 0)); |
|
531
|
|
|
|
|
|
|
} |
|
532
|
|
|
|
|
|
|
|
|
533
|
|
|
|
|
|
|
sub _fmt_qty { |
|
534
|
8
|
|
|
8
|
|
25
|
my ($n) = @_; |
|
535
|
8
|
|
50
|
|
|
120
|
my $v = 0 + ($n // 0); |
|
536
|
8
|
50
|
|
|
|
90
|
return $v == int($v) ? sprintf('%d', $v) : sprintf('%.4f', $v); |
|
537
|
|
|
|
|
|
|
} |
|
538
|
|
|
|
|
|
|
|
|
539
|
|
|
|
|
|
|
sub _fmt_date { |
|
540
|
20
|
|
|
20
|
|
36
|
my ($iso) = @_; |
|
541
|
20
|
50
|
|
|
|
49
|
return '' unless defined $iso; |
|
542
|
20
|
50
|
|
|
|
140
|
$iso =~ /^(\d{4})-(\d{2})-(\d{2})/ or die "Date ISO invalide: $iso"; |
|
543
|
20
|
|
|
|
|
145
|
return "$1$2$3"; |
|
544
|
|
|
|
|
|
|
} |
|
545
|
|
|
|
|
|
|
|
|
546
|
|
|
|
|
|
|
1; |
|
547
|
|
|
|
|
|
|
|
|
548
|
|
|
|
|
|
|
__END__ |