Merge branch 'main' into 6.8
This commit is contained in:
commit
eb183946a3
5 changed files with 173 additions and 9 deletions
|
@ -12,3 +12,5 @@ https://portal3.gefeg.com/projectdata/invoice/deliverables/installed/publishingp
|
||||||
|
|
||||||
https://erechnungsvalidator.service-bw.de/
|
https://erechnungsvalidator.service-bw.de/
|
||||||
https://ecosio.com/de/peppol-und-xml-dokumente-online-validieren/
|
https://ecosio.com/de/peppol-und-xml-dokumente-online-validieren/
|
||||||
|
|
||||||
|
https://www.e-rechnungs-checker.de/
|
||||||
|
|
62
mixin.py
62
mixin.py
|
@ -10,6 +10,7 @@ from trytond.exceptions import UserError
|
||||||
from trytond.i18n import gettext
|
from trytond.i18n import gettext
|
||||||
from trytond.tools import cached_property
|
from trytond.tools import cached_property
|
||||||
from trytond.pool import Pool
|
from trytond.pool import Pool
|
||||||
|
from trytond.transaction import Transaction
|
||||||
from trytond.modules.product import round_price
|
from trytond.modules.product import round_price
|
||||||
|
|
||||||
|
|
||||||
|
@ -160,6 +161,41 @@ class EdocumentMixin(object):
|
||||||
taxname=tax.rec_name))
|
taxname=tax.rec_name))
|
||||||
return tax.unece_code
|
return tax.unece_code
|
||||||
|
|
||||||
|
def get_line_amount(self, line):
|
||||||
|
""" get amount of current invoice-line,
|
||||||
|
depends on modegross of invoice, set used-modegross to 'net'
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line (record): model account.invoice.line
|
||||||
|
"""
|
||||||
|
if line.modegross == 'net':
|
||||||
|
return line.amount
|
||||||
|
elif line.modegross == 'gross':
|
||||||
|
# get net-amount
|
||||||
|
# copy from account_invoice/invoice.py:2416-2434
|
||||||
|
currency = (
|
||||||
|
line.invoice.currency
|
||||||
|
if line.invoice else line.currency)
|
||||||
|
|
||||||
|
amount = (Decimal(str(line.quantity or 0)) * (
|
||||||
|
line.unit_price or Decimal(0)))
|
||||||
|
invoice_type = (
|
||||||
|
line.invoice.type
|
||||||
|
if line.invoice else line.invoice_type)
|
||||||
|
|
||||||
|
if (invoice_type == 'in'
|
||||||
|
and line.taxes_deductible_rate is not None
|
||||||
|
and line.taxes_deductible_rate != 1):
|
||||||
|
with Transaction().set_context(_deductible_rate=1):
|
||||||
|
tax_amount = sum(
|
||||||
|
t['amount'] for t in line._get_taxes().values())
|
||||||
|
non_deductible_amount = (
|
||||||
|
tax_amount * (1 - line.taxes_deductible_rate))
|
||||||
|
amount += non_deductible_amount
|
||||||
|
if currency:
|
||||||
|
return currency.round(amount)
|
||||||
|
return amount
|
||||||
|
|
||||||
def get_tax_unece_code(self, tax):
|
def get_tax_unece_code(self, tax):
|
||||||
while tax:
|
while tax:
|
||||||
if tax.unece_code:
|
if tax.unece_code:
|
||||||
|
@ -203,4 +239,30 @@ class EdocumentMixin(object):
|
||||||
if text:
|
if text:
|
||||||
return html.escape(text)
|
return html.escape(text)
|
||||||
|
|
||||||
|
def _party_legal_types(self):
|
||||||
|
""" get list of identifier-types to be used as
|
||||||
|
legal-ids
|
||||||
|
"""
|
||||||
|
return ['de_handelsregisternummer']
|
||||||
|
|
||||||
|
def party_legal_ids(self, party, address):
|
||||||
|
""" get list of legal-ids of party
|
||||||
|
|
||||||
|
Args:
|
||||||
|
party (record): model party.party
|
||||||
|
address (record): model party.address
|
||||||
|
"""
|
||||||
|
result = super().party_legal_ids(party, address)
|
||||||
|
|
||||||
|
legal_types = self._party_legal_types()
|
||||||
|
if party and party.identifiers:
|
||||||
|
for x in party.identifiers:
|
||||||
|
if x.type in legal_types:
|
||||||
|
if x.address:
|
||||||
|
if x.address == address:
|
||||||
|
result.append((x.rec_name, {'schemeID': '0002'}))
|
||||||
|
else:
|
||||||
|
result.append((x.rec_name, {'schemeID': '0002'}))
|
||||||
|
return result
|
||||||
|
|
||||||
# end EdocumentMixin
|
# end EdocumentMixin
|
||||||
|
|
|
@ -19,7 +19,7 @@ this repository contains the full copyright notices and license terms. -->
|
||||||
</py:for>
|
</py:for>
|
||||||
</ram:SpecifiedLegalOrganization>
|
</ram:SpecifiedLegalOrganization>
|
||||||
<ram:PostalTradeAddress py:if="address">${TradeAddress(address)}</ram:PostalTradeAddress>
|
<ram:PostalTradeAddress py:if="address">${TradeAddress(address)}</ram:PostalTradeAddress>
|
||||||
<ram:SpecifiedTaxRegistration py:if="tax_identifier and tax_identifier.type == 'eu_vat'">
|
<ram:SpecifiedTaxRegistration py:if="tax_identifier and tax_identifier.type.endswith('_vat')">
|
||||||
<ram:ID schemeID='VA'>${tax_identifier.code}</ram:ID>
|
<ram:ID schemeID='VA'>${tax_identifier.code}</ram:ID>
|
||||||
</ram:SpecifiedTaxRegistration>
|
</ram:SpecifiedTaxRegistration>
|
||||||
</py:def>
|
</py:def>
|
||||||
|
@ -38,7 +38,7 @@ this repository contains the full copyright notices and license terms. -->
|
||||||
<ram:ApplicableTradeTax>
|
<ram:ApplicableTradeTax>
|
||||||
<ram:CalculatedAmount py:if="amount" py:attrs="{'currencyID': this.invoice.currency.code}">${amount * this.type_sign}</ram:CalculatedAmount>
|
<ram:CalculatedAmount py:if="amount" py:attrs="{'currencyID': this.invoice.currency.code}">${amount * this.type_sign}</ram:CalculatedAmount>
|
||||||
<ram:TypeCode>${this.tax_unece_code(tax)}</ram:TypeCode>
|
<ram:TypeCode>${this.tax_unece_code(tax)}</ram:TypeCode>
|
||||||
<ram:ExemptionReason py:if="tax.legal_notice">${tax.legal_notice}</ram:ExemptionReason>
|
<ram:ExemptionReason py:if="tax.legal_notice and not this.tax_category_code(tax)">${tax.legal_notice}</ram:ExemptionReason>
|
||||||
<ram:BasisAmount py:if="base">${base * this.type_sign}</ram:BasisAmount>
|
<ram:BasisAmount py:if="base">${base * this.type_sign}</ram:BasisAmount>
|
||||||
<ram:CategoryCode>${this.tax_category_code(tax)}</ram:CategoryCode>
|
<ram:CategoryCode>${this.tax_category_code(tax)}</ram:CategoryCode>
|
||||||
<ram:RateApplicablePercent py:if="tax.type == 'percentage'">${tax.rate * 100}</ram:RateApplicablePercent>
|
<ram:RateApplicablePercent py:if="tax.type == 'percentage'">${tax.rate * 100}</ram:RateApplicablePercent>
|
||||||
|
@ -71,8 +71,8 @@ this repository contains the full copyright notices and license terms. -->
|
||||||
</ram:AssociatedDocumentLineDocument>
|
</ram:AssociatedDocumentLineDocument>
|
||||||
<ram:SpecifiedTradeProduct>
|
<ram:SpecifiedTradeProduct>
|
||||||
<ram:ID py:if="line.product and line.product.code">${line.product.code}</ram:ID>
|
<ram:ID py:if="line.product and line.product.code">${line.product.code}</ram:ID>
|
||||||
<ram:Name>${this.quote_text(line.product.name if line.product else '')}</ram:Name>
|
<ram:Name>${this.quote_text(line.product.name if line.product else line.description if line.description else 'name not set')}</ram:Name>
|
||||||
<ram:Description py:if="line.description">${this.quote_text(line.description)}</ram:Description>
|
<ram:Description py:if="line.description">${this.quote_text(line.description if line.product else '')}</ram:Description>
|
||||||
</ram:SpecifiedTradeProduct>
|
</ram:SpecifiedTradeProduct>
|
||||||
<ram:SpecifiedLineTradeAgreement>
|
<ram:SpecifiedLineTradeAgreement>
|
||||||
<ram:NetPriceProductTradePrice>
|
<ram:NetPriceProductTradePrice>
|
||||||
|
@ -85,7 +85,7 @@ this repository contains the full copyright notices and license terms. -->
|
||||||
<ram:SpecifiedLineTradeSettlement>
|
<ram:SpecifiedLineTradeSettlement>
|
||||||
${TradeTax(this.invoice_line_tax(line))}
|
${TradeTax(this.invoice_line_tax(line))}
|
||||||
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||||
<ram:LineTotalAmount py:attrs="{'currencyID': this.invoice.currency.code}">${line.amount}</ram:LineTotalAmount>
|
<ram:LineTotalAmount py:attrs="{'currencyID': this.invoice.currency.code}">${this.get_line_amount(line)}</ram:LineTotalAmount>
|
||||||
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||||
</ram:SpecifiedLineTradeSettlement>
|
</ram:SpecifiedLineTradeSettlement>
|
||||||
</ram:IncludedSupplyChainTradeLineItem>
|
</ram:IncludedSupplyChainTradeLineItem>
|
||||||
|
|
|
@ -7,7 +7,8 @@ from lxml import etree
|
||||||
import os
|
import os
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
|
from trytond.tests.test_tryton import (
|
||||||
|
ModuleTestCase, with_transaction, activate_module)
|
||||||
from trytond.pool import Pool
|
from trytond.pool import Pool
|
||||||
from trytond.modules.company.tests import create_company, set_company
|
from trytond.modules.company.tests import create_company, set_company
|
||||||
from trytond.modules.account.tests import create_chart, get_fiscalyear
|
from trytond.modules.account.tests import create_chart, get_fiscalyear
|
||||||
|
@ -42,6 +43,14 @@ class EdocTestCase(ModuleTestCase):
|
||||||
'Test e-rechnung module'
|
'Test e-rechnung module'
|
||||||
module = 'edocument_xrechnung'
|
module = 'edocument_xrechnung'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
activate_module([
|
||||||
|
'edocument_uncefact', 'party', 'bank',
|
||||||
|
'account_invoice', 'sale_point_invoice',
|
||||||
|
'product_grossprice'], 'en')
|
||||||
|
|
||||||
def prep_fiscalyear(self, company1):
|
def prep_fiscalyear(self, company1):
|
||||||
""" prepare fiscal year, sequences...
|
""" prepare fiscal year, sequences...
|
||||||
"""
|
"""
|
||||||
|
@ -69,6 +78,11 @@ class EdocTestCase(ModuleTestCase):
|
||||||
|
|
||||||
company = create_company('m-ds')
|
company = create_company('m-ds')
|
||||||
Party.write(*[[company.party], {
|
Party.write(*[[company.party], {
|
||||||
|
'identifiers': [('create', [
|
||||||
|
# post.de
|
||||||
|
{'type': 'de_handelsregisternummer', 'code': 'Bonn HRB 6792'},
|
||||||
|
{'type': 'de_vat', 'code': 'DE 169838187'},
|
||||||
|
])],
|
||||||
'addresses': [('write', [company.party.addresses[0]], {
|
'addresses': [('write', [company.party.addresses[0]], {
|
||||||
'country': country_de.id})]}])
|
'country': country_de.id})]}])
|
||||||
|
|
||||||
|
@ -84,7 +98,7 @@ class EdocTestCase(ModuleTestCase):
|
||||||
'number': 'DE02300209000106531065'}])]}])
|
'number': 'DE02300209000106531065'}])]}])
|
||||||
return company
|
return company
|
||||||
|
|
||||||
def prep_invoice(self, credit_note=False):
|
def prep_invoice(self, credit_note=False, modegross='net'):
|
||||||
""" add invoice
|
""" add invoice
|
||||||
"""
|
"""
|
||||||
pool = Pool()
|
pool = Pool()
|
||||||
|
@ -112,11 +126,12 @@ class EdocTestCase(ModuleTestCase):
|
||||||
}])
|
}])
|
||||||
|
|
||||||
currency1, = Currency.search([('code', '=', 'usd')])
|
currency1, = Currency.search([('code', '=', 'usd')])
|
||||||
|
Currency.write(*[[currency1], {'code': 'USD'}])
|
||||||
|
|
||||||
tax, = Taxes.search([('name', '=', '20% VAT')])
|
tax, = Taxes.search([('name', '=', '20% VAT')])
|
||||||
Taxes.write(*[
|
Taxes.write(*[
|
||||||
[tax],
|
[tax],
|
||||||
{'unece_code': 'GST', 'unece_category_code': 'S',
|
{'unece_code': 'VAT', 'unece_category_code': 'S',
|
||||||
'legal_notice': 'Legal Notice'}])
|
'legal_notice': 'Legal Notice'}])
|
||||||
|
|
||||||
account_lst = Account.search([
|
account_lst = Account.search([
|
||||||
|
@ -130,6 +145,7 @@ class EdocTestCase(ModuleTestCase):
|
||||||
|
|
||||||
to_create_invoice = [{
|
to_create_invoice = [{
|
||||||
'type': 'out',
|
'type': 'out',
|
||||||
|
'modegross': modegross,
|
||||||
'description': 'description of invoice',
|
'description': 'description of invoice',
|
||||||
'comment': 'note line 1\nnote line 2',
|
'comment': 'note line 1\nnote line 2',
|
||||||
'invoice_date': date(2024, 7, 1),
|
'invoice_date': date(2024, 7, 1),
|
||||||
|
@ -149,12 +165,17 @@ class EdocTestCase(ModuleTestCase):
|
||||||
'currency': currency1.id,
|
'currency': currency1.id,
|
||||||
}])],
|
}])],
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
if modegross == 'gross':
|
||||||
|
to_create_invoice[0]['lines'][0][1][0]['unit_gross_price'] = (
|
||||||
|
Decimal('50.0') * Decimal('1.2'))
|
||||||
|
|
||||||
inv_lst, = Invoice.create(to_create_invoice)
|
inv_lst, = Invoice.create(to_create_invoice)
|
||||||
inv_lst.on_change_lines()
|
inv_lst.on_change_lines()
|
||||||
inv_lst.save()
|
inv_lst.save()
|
||||||
Invoice.validate_invoice([inv_lst])
|
Invoice.validate_invoice([inv_lst])
|
||||||
Invoice.post([inv_lst])
|
Invoice.post([inv_lst])
|
||||||
self.assertEqual(inv_lst.currency.code, 'usd')
|
self.assertEqual(inv_lst.currency.code, 'USD')
|
||||||
self.assertEqual(len(inv_lst.move.lines), 3)
|
self.assertEqual(len(inv_lst.move.lines), 3)
|
||||||
return inv_lst
|
return inv_lst
|
||||||
|
|
||||||
|
@ -233,6 +254,69 @@ class EdocTestCase(ModuleTestCase):
|
||||||
{'identifiers': [('delete', [party.identifiers[0].id])]}]
|
{'identifiers': [('delete', [party.identifiers[0].id])]}]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@with_transaction()
|
||||||
|
def test_xrechn_export_facturx_gross(self):
|
||||||
|
""" run export - factur-x, modegross='gross'
|
||||||
|
"""
|
||||||
|
pool = Pool()
|
||||||
|
Template = pool.get('edocument.facturxext.invoice')
|
||||||
|
|
||||||
|
company = self.prep_company()
|
||||||
|
with set_company(company):
|
||||||
|
create_chart(company=company, tax=True)
|
||||||
|
self.prep_fiscalyear(company)
|
||||||
|
invoice = self.prep_invoice(modegross='gross')
|
||||||
|
|
||||||
|
template = Template(invoice)
|
||||||
|
|
||||||
|
schema_file = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
'Factur-X_1.07.2_EXTENDED',
|
||||||
|
'Factur-X_1.07.2_EXTENDED.xsd')
|
||||||
|
|
||||||
|
invoice_string = template.render('Factur-X-1.07.2-extended')
|
||||||
|
with open('gross_invoice_string.xml', 'wb') as fhdl:
|
||||||
|
fhdl.write(invoice_string)
|
||||||
|
|
||||||
|
invoice_xml = etree.fromstring(invoice_string)
|
||||||
|
|
||||||
|
# check values in xml
|
||||||
|
nodes = invoice_xml.xpath(self._readxml_xpath([
|
||||||
|
'rsm:CrossIndustryInvoice', 'rsm:SupplyChainTradeTransaction',
|
||||||
|
'ram:ApplicableHeaderTradeAgreement', 'ram:SellerTradeParty',
|
||||||
|
'ram:SpecifiedLegalOrganization', 'ram:ID']),
|
||||||
|
namespaces=invoice_xml.nsmap)
|
||||||
|
self.assertEqual(nodes[0].text, 'Bonn HRB 6792')
|
||||||
|
|
||||||
|
nodes = invoice_xml.xpath(self._readxml_xpath([
|
||||||
|
'rsm:CrossIndustryInvoice', 'rsm:SupplyChainTradeTransaction',
|
||||||
|
'ram:IncludedSupplyChainTradeLineItem',
|
||||||
|
'ram:SpecifiedLineTradeSettlement',
|
||||||
|
'ram:SpecifiedTradeSettlementLineMonetarySummation',
|
||||||
|
'ram:LineTotalAmount']),
|
||||||
|
namespaces=invoice_xml.nsmap)
|
||||||
|
self.assertEqual(nodes[0].text, '100.00')
|
||||||
|
|
||||||
|
schema = etree.XMLSchema(etree.parse(schema_file))
|
||||||
|
schema.assertValid(invoice_xml)
|
||||||
|
|
||||||
|
def _readxml_xpath(self, tags):
|
||||||
|
""" generate xpath
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tags (list): list of string or integer to build path
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for x in tags:
|
||||||
|
if isinstance(x, str):
|
||||||
|
parts.append(x)
|
||||||
|
elif isinstance(x, int):
|
||||||
|
if parts[-1].endswith(']'):
|
||||||
|
raise ValueError('multiple list selector')
|
||||||
|
parts[-1] += '[%d]' % x
|
||||||
|
result = '/' + '/'.join(parts)
|
||||||
|
return result
|
||||||
|
|
||||||
@with_transaction()
|
@with_transaction()
|
||||||
def test_xrechn_export_facturx(self):
|
def test_xrechn_export_facturx(self):
|
||||||
""" run export - factur-x
|
""" run export - factur-x
|
||||||
|
@ -248,6 +332,10 @@ class EdocTestCase(ModuleTestCase):
|
||||||
|
|
||||||
template = Template(invoice)
|
template = Template(invoice)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
template.party_legal_ids(invoice.company_party, None),
|
||||||
|
[('Bonn HRB 6792', {'schemeID': '0002'})])
|
||||||
|
|
||||||
schema_file = os.path.join(
|
schema_file = os.path.join(
|
||||||
os.path.dirname(__file__),
|
os.path.dirname(__file__),
|
||||||
'Factur-X_1.07.2_EXTENDED',
|
'Factur-X_1.07.2_EXTENDED',
|
||||||
|
@ -255,6 +343,15 @@ class EdocTestCase(ModuleTestCase):
|
||||||
|
|
||||||
invoice_string = template.render('Factur-X-1.07.2-extended')
|
invoice_string = template.render('Factur-X-1.07.2-extended')
|
||||||
invoice_xml = etree.fromstring(invoice_string)
|
invoice_xml = etree.fromstring(invoice_string)
|
||||||
|
|
||||||
|
# check values in xml
|
||||||
|
nodes = invoice_xml.xpath(self._readxml_xpath([
|
||||||
|
'rsm:CrossIndustryInvoice', 'rsm:SupplyChainTradeTransaction',
|
||||||
|
'ram:ApplicableHeaderTradeAgreement', 'ram:SellerTradeParty',
|
||||||
|
'ram:SpecifiedLegalOrganization', 'ram:ID']),
|
||||||
|
namespaces=invoice_xml.nsmap)
|
||||||
|
self.assertEqual(nodes[0].text, 'Bonn HRB 6792')
|
||||||
|
|
||||||
schema = etree.XMLSchema(etree.parse(schema_file))
|
schema = etree.XMLSchema(etree.parse(schema_file))
|
||||||
schema.assertValid(invoice_xml)
|
schema.assertValid(invoice_xml)
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,9 @@ depends:
|
||||||
party
|
party
|
||||||
bank
|
bank
|
||||||
account_invoice
|
account_invoice
|
||||||
|
extras_depend:
|
||||||
|
sale_point_invoice
|
||||||
|
product_grossprice
|
||||||
xml:
|
xml:
|
||||||
message.xml
|
message.xml
|
||||||
configuration.xml
|
configuration.xml
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue