Replace the VIES wizard by an automatic background task

Closes #14930
This commit is contained in:
Cédric Krier
2026-07-04 11:39:19 +02:00
parent 3e3b554889
commit eb90cbbd2a
14 changed files with 206 additions and 104 deletions
+1
View File
@@ -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
+10
View File
@@ -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']
+4 -11
View File
@@ -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:
-9
View File
@@ -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
+15
View File
@@ -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'
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+1 -2
View File
@@ -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
-7
View File
@@ -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>
+8
View File
@@ -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>
+2
View File
@@ -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>