@@ -1,3 +1,4 @@
|
||||
* Replace the VIES wizard by an automatic background task
|
||||
* Guess the type of contact mechanism
|
||||
|
||||
Version 8.0.0 - 2026-04-20
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from trytond.i18n import gettext
|
||||
from trytond.model import (
|
||||
ModelSingleton, ModelSQL, ModelView, MultiValueMixin, ValueMixin, fields)
|
||||
@@ -28,6 +31,9 @@ class Configuration(ModelSingleton, ModelSQL, ModelView, MultiValueMixin):
|
||||
IDENTIFIER_TYPES, "Identifier Types", sort=False,
|
||||
help="Defines which identifier types are available.\n"
|
||||
"Leave empty for all of them.")
|
||||
identifier_eu_vat_validation_period = fields.TimeDelta(
|
||||
"European VAT Number Validation Period", required=True,
|
||||
help="The period during which the number is still considered valid.")
|
||||
|
||||
@classmethod
|
||||
def __register__(cls, module):
|
||||
@@ -55,6 +61,10 @@ class Configuration(ModelSingleton, ModelSQL, ModelView, MultiValueMixin):
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def default_identifier_eu_vat_validation_period(cls):
|
||||
return dt.timedelta(days=365)
|
||||
|
||||
def get_identifier_types(self):
|
||||
selection = self.fields_get(
|
||||
['identifier_types'])['identifier_types']['selection']
|
||||
|
||||
@@ -43,17 +43,6 @@ stuck onto envelopes.
|
||||
Wizards
|
||||
-------
|
||||
|
||||
.. _wizard-party.check_vies:
|
||||
|
||||
Check VIES
|
||||
^^^^^^^^^^
|
||||
|
||||
The *Check* :abbr:`VIES (VAT Information Exchange System)` wizard uses the
|
||||
European Commission's `VIES web service`_ to verify that a party's
|
||||
VAT-identification number is valid.
|
||||
|
||||
.. _VIES web service: https://ec.europa.eu/taxation_customs/vies/
|
||||
|
||||
.. _wizard-party.replace:
|
||||
|
||||
Replace
|
||||
@@ -90,6 +79,10 @@ They include things like company, personal identification, social security,
|
||||
tax and vat registration numbers.
|
||||
Most types of identifiers are checked by Tryton before they get saved to
|
||||
ensure that they are valid.
|
||||
The :guilabel:`European VAT Number`'s are also checked automatically against
|
||||
the European Commission's `VIES web service`_.
|
||||
|
||||
.. _VIES web service: https://ec.europa.eu/taxation_customs/vies/
|
||||
|
||||
.. _model-party.address:
|
||||
|
||||
|
||||
@@ -78,15 +78,6 @@ that new parties are given a language.
|
||||
This, in turn, can be important as Tryton will automatically generate reports,
|
||||
such as invoices and delivery notes, in the correct language for each party.
|
||||
|
||||
.. _Checking VAT numbers are valid:
|
||||
|
||||
Checking VAT numbers are valid
|
||||
==============================
|
||||
|
||||
If the `Party <model-party.party>` is in the European Union you can use the
|
||||
`Check VIES <wizard-party.check_vies>` on it to check whether its VAT number
|
||||
is valid or not.
|
||||
|
||||
.. _Merging duplicate parties together:
|
||||
|
||||
Merging duplicate parties together
|
||||
|
||||
@@ -6,6 +6,21 @@ from trytond.tools import escape_wildcard, grouped_slice
|
||||
from trytond.transaction import Transaction
|
||||
|
||||
|
||||
class Cron(metaclass=PoolMeta):
|
||||
__name__ = 'ir.cron'
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super().__setup__()
|
||||
cls.method.selection.extend([
|
||||
('party.identifier|check_vies',
|
||||
"Check European VAT Number with VIES"),
|
||||
])
|
||||
cls._notifications.update({
|
||||
'party.identifier|check_vies',
|
||||
})
|
||||
|
||||
|
||||
class Email(metaclass=PoolMeta):
|
||||
__name__ = 'ir.email'
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<record model="ir.message" id="msg_vies_unavailable">
|
||||
<field name="text">The VIES service is unavailable, try again later.</field>
|
||||
</record>
|
||||
<record model="ir.message" id="msg_party_identifier_eu_vat_invalid">
|
||||
<field name="text">The VAT numbers "%(identifiers)s" are not valid for the VIES service.</field>
|
||||
</record>
|
||||
<record model="ir.message" id="msg_different_name">
|
||||
<field name="text">Parties have different names: "%(source_name)s" vs "%(destination_name)s".</field>
|
||||
</record>
|
||||
|
||||
+147
-60
@@ -1,12 +1,13 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
import stdnum.exceptions
|
||||
from sql import Column, Literal
|
||||
from sql import Column, Literal, Null
|
||||
from sql.aggregate import Min
|
||||
from sql.functions import CharLength
|
||||
from stdnum import get_cc_module
|
||||
@@ -19,7 +20,7 @@ from trytond.model import (
|
||||
ValueMixin, convert_from, fields, sequence_ordered)
|
||||
from trytond.model.exceptions import AccessError
|
||||
from trytond.pool import Pool
|
||||
from trytond.pyson import Bool, Eval
|
||||
from trytond.pyson import Bool, Eval, If, PYSONEncoder
|
||||
from trytond.tools import is_full_text, lstrip_wildcard
|
||||
from trytond.transaction import Transaction, inactive_records
|
||||
from trytond.wizard import Button, StateTransition, StateView, Wizard
|
||||
@@ -847,13 +848,47 @@ class Identifier(sequence_ordered(), DeactivableMixin, ModelSQL, ModelView):
|
||||
fields.Boolean("Type of Address"), 'on_change_with_type_address')
|
||||
code = fields.Char('Code', required=True)
|
||||
code_compact = fields.Char("Code Compact", readonly=True, required=True)
|
||||
eu_vat_valid = fields.Boolean(
|
||||
"European VAT Number Valid", readonly=True,
|
||||
states={
|
||||
'invisible': Eval('type') != 'eu_vat',
|
||||
},
|
||||
help="Checked if the number has been validated on VIES service.")
|
||||
eu_vat_validated_at = fields.DateTime(
|
||||
"European VAT Number Validated At", readonly=True,
|
||||
states={
|
||||
'invisible': Eval('type') != 'eu_vat',
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
cls.code.search_unaccented = False
|
||||
cls.code_compact.search_unaccented = False
|
||||
super().__setup__()
|
||||
t = cls.__table__()
|
||||
cls._sql_indexes.update({
|
||||
Index(
|
||||
t,
|
||||
(t.active, Index.Equality(cardinality='low')),
|
||||
(t.type, Index.Equality(cardinality='low')),
|
||||
(t.eu_vat_valid, Index.Equality(cardinality='low')),
|
||||
where=(t.type == 'eu_vat')
|
||||
& ((t.eu_vat_valid == Literal(False))
|
||||
| (t.eu_vat_valid == Null))
|
||||
& (t.active == Literal(True))),
|
||||
Index(
|
||||
t,
|
||||
(t.active, Index.Equality(cardinality='low')),
|
||||
(t.type, Index.Equality(cardinality='low')),
|
||||
(t.eu_vat_validated_at, Index.Range()),
|
||||
where=(t.type == 'eu_vat')
|
||||
& (t.active == Literal(True))),
|
||||
})
|
||||
cls.__access__.add('party')
|
||||
cls._buttons.update(
|
||||
check_vies_button={
|
||||
'invisible': Eval('type') != 'eu_vat',
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def __register__(cls, module_name):
|
||||
@@ -965,8 +1000,21 @@ class Identifier(sequence_ordered(), DeactivableMixin, ModelSQL, ModelView):
|
||||
values['code_compact'] = module.compact(code)
|
||||
except stdnum.exceptions.ValidationError:
|
||||
pass
|
||||
if mode == 'write':
|
||||
if {'type', 'code'} & values.keys():
|
||||
values['eu_vat_valid'] = None
|
||||
values['eu_vat_validated_at'] = None
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def on_modification(cls, mode, identifiers, field_names=None):
|
||||
super().on_modification(mode, identifiers, field_names=field_names)
|
||||
if (mode == 'create'
|
||||
or (mode == 'write' and (
|
||||
field_names is None
|
||||
or {'type', 'code'} & field_names))):
|
||||
cls.__queue__.check_vies(identifiers)
|
||||
|
||||
def compute_fields(self, field_names=None):
|
||||
values = super().compute_fields(field_names=field_names)
|
||||
if field_names is None or {'type', 'code'} & field_names:
|
||||
@@ -1015,68 +1063,107 @@ class Identifier(sequence_ordered(), DeactivableMixin, ModelSQL, ModelView):
|
||||
type=other.type_string,
|
||||
code=other.code))
|
||||
|
||||
@classmethod
|
||||
def copy(cls, identifiers, default=None):
|
||||
default = default.copy() if default is not None else {}
|
||||
default.setdefault('eu_vat_valid')
|
||||
default.setdefault('eu_vat_validated_at')
|
||||
return super().copy(identifiers, default=default)
|
||||
|
||||
class CheckVIESResult(ModelView):
|
||||
__name__ = 'party.check_vies.result'
|
||||
parties_succeed = fields.Many2Many('party.party', None, None,
|
||||
'Parties Succeed', readonly=True, states={
|
||||
'invisible': ~Eval('parties_succeed'),
|
||||
})
|
||||
parties_failed = fields.Many2Many('party.party', None, None,
|
||||
'Parties Failed', readonly=True, states={
|
||||
'invisible': ~Eval('parties_failed'),
|
||||
})
|
||||
@classmethod
|
||||
def view_attributes(cls):
|
||||
return super().view_attributes() + [
|
||||
('/tree', 'visual',
|
||||
If((Eval('type') == 'eu_vat')
|
||||
& ~Eval('eu_vat_valid'),
|
||||
If(Eval('eu_vat_validated_at'),
|
||||
'danger',
|
||||
'warning'),
|
||||
'')),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
@ModelView.button
|
||||
def check_vies_button(cls, identifiers):
|
||||
cls.check_vies(identifiers)
|
||||
|
||||
class CheckVIES(Wizard):
|
||||
__name__ = 'party.check_vies'
|
||||
start_state = 'check'
|
||||
@classmethod
|
||||
def check_vies(cls, identifiers=None):
|
||||
pool = Pool()
|
||||
Configuration = pool.get('party.configuration')
|
||||
Cron = pool.get('ir.cron')
|
||||
|
||||
check = StateTransition()
|
||||
result = StateView('party.check_vies.result',
|
||||
'party.check_vies_result', [
|
||||
Button('OK', 'end', 'tryton-ok', True),
|
||||
])
|
||||
|
||||
def transition_check(self):
|
||||
parties_succeed = []
|
||||
parties_failed = []
|
||||
for party in self.records:
|
||||
for identifier in party.identifiers:
|
||||
if identifier.type != 'eu_vat':
|
||||
continue
|
||||
eu_vat = get_cc_module('eu', 'vat')
|
||||
try:
|
||||
if not eu_vat.check_vies(identifier.code)['valid']:
|
||||
parties_failed.append(party.id)
|
||||
else:
|
||||
parties_succeed.append(party.id)
|
||||
except Exception as e:
|
||||
for msg in e.args:
|
||||
if msg == 'INVALID_INPUT':
|
||||
parties_failed.append(party.id)
|
||||
break
|
||||
elif msg in {
|
||||
'SERVICE_UNAVAILABLE',
|
||||
'MS_UNAVAILABLE',
|
||||
'MS_MAX_CONCURRENT_REQ',
|
||||
'GLOBAL_MS_MAX_CONCURRENT_REQ',
|
||||
'TIMEOUT',
|
||||
'SERVER_BUSY',
|
||||
}:
|
||||
raise VIESUnavailable(
|
||||
gettext('party.msg_vies_unavailable')) from e
|
||||
else:
|
||||
raise
|
||||
self.result.parties_succeed = parties_succeed
|
||||
self.result.parties_failed = parties_failed
|
||||
return 'result'
|
||||
|
||||
def default_result(self, fields):
|
||||
return {
|
||||
'parties_succeed': [p.id for p in self.result.parties_succeed],
|
||||
'parties_failed': [p.id for p in self.result.parties_failed],
|
||||
}
|
||||
config = Configuration(1)
|
||||
now = dt.datetime.now()
|
||||
if identifiers is None:
|
||||
identifiers = cls.search([
|
||||
('type', '=', 'eu_vat'),
|
||||
['OR',
|
||||
('eu_vat_valid', '=', False),
|
||||
('eu_vat_validated_at', '<',
|
||||
now - config.identifier_eu_vat_validation_period),
|
||||
],
|
||||
],
|
||||
order=[])
|
||||
if not identifiers:
|
||||
return
|
||||
failed, succeeded = [], []
|
||||
for identifier in identifiers:
|
||||
if identifier.type != 'eu_vat':
|
||||
continue
|
||||
eu_vat = get_cc_module('eu', 'vat')
|
||||
try:
|
||||
if not eu_vat.check_vies(identifier.code)['valid']:
|
||||
failed.append(identifier)
|
||||
else:
|
||||
succeeded.append(identifier)
|
||||
except ImportError:
|
||||
# Missing dependencies so we consider the identifier as valid
|
||||
succeeded.append(identifier)
|
||||
except Exception as e:
|
||||
for msg in e.args:
|
||||
if msg == 'INVALID_INPUT':
|
||||
failed.append(identifier)
|
||||
break
|
||||
elif msg in {
|
||||
'SERVICE_UNAVAILABLE',
|
||||
'MS_UNAVAILABLE',
|
||||
'MS_MAX_CONCURRENT_REQ',
|
||||
'GLOBAL_MS_MAX_CONCURRENT_REQ',
|
||||
'TIMEOUT',
|
||||
'SERVER_BUSY',
|
||||
}:
|
||||
raise VIESUnavailable(
|
||||
gettext('party.msg_vies_unavailable')) from e
|
||||
else:
|
||||
raise
|
||||
cls.write(succeeded, {
|
||||
'eu_vat_valid': True,
|
||||
'eu_vat_validated_at': now,
|
||||
})
|
||||
if failed:
|
||||
encoder = PYSONEncoder()
|
||||
cls.write(failed, {
|
||||
'eu_vat_valid': False,
|
||||
'eu_vat_validated_at': now,
|
||||
})
|
||||
names = ', '.join(i.rec_name for i in failed[:5])
|
||||
if len(failed) < 5:
|
||||
domain = [('id', 'in', [i.id for i in failed])]
|
||||
else:
|
||||
names += '...'
|
||||
domain = [
|
||||
('type', '=', 'eu_vat'),
|
||||
('eu_vat_valid', '=', False),
|
||||
]
|
||||
Cron.notify(
|
||||
'tryton-error',
|
||||
'party.act_identifier_form', {
|
||||
'pyson_domain': encoder.encode(domain),
|
||||
},
|
||||
'party.msg_party_identifier_eu_vat_invalid',
|
||||
identifiers=names,
|
||||
)
|
||||
|
||||
|
||||
class Replace(Wizard):
|
||||
|
||||
+11
-15
@@ -147,21 +147,10 @@ this repository contains the full copyright notices and license terms. -->
|
||||
sequence="20"
|
||||
id="menu_identifier_form"/>
|
||||
|
||||
<record model="ir.action.wizard" id="wizard_check_vies">
|
||||
<field name="name">Check VIES</field>
|
||||
<field name="wiz_name">party.check_vies</field>
|
||||
<field name="model">party.party</field>
|
||||
</record>
|
||||
<record model="ir.action.keyword" id="check_vies_keyword">
|
||||
<field name="keyword">form_action</field>
|
||||
<field name="model">party.party,-1</field>
|
||||
<field name="action" ref="wizard_check_vies"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="check_vies_result">
|
||||
<field name="model">party.check_vies.result</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">check_vies_result</field>
|
||||
<record model="ir.model.button" id="identifier_check_view_button">
|
||||
<field name="model">party.identifier</field>
|
||||
<field name="name">check_vies_button</field>
|
||||
<field name="string">Check VIES</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.wizard" id="wizard_replace">
|
||||
@@ -207,4 +196,11 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="name">erase_ask_form</field>
|
||||
</record>
|
||||
</data>
|
||||
<data noupdate="1">
|
||||
<record model="ir.cron" id="cron_party_identifier_check_vies">
|
||||
<field name="method">party.identifier|check_vies</field>
|
||||
<field name="interval_number" eval="1"/>
|
||||
<field name="interval_type">days</field>
|
||||
</record>
|
||||
</data>
|
||||
</tryton>
|
||||
|
||||
@@ -15,13 +15,13 @@ xml:
|
||||
|
||||
[register]
|
||||
model:
|
||||
ir.Cron
|
||||
country.PostalCode
|
||||
category.Category
|
||||
party.Party
|
||||
party.PartyLang
|
||||
party.PartyCategory
|
||||
party.Identifier
|
||||
party.CheckVIESResult
|
||||
party.ReplaceAsk
|
||||
party.EraseAsk
|
||||
address.Address
|
||||
@@ -36,6 +36,5 @@ model:
|
||||
ir.EmailTemplate
|
||||
ir.Channel
|
||||
wizard:
|
||||
party.CheckVIES
|
||||
party.Replace
|
||||
party.Erase
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. -->
|
||||
<form col="1">
|
||||
<field name="parties_succeed"/>
|
||||
<field name="parties_failed"/>
|
||||
</form>
|
||||
@@ -9,4 +9,6 @@ this repository contains the full copyright notices and license terms. -->
|
||||
|
||||
<separator name="identifier_types" colspan="4"/>
|
||||
<field name="identifier_types" colspan="4" height="200"/>
|
||||
<label name="identifier_eu_vat_validation_period"/>
|
||||
<field name="identifier_eu_vat_validation_period"/>
|
||||
</form>
|
||||
|
||||
@@ -15,6 +15,14 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<label name="code"/>
|
||||
<field name="code"/>
|
||||
|
||||
<group colspan="2" col="-1" id="eu_vat">
|
||||
<label name="eu_vat_valid" string="Valid:"/>
|
||||
<field name="eu_vat_valid"/>
|
||||
<label name="eu_vat_validated_at" string="at"/>
|
||||
<field name="eu_vat_validated_at"/>
|
||||
</group>
|
||||
<button name="check_vies_button" colspan="2"/>
|
||||
|
||||
<label name="address"/>
|
||||
<field name="address"/>
|
||||
</form>
|
||||
|
||||
@@ -6,4 +6,6 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="address" expand="1"/>
|
||||
<field name="type" expand="1"/>
|
||||
<field name="code" expand="2"/>
|
||||
<field name="eu_vat_valid" optional="1"/>
|
||||
<field name="eu_vat_validated_at" widget="date" tree_invisible="1"/>
|
||||
</tree>
|
||||
|
||||
@@ -6,4 +6,6 @@ this repository contains the full copyright notices and license terms. -->
|
||||
<field name="address" expand="1"/>
|
||||
<field name="type" expand="1"/>
|
||||
<field name="code" expand="2"/>
|
||||
<field name="eu_vat_valid" optional="1"/>
|
||||
<field name="eu_vat_validated_at" widget="date" tree_invisible="1"/>
|
||||
</tree>
|
||||
|
||||
Reference in New Issue
Block a user