mikrowerk-edi-invoicing 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mikrowerk_edi_invoicing-0.1.0.dist-info/LICENSE +661 -0
- mikrowerk_edi_invoicing-0.1.0.dist-info/METADATA +35 -0
- mikrowerk_edi_invoicing-0.1.0.dist-info/RECORD +18 -0
- mikrowerk_edi_invoicing-0.1.0.dist-info/WHEEL +5 -0
- mikrowerk_edi_invoicing-0.1.0.dist-info/top_level.txt +4 -0
- model/__init__.py +3 -0
- model/x_rechnung.py +260 -0
- tests/__init__.py +0 -0
- tests/test_iban_handling.py +24 -0
- tests/test_parse_x_rechnung.py +82 -0
- util/__init__.py +0 -0
- util/file_helper.py +24 -0
- x_mapper/__init__.py +8 -0
- x_mapper/cross_industry_invoice_mapper.py +20 -0
- x_mapper/drafthorse_elements_helper.py +132 -0
- x_mapper/xml_abstract_x_rechnung_parser.py +21 -0
- x_mapper/xml_cii_dom_parser.py +203 -0
- x_mapper/xml_ubl_sax_parser.py +268 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: mikrowerk_edi_invoicing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parser for EDI invoices in CII or UBL format
|
|
5
|
+
Author: Mikrowerk a Gammadata Division
|
|
6
|
+
Author-email: info@mikrowerk.com
|
|
7
|
+
License: Affero-3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: lxml
|
|
14
|
+
Requires-Dist: pypdf
|
|
15
|
+
Requires-Dist: pytest
|
|
16
|
+
Requires-Dist: flake8
|
|
17
|
+
Requires-Dist: isort
|
|
18
|
+
Requires-Dist: black
|
|
19
|
+
Requires-Dist: coverage
|
|
20
|
+
Requires-Dist: codecov
|
|
21
|
+
Requires-Dist: drafthorse~=2.4.0
|
|
22
|
+
Requires-Dist: factur-x==3.6
|
|
23
|
+
Requires-Dist: jsonpickle~=4.0.1
|
|
24
|
+
Requires-Dist: parameterized
|
|
25
|
+
Requires-Dist: schwifty
|
|
26
|
+
Dynamic: author
|
|
27
|
+
Dynamic: author-email
|
|
28
|
+
Dynamic: classifier
|
|
29
|
+
Dynamic: description
|
|
30
|
+
Dynamic: description-content-type
|
|
31
|
+
Dynamic: license
|
|
32
|
+
Dynamic: requires-dist
|
|
33
|
+
Dynamic: summary
|
|
34
|
+
|
|
35
|
+
# Mikrowerk EDI Parser for CII and UBL Invoices
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
model/__init__.py,sha256=eM8kFNSQHvV3RbGnrOPECSNcdOZIU_FJYkqOJ9gZ5-0,59
|
|
2
|
+
model/x_rechnung.py,sha256=qklcfdmihPZL9Ny7rqvP_KgvcnEsNGXLJUEO8iafZas,9790
|
|
3
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
tests/test_iban_handling.py,sha256=suRaB9gxbNc2Dc7spjHmQyPBdXva98HF1js85wQWqPM,662
|
|
5
|
+
tests/test_parse_x_rechnung.py,sha256=ChVUcDp3P1o8IzQmRJJWwkIlrqZel3xYmoE3V1ukytE,3584
|
|
6
|
+
util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
util/file_helper.py,sha256=4gdWbv8L9LSMraLKvGI1Z3NMcuGGy7JB1qFvNaW-yo4,767
|
|
8
|
+
x_mapper/__init__.py,sha256=Ee222PGj2y9wgsjx53yP9InOwkig31SZd8ra29gkPMc,536
|
|
9
|
+
x_mapper/cross_industry_invoice_mapper.py,sha256=HmIDFJhJf4iB5efFZoPhs04c2qvwvTn0pfbsrPA9EJk,827
|
|
10
|
+
x_mapper/drafthorse_elements_helper.py,sha256=gO8VobJnBqKEdRDcVsz4XixCvSCQEYqhcZeb7v51jGE,3756
|
|
11
|
+
x_mapper/xml_abstract_x_rechnung_parser.py,sha256=puxCSC02zIXpfMY9xNLqY-w0aRv0y5ROHmNtmaV16o4,673
|
|
12
|
+
x_mapper/xml_cii_dom_parser.py,sha256=q_0osUHGloVkG0ZL782SBMWAGtMaBdEI8xblyGrDlmw,10980
|
|
13
|
+
x_mapper/xml_ubl_sax_parser.py,sha256=VZcZSQo-iHiVA3IUtZu9xAQwWglgk34lD_Y-Hw3Sq5M,14008
|
|
14
|
+
mikrowerk_edi_invoicing-0.1.0.dist-info/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
15
|
+
mikrowerk_edi_invoicing-0.1.0.dist-info/METADATA,sha256=9kizoXNxoAkO0FHNyu10gjLF10aJr6VYsPlQpPQgjBM,1004
|
|
16
|
+
mikrowerk_edi_invoicing-0.1.0.dist-info/WHEEL,sha256=nn6H5-ilmfVryoAQl3ZQ2l8SH5imPWFpm1A5FgEuFV4,91
|
|
17
|
+
mikrowerk_edi_invoicing-0.1.0.dist-info/top_level.txt,sha256=pchuH1VFSrOMiUHBeuZxMaHNvC6mgZ59aac8eM_PXgs,26
|
|
18
|
+
mikrowerk_edi_invoicing-0.1.0.dist-info/RECORD,,
|
model/__init__.py
ADDED
model/x_rechnung.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from dataclasses import dataclass, asdict
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
|
|
5
|
+
__all__ = ['XRechnung', "XRechnungCurrency", "XRechnungTradeParty", "XRechnungTradeAddress", "XRechnungTradeContact",
|
|
6
|
+
"XRechnungPaymentMeans", "XRechnungBankAccount", "XRechnungAppliedTradeTax", "XRechnungTradeLine",
|
|
7
|
+
"XRechnungFinancialCard"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class XRechnungCurrency:
|
|
12
|
+
amount: Decimal
|
|
13
|
+
currency_code: str
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def from_currency_tuple(currency_tuple: tuple) -> 'XRechnungCurrency':
|
|
17
|
+
return XRechnungCurrency(*currency_tuple)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class XRechnungTradeAddress:
|
|
22
|
+
post_code: str = None # 'Post-Code'
|
|
23
|
+
city_name: str = None # 'City'
|
|
24
|
+
country_id: str = None # 'Country ID
|
|
25
|
+
country_subdivision_id: str = None # 'Country Subdivision ID'
|
|
26
|
+
address_line_1: str = None # 'Address Line 1'
|
|
27
|
+
address_line_2: str = None # 'Address Line 2'
|
|
28
|
+
address_line_3: str = None # 'Address Line 3'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class XRechnungTradeContact:
|
|
33
|
+
name: str = None # 'Person Name'
|
|
34
|
+
department_name: str = None # 'Department Name'
|
|
35
|
+
telephone: str = None # 'Telephone Number'
|
|
36
|
+
fax: str = None # 'Fax'
|
|
37
|
+
email: str = None # 'Email'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class XRechnungTradeParty:
|
|
42
|
+
global_id: int = 0 # 'Global ID'
|
|
43
|
+
global_id_schema: str = None # 'Global Schema'
|
|
44
|
+
id: str = None # 'id'
|
|
45
|
+
name: str = None # 'Name'
|
|
46
|
+
description: str = None # 'Description'
|
|
47
|
+
postal_address: XRechnungTradeAddress = None
|
|
48
|
+
email: str = None # 'Email'
|
|
49
|
+
trade_contact: XRechnungTradeContact = None
|
|
50
|
+
vat_registration_number: str = None # 'VAT Registration Number'
|
|
51
|
+
fiscal_registration_number: str = None # 'Fiscal Registration Number'
|
|
52
|
+
legal_registration_number: str = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# @dataclass
|
|
56
|
+
# class XRechnungSpecifiedTradeSettlementPaymentMeans:
|
|
57
|
+
# name: str = None # 'Name'
|
|
58
|
+
# type_code: str = None # 'Type Code'
|
|
59
|
+
# information: str = None # 'Information'
|
|
60
|
+
# iban: str = None # 'IBAN'
|
|
61
|
+
# bicid: str = None # 'BICID'
|
|
62
|
+
# account_name: str = None # 'Account Name'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class XRechnungAppliedTradeTax:
|
|
67
|
+
name: str = None # 'Name'
|
|
68
|
+
type_code: str = None
|
|
69
|
+
category_code: str = None
|
|
70
|
+
applicable_percent: float = 0.0
|
|
71
|
+
basis_amount: float = 0.0 # 'Basis Amount'
|
|
72
|
+
calculated_amount: float = 0.0 # 'Calculated Tax Amount'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class XRechnungTradeLine:
|
|
77
|
+
name: str = None # 'Name')
|
|
78
|
+
description: str = None
|
|
79
|
+
line_id: str = None # 'Line ID')
|
|
80
|
+
global_product_id: str = None # 'Global Product ID')
|
|
81
|
+
global_product_scheme_id: str = None # 'Global Product Scheme ID')
|
|
82
|
+
seller_assigned_id: str = None # 'Seller Assigned ID')
|
|
83
|
+
buyer_assigned_id: str = None # 'Buyer Assigned ID')
|
|
84
|
+
price_unit: float = 0.0 # 'Net Price')
|
|
85
|
+
quantity_billed: float = 0.0 # 'Billed Quantity')
|
|
86
|
+
quantity_unit_code: str = None # 'Quantity Code')
|
|
87
|
+
total_amount_net: float = 0.0 # 'Total Amount Net')
|
|
88
|
+
price_unit_gross: float = 0.0
|
|
89
|
+
total_allowance_charge: float = 0.0
|
|
90
|
+
trade_tax: any = None
|
|
91
|
+
note: str = None
|
|
92
|
+
lot_number_id: str = None
|
|
93
|
+
expiry_date: datetime = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class XRechnungFinancialCard:
|
|
98
|
+
id: str | None = None
|
|
99
|
+
cardholder_name: str | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class XRechnungBankAccount:
|
|
104
|
+
iban: str | None = None
|
|
105
|
+
bic: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class XRechnungPaymentMeans:
|
|
110
|
+
id: str = None
|
|
111
|
+
type_code: str = None
|
|
112
|
+
information: str = None
|
|
113
|
+
financial_card: XRechnungFinancialCard = None
|
|
114
|
+
payee_account: XRechnungBankAccount = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class XRechnung:
|
|
119
|
+
name: str = None # 'Name'
|
|
120
|
+
doc_id: str = None # 'Document ID'
|
|
121
|
+
doc_type_code: str = None # 'Subject Code'
|
|
122
|
+
issued_date_time: datetime = None # 'Date'
|
|
123
|
+
delivered_date_time: datetime = None # 'Delivered Date'
|
|
124
|
+
languages: str = None # 'Languages'
|
|
125
|
+
notes: str = None # 'Notes'
|
|
126
|
+
buyer_reference: str = None # 'Buyer Reference'
|
|
127
|
+
order_reference: str = None
|
|
128
|
+
dispatch_reference: str = None
|
|
129
|
+
sales_order_reference: str = None
|
|
130
|
+
seller: XRechnungTradeParty = None
|
|
131
|
+
payee: XRechnungTradeParty = None
|
|
132
|
+
buyer: XRechnungTradeParty = None
|
|
133
|
+
invoicee: XRechnungTradeParty = None
|
|
134
|
+
currency_code: str = None # 'Currency Code'
|
|
135
|
+
payment_means: XRechnungPaymentMeans = None
|
|
136
|
+
payment_terms: str = None # 'Payment Terms'
|
|
137
|
+
line_total_amount: Decimal = None # 'Line Total Amount'
|
|
138
|
+
charge_total_amount: Decimal = None # 'Charge Total Amount'
|
|
139
|
+
allowance_total_amount: Decimal = None # 'Allowance Total Amount'
|
|
140
|
+
tax_basis_total_amount: XRechnungCurrency = None
|
|
141
|
+
tax_total_amount: [XRechnungCurrency] = None # 'Tax Grand Total Amount'
|
|
142
|
+
grand_total_amount: XRechnungCurrency = None # 'Grand Total Amount'
|
|
143
|
+
total_prepaid_amount: Decimal = None # 'Total Prepaid Amount'
|
|
144
|
+
due_payable_amount: Decimal = None # 'Due Payable Amount'
|
|
145
|
+
trade_line_items: [XRechnungTradeLine] = None
|
|
146
|
+
applicable_trade_taxes: [XRechnungAppliedTradeTax] = None
|
|
147
|
+
|
|
148
|
+
def map_to_dict(self) -> dict:
|
|
149
|
+
"""
|
|
150
|
+
maps a XRechnung to a dict suited for generation odoo entities
|
|
151
|
+
Note: this is not a 1:1 mapping of the XRechnung model, some adjustments and simplifications made
|
|
152
|
+
:param self: XRechnung.XRechnung
|
|
153
|
+
:return: dict
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
_dict = asdict(self)
|
|
157
|
+
_dict.update({
|
|
158
|
+
'line_total_amount': float(self.line_total_amount) if self.line_total_amount is not None else 0,
|
|
159
|
+
'charge_total_amount': float(self.charge_total_amount) if self.charge_total_amount else 0,
|
|
160
|
+
'allowance_total_amount': float(self.allowance_total_amount) if self.allowance_total_amount else 0,
|
|
161
|
+
'tax_basis_total_amount': float(
|
|
162
|
+
self.tax_basis_total_amount.amount) if self.tax_basis_total_amount else 0,
|
|
163
|
+
'tax_total_amount': self.sum_x_rechnung_currency(self.tax_total_amount) if self.tax_total_amount else 0,
|
|
164
|
+
'grand_total_amount': float(self.grand_total_amount.amount) if self.grand_total_amount else 0,
|
|
165
|
+
'total_prepaid_amount': float(self.total_prepaid_amount) if self.total_prepaid_amount else 0.0,
|
|
166
|
+
'due_payable_amount': float(self.due_payable_amount) if self.due_payable_amount else 0.0,
|
|
167
|
+
'seller': self.map_trade_party(self.seller) if self.seller else None,
|
|
168
|
+
'payee': self.map_trade_party(self.payee) if self.payee else None,
|
|
169
|
+
'buyer': self.map_trade_party(self.buyer) if self.buyer else None,
|
|
170
|
+
'invoicee': self.map_trade_party(self.invoicee) if self.invoicee else None,
|
|
171
|
+
'payment_means': self.map_payment_means(self.payment_means) if self.payment_means else None,
|
|
172
|
+
'trade_line_items': self.map_tradeline_items_to_dict(self.trade_line_items),
|
|
173
|
+
'applicable_trade_taxes': self.map_trade_taxes_to_dict(self.applicable_trade_taxes),
|
|
174
|
+
})
|
|
175
|
+
return _dict
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def map_tradeline_to_dict(cls, x_trade_line: XRechnungTradeLine) -> dict:
|
|
179
|
+
_dict = asdict(x_trade_line)
|
|
180
|
+
_dict.update(
|
|
181
|
+
{'trade_tax': asdict(x_trade_line.trade_tax),
|
|
182
|
+
'price_unit': float(x_trade_line.price_unit),
|
|
183
|
+
'quantity_billed': float(x_trade_line.quantity_billed),
|
|
184
|
+
'total_amount_net': float(x_trade_line.total_amount_net),
|
|
185
|
+
})
|
|
186
|
+
return _dict
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def map_tradeline_items_to_dict(cls, tradeline_items: list) -> list:
|
|
190
|
+
res = []
|
|
191
|
+
if tradeline_items:
|
|
192
|
+
for tradeline_item in tradeline_items:
|
|
193
|
+
res.append(cls.map_tradeline_to_dict(tradeline_item))
|
|
194
|
+
return res
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def map_trade_taxes_to_dict(cls, trade_taxes: list) -> list:
|
|
198
|
+
res = []
|
|
199
|
+
if trade_taxes:
|
|
200
|
+
for tax in trade_taxes:
|
|
201
|
+
res.append(asdict(tax))
|
|
202
|
+
return res
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def map_trade_party(cls, trade_party: XRechnungTradeParty) -> dict:
|
|
206
|
+
_dict = asdict(trade_party)
|
|
207
|
+
_dict.update({
|
|
208
|
+
'postal_address': cls.map_trade_address(trade_party.postal_address),
|
|
209
|
+
'trade_contact': cls.map_trade_contact(trade_party.trade_contact)
|
|
210
|
+
})
|
|
211
|
+
return _dict
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def map_trade_address(cls, trade_address: XRechnungTradeAddress) -> dict | None:
|
|
215
|
+
if trade_address is None:
|
|
216
|
+
return None
|
|
217
|
+
_dict = asdict(trade_address)
|
|
218
|
+
return _dict
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def map_trade_contact(cls, trade_contact: XRechnungTradeContact) -> dict | None:
|
|
222
|
+
if trade_contact is None:
|
|
223
|
+
return None
|
|
224
|
+
_dict = asdict(trade_contact)
|
|
225
|
+
return _dict
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def map_payment_means(cls, payment_means: XRechnungPaymentMeans) -> dict:
|
|
229
|
+
_dict = {
|
|
230
|
+
'type_code': payment_means.type_code,
|
|
231
|
+
'information': payment_means.information,
|
|
232
|
+
'financial_card': cls.map_financial_card(
|
|
233
|
+
payment_means.financial_card) if payment_means.financial_card else None,
|
|
234
|
+
'bank_account': cls.map_bank_account(payment_means.payee_account) if payment_means.payee_account else None,
|
|
235
|
+
}
|
|
236
|
+
return _dict
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def map_financial_card(cls, card: XRechnungFinancialCard) -> dict:
|
|
240
|
+
_dict = {
|
|
241
|
+
'card_number': card.id,
|
|
242
|
+
'card_holder_name': card.cardholder_name
|
|
243
|
+
}
|
|
244
|
+
return _dict
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def map_bank_account(cls, bank: XRechnungBankAccount) -> dict:
|
|
248
|
+
_dict = {
|
|
249
|
+
'iban': bank.iban,
|
|
250
|
+
'bic': bank.bic
|
|
251
|
+
}
|
|
252
|
+
return _dict
|
|
253
|
+
|
|
254
|
+
@classmethod
|
|
255
|
+
def sum_x_rechnung_currency(cls, x_currencies: [XRechnungCurrency]) -> float:
|
|
256
|
+
res = 0.0
|
|
257
|
+
if x_currencies:
|
|
258
|
+
for x_currency in x_currencies:
|
|
259
|
+
res += float(x_currency.amount)
|
|
260
|
+
return res
|
tests/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from schwifty import IBAN
|
|
3
|
+
|
|
4
|
+
_iban_1 = 'DE43500105175451887913'
|
|
5
|
+
_bic_expected = 'INGDDEFFXXX'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IbanTestCase(unittest.TestCase):
|
|
9
|
+
def test_iban_decoding(self):
|
|
10
|
+
_ib_1: IBAN = IBAN(_iban_1)
|
|
11
|
+
self.assertIsNotNone(_ib_1)
|
|
12
|
+
_bank_code = _ib_1.bank_code
|
|
13
|
+
self.assertIsNotNone(_bank_code)
|
|
14
|
+
_account_code = _ib_1.account_code
|
|
15
|
+
self.assertIsNotNone(_account_code)
|
|
16
|
+
_country_code = _ib_1.country_code
|
|
17
|
+
self.assertIsNotNone(_country_code)
|
|
18
|
+
_bic = _ib_1.bic
|
|
19
|
+
self.assertIsNotNone(_bic)
|
|
20
|
+
self.assertEqual(_bic, _bic_expected)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == '__main__':
|
|
24
|
+
unittest.main()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import jsonpickle
|
|
3
|
+
from parameterized import parameterized
|
|
4
|
+
|
|
5
|
+
from facturx import get_facturx_xml_from_pdf
|
|
6
|
+
|
|
7
|
+
from ..util.file_helper import get_checked_file_path
|
|
8
|
+
from ..model.x_rechnung import XRechnung
|
|
9
|
+
from ..x_mapper.cross_industry_invoice_mapper import parse_and_map_x_rechnung
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class XRechnungEinfachTestCase(unittest.TestCase):
|
|
13
|
+
|
|
14
|
+
def setUp(self) -> None:
|
|
15
|
+
jsonpickle.set_encoder_options("json", indent=2, ensure_ascii=False)
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@parameterized.expand([
|
|
19
|
+
('xml', 'zugferd/XRECHNUNG_Einfach/xrechnung.xml'),
|
|
20
|
+
('xml', 'real_invoice_samples/AKD-736116091815.xml'),
|
|
21
|
+
('xml', 'griffity_exapmles/385_2025.xml'),
|
|
22
|
+
('xml', 'e_invoicing_EN16931/CII-BR-CO-10-RoundingIssue.xml'),
|
|
23
|
+
('xml', 'e_invoicing_EN16931/CII_business_example_01.xml'),
|
|
24
|
+
('xml', 'e_invoicing_EN16931/CII_business_example_02.xml'),
|
|
25
|
+
('xml', 'e_invoicing_EN16931/CII_business_example_Z.xml'),
|
|
26
|
+
('xml', 'e_invoicing_EN16931/CII_example1.xml'),
|
|
27
|
+
# # ('xml', 'e_invoicing_EN16931/CII_example2.xml'), # throws error
|
|
28
|
+
('xml', 'e_invoicing_EN16931/CII_example3.xml'),
|
|
29
|
+
('xml', 'e_invoicing_EN16931/CII_example4.xml'),
|
|
30
|
+
# # ('xml', 'e_invoicing_EN16931/CII_example5.xml'), # throws error
|
|
31
|
+
('xml', 'e_invoicing_EN16931/CII_example6.xml'),
|
|
32
|
+
('xml', 'e_invoicing_EN16931/CII_example7.xml'),
|
|
33
|
+
('xml', 'e_invoicing_EN16931/CII_example8.xml'),
|
|
34
|
+
('xml', 'e_invoicing_EN16931/CII_example9.xml'),
|
|
35
|
+
('xml', 'ubl/RG00343552.xml'),
|
|
36
|
+
('xml', 'ubl/ubl_invoice_example.xml'),
|
|
37
|
+
('xml', 'ubl/UBL-Invoice-2.1-Example.xml'),
|
|
38
|
+
('pdf', 'zugferd/BASIC-WL_Einfach/BASIC-WL_Einfach.pdf'),
|
|
39
|
+
# ('pdf', 'zugferd/XRECHNUNG_Einfach/XRECHNUNG_Einfach.pdf'), # needs some checks, why test failed
|
|
40
|
+
# ("pdf", "zugferd/XRECHNUNG_Elektron/XRECHNUNG_Elektron.pdf"), # needs some checks, why test failed
|
|
41
|
+
('pdf', 'odoo_generated/INV_2025_00001.pdf')
|
|
42
|
+
])
|
|
43
|
+
def test_x_rechnung_files(self, file_type, file_path):
|
|
44
|
+
print(f"start testing with file: {file_path}")
|
|
45
|
+
if file_type == 'xml':
|
|
46
|
+
_parsed = self._parse_xml(file_path)
|
|
47
|
+
elif file_type == 'pdf':
|
|
48
|
+
_parsed = self._parse_pdf(file_path)
|
|
49
|
+
else:
|
|
50
|
+
raise AssertionError(f'File type {file_type} not supported')
|
|
51
|
+
assert _parsed is not None
|
|
52
|
+
res_dict = _parsed.map_to_dict()
|
|
53
|
+
print(jsonpickle.dumps(res_dict))
|
|
54
|
+
|
|
55
|
+
def _parse_xml(self, filepath) -> XRechnung:
|
|
56
|
+
_file_path, _exists, _is_dir = get_checked_file_path(filepath, __file__)
|
|
57
|
+
self.assertTrue(_exists)
|
|
58
|
+
print(f"\n_parse_xml: file_path={_file_path}")
|
|
59
|
+
with open(_file_path, "rb") as _file:
|
|
60
|
+
samplexml = _file.read()
|
|
61
|
+
res = parse_and_map_x_rechnung(samplexml)
|
|
62
|
+
self.assertIsNotNone(res)
|
|
63
|
+
return res
|
|
64
|
+
|
|
65
|
+
def _parse_pdf(self, filepath) -> XRechnung:
|
|
66
|
+
_file_path, _exists, _is_dir = get_checked_file_path(filepath, __file__)
|
|
67
|
+
self.assertTrue(_exists)
|
|
68
|
+
print(f"\n_parse_pdf: file_path={_file_path}")
|
|
69
|
+
with open(_file_path, "rb") as _file:
|
|
70
|
+
sample_pdf = _file.read()
|
|
71
|
+
filename, xml = get_facturx_xml_from_pdf(sample_pdf, False)
|
|
72
|
+
print(xml)
|
|
73
|
+
if not xml or len(xml) == 0:
|
|
74
|
+
raise FileNotFoundError(
|
|
75
|
+
f"Could not extraxt XML from PDF file: {filepath}")
|
|
76
|
+
res = parse_and_map_x_rechnung(xml)
|
|
77
|
+
self.assertIsNotNone(res)
|
|
78
|
+
return res
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == '__main__':
|
|
82
|
+
unittest.main()
|
util/__init__.py
ADDED
|
File without changes
|
util/file_helper.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper functions for working with files and directories.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from os import path as os_path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_checked_file_path(file_path: str, rel_file=None) -> (str, bool, bool):
|
|
9
|
+
"""
|
|
10
|
+
get relative or absolute file path
|
|
11
|
+
Args:
|
|
12
|
+
file_path: path to file, relative or absolute
|
|
13
|
+
rel_file: if not none then this is the base dir-path to the file_path
|
|
14
|
+
|
|
15
|
+
Returns: (str, bool, bool) resulting path to file, file exists, is directory
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
if rel_file is None or file_path.startswith("/"):
|
|
19
|
+
_path = os_path.normpath(file_path) # absolute
|
|
20
|
+
else:
|
|
21
|
+
rel_dir = os_path.dirname(rel_file)
|
|
22
|
+
fp = os_path.normpath(file_path)
|
|
23
|
+
_path = os_path.join(rel_dir, fp)
|
|
24
|
+
return _path, os_path.exists(_path), os_path.isdir(_path)
|
x_mapper/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from .cross_industry_invoice_mapper import parse_and_map_x_rechnung
|
|
2
|
+
from .xml_abstract_x_rechnung_parser import XMLAbstractXRechnungParser
|
|
3
|
+
from .xml_cii_dom_parser import XRechnungCIIXMLParser
|
|
4
|
+
from .xml_ubl_sax_parser import XRechnungUblXMLParser
|
|
5
|
+
from .drafthorse_elements_helper import get_string_from_text as get_string_from_text
|
|
6
|
+
__all__ = ["parse_and_map_x_rechnung", "cross_industry_invoice_mapper", "drafthorse_elements_helper",
|
|
7
|
+
"XMLAbstractXRechnungParser",
|
|
8
|
+
"XRechnungCIIXMLParser", "XRechnungUblXMLParser"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This implements a mapper from a drafthorse parsed x-rechnung-xml to the internal XRechnung object
|
|
3
|
+
"""
|
|
4
|
+
from lxml import etree
|
|
5
|
+
|
|
6
|
+
from ..model.x_rechnung import XRechnung
|
|
7
|
+
from ..x_mapper.xml_cii_dom_parser import XRechnungCIIXMLParser
|
|
8
|
+
from ..x_mapper.xml_ubl_sax_parser import XRechnungUblXMLParser
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_and_map_x_rechnung(_xml: any) -> XRechnung:
|
|
12
|
+
_parser = None
|
|
13
|
+
tree = etree.fromstring(_xml)
|
|
14
|
+
if tree.tag == '{urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100}CrossIndustryInvoice':
|
|
15
|
+
_parser = XRechnungCIIXMLParser()
|
|
16
|
+
elif tree.tag == '{urn:oasis:names:specification:ubl:schema:xsd:Invoice-2}Invoice':
|
|
17
|
+
_parser = XRechnungUblXMLParser()
|
|
18
|
+
if _parser is None:
|
|
19
|
+
raise ValueError(f'xml format not supported: "{tree.tag}"')
|
|
20
|
+
return _parser.parse_and_map_x_rechnung(_xml)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from drafthorse.models.elements import (StringElement, DirectDateTimeElement, DateTimeElement, DecimalElement,
|
|
2
|
+
IndicatorElement, QuantityElement, CurrencyElement, ClassificationElement,
|
|
3
|
+
Container)
|
|
4
|
+
from drafthorse.models.party import EmailURI, PhoneNumber, FaxNumber
|
|
5
|
+
from drafthorse.models.note import IncludedNote
|
|
6
|
+
from drafthorse.models.payment import PaymentTerms
|
|
7
|
+
from drafthorse.models.container import IDContainer, StringContainer, CurrencyContainer
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
Code zur Beschreibung des Datumsformats
|
|
11
|
+
Codeliste:
|
|
12
|
+
102 = CCYYMMDD
|
|
13
|
+
201 = YYMMDDHHMM
|
|
14
|
+
615 = YYWW
|
|
15
|
+
616 = CCYYWW
|
|
16
|
+
720 = THHMMTHHMM
|
|
17
|
+
804 = Anzahl Tage
|
|
18
|
+
Beispiel: <DocumentDate FormatCode="102">20160331</DocumentDate>
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_value(self) -> any:
|
|
23
|
+
return self._value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_value_from_amount(self) -> any:
|
|
27
|
+
return self._amount
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_currency(self) -> (any, any):
|
|
31
|
+
return self._amount, self._currency
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_currencies(self) -> [(any, any)]:
|
|
35
|
+
res = []
|
|
36
|
+
if self.children:
|
|
37
|
+
for currency in self.children:
|
|
38
|
+
res.append(currency)
|
|
39
|
+
return res
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_string_from_text(self) -> str:
|
|
43
|
+
return self._text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_string_from_payment_terms(self, separator) -> str:
|
|
47
|
+
return str(self.description)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_string_from_value(self) -> str:
|
|
51
|
+
return str(self._value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_string_from_data(self) -> str:
|
|
55
|
+
return str(self._data)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_string_from_address(self) -> str:
|
|
59
|
+
return str(self.address)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_string_from_number(self) -> str:
|
|
63
|
+
return str(self.number)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_string_from_date(self) -> str:
|
|
67
|
+
return str(self._value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_string_from_quantity(self) -> str:
|
|
71
|
+
return str(f"{self._amount} {self._unit_code}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_string_from_currency(self) -> str:
|
|
75
|
+
return str(f"{self._amount} {self._currency}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_string_from_content(self, separator) -> str:
|
|
79
|
+
if not self.content:
|
|
80
|
+
return ""
|
|
81
|
+
return self.content.get_string_elements(separator)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_string_elements(self, separator: str = ';') -> str:
|
|
85
|
+
res = ""
|
|
86
|
+
_separator = ""
|
|
87
|
+
if self.children:
|
|
88
|
+
for child in self.children:
|
|
89
|
+
if isinstance(child, str):
|
|
90
|
+
res += _separator + child
|
|
91
|
+
elif isinstance(child, tuple):
|
|
92
|
+
p1, p2 = child[:]
|
|
93
|
+
res += f"{p1} {p2} {_separator}"
|
|
94
|
+
else:
|
|
95
|
+
res += _separator + child.get_string(separator)
|
|
96
|
+
_separator = separator
|
|
97
|
+
return res
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_ids(self) -> list:
|
|
101
|
+
res = list()
|
|
102
|
+
if self.children:
|
|
103
|
+
for text, _id, in self.children:
|
|
104
|
+
res.append({
|
|
105
|
+
'id': _id,
|
|
106
|
+
'schema': text,
|
|
107
|
+
})
|
|
108
|
+
return res
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
StringElement.get_string = get_string_from_text
|
|
112
|
+
StringContainer.get_string = get_string_elements
|
|
113
|
+
DirectDateTimeElement.get_string = get_string_from_date
|
|
114
|
+
DateTimeElement.get_string = get_string_from_date
|
|
115
|
+
DateTimeElement.get_value = get_value
|
|
116
|
+
DecimalElement.get_string = get_string_from_value
|
|
117
|
+
DecimalElement.get_value = get_value
|
|
118
|
+
IndicatorElement.get_string = get_string_from_value
|
|
119
|
+
QuantityElement.get_string = get_string_from_quantity
|
|
120
|
+
QuantityElement.get_value = get_value_from_amount
|
|
121
|
+
CurrencyElement.get_currency = get_currency
|
|
122
|
+
CurrencyContainer.get_string = get_string_elements
|
|
123
|
+
CurrencyContainer.get_currencies = get_currencies
|
|
124
|
+
ClassificationElement.get_string = get_string_from_text
|
|
125
|
+
IDContainer.get_object = get_ids
|
|
126
|
+
EmailURI.get_string = get_string_from_address
|
|
127
|
+
PhoneNumber.get_string = get_string_from_number
|
|
128
|
+
FaxNumber.get_string = get_string_from_number
|
|
129
|
+
Container.get_string_elements = get_string_elements
|
|
130
|
+
IncludedNote.get_string = get_string_from_content
|
|
131
|
+
IncludedNote.get_string_elements = get_string_elements
|
|
132
|
+
PaymentTerms.get_string = get_string_from_payment_terms
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from ..model.x_rechnung import XRechnung
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class XMLAbstractXRechnungParser(ABC):
|
|
7
|
+
TYPE_CODES = {
|
|
8
|
+
'326': 'Partial invoice',
|
|
9
|
+
'380': 'Commercial invoice', # Rechnung
|
|
10
|
+
'384': 'Corrected invoice', # Rechnungskorrektur
|
|
11
|
+
'389': 'Self-billed invoice', # Selbst erstelle Rechnung
|
|
12
|
+
'381': 'Credit note', # Gutschrift
|
|
13
|
+
'875': 'Partial Invoice', # Abschlagsrechnung
|
|
14
|
+
'876': 'Partial final invoice', # Teilschlussrechnung
|
|
15
|
+
'877': 'Final invoice' # Schlussrechnung
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def parse_and_map_x_rechnung(_xml: any) -> XRechnung:
|
|
21
|
+
pass
|