Files
tryton/modules/account_fr_chorus/account.py
T
Cédric Krier 7cda9ec0fc Set button decorator for sending and updating invoices on Chorus to a different method than the cron
Closes #15069
(grafted from 59d049637f6755f0678f5acb460525b5be5d72b4)

--HG--
branch : 8.0
2026-09-06 10:21:56 +02:00

453 lines
15 KiB
Python

# 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 base64
import datetime
import logging
import posixpath
from collections import defaultdict
from oauthlib.oauth2 import BackendApplicationClient, TokenExpiredError
from requests_oauthlib import OAuth2Session
from sql.functions import CharLength
import trytond.config as config
from trytond.i18n import gettext
from trytond.model import ModelSQL, ModelView, Unique, Workflow, fields
from trytond.model.exceptions import AccessError
from trytond.modules.company.model import CompanyValueMixin
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, If
from trytond.transaction import Transaction
from .exceptions import ChorusCredentialWarning, InvoiceChorusValidationError
OAUTH_TOKEN_URL = {
'service-qualif': 'https://sandbox-oauth.piste.gouv.fr/api/oauth/token',
'service': 'https://oauth.piste.gouv.fr/api/oauth/token',
}
API_URL = {
'service-qualif': 'https://sandbox-api.piste.gouv.fr',
'service': 'https://api.piste.gouv.fr',
}
EDOC2SYNTAX = {
'edocument.uncefact.invoice': 'IN_DP_E1_CII_16B',
}
EDOC2FILENAME = {
'edocument.uncefact.invoice': 'UNCEFACT-%s.xml',
}
if config.getboolean('account_fr_chorus', 'filestore', default=False):
file_id = 'data_file_id'
store_prefix = config.get(
'account_payment_sepa', 'store_prefix', default=None)
else:
file_id = None
store_prefix = None
logger = logging.getLogger(__name__)
SUCCEEDED = {'IN_INTEGRE', 'IN_RECU', 'IN_TRAITE_SE_CPP'}
FAILED = {
'IN_INCIDENTE', 'QP_IRRECEVABLE', 'QP_RECEVABLE_AVEC_ERREUR', 'IN_REJETE'}
class _SyntaxMixin(object):
__slots__ = ()
@classmethod
def get_syntaxes(cls):
pool = Pool()
syntaxes = [(None, "")]
try:
doc = pool.get('edocument.uncefact.invoice')
except KeyError:
pass
else:
syntaxes.append((doc.__name__, "CII"))
return syntaxes
class Configuration(_SyntaxMixin, metaclass=PoolMeta):
__name__ = 'account.configuration'
_states = {
'required': Bool(Eval('chorus_login')),
}
chorus_piste_client_id = fields.MultiValue(
fields.Char("Piste Client ID", strip=False))
chorus_piste_client_secret = fields.MultiValue(
fields.Char("Piste Client Secret", strip=False, states=_states))
chorus_login = fields.MultiValue(fields.Char("Login", strip=False))
chorus_password = fields.MultiValue(fields.Char(
"Password", strip=False, states=_states))
chorus_service = fields.MultiValue(fields.Selection([
(None, ""),
('service-qualif', "Qualification"),
('service', "Production"),
], "Service", states=_states))
chorus_syntax = fields.Selection(
'get_syntaxes', "Syntax", states=_states)
del _states
@classmethod
def multivalue_model(cls, field):
pool = Pool()
if field in {
'chorus_piste_client_id', 'chorus_piste_client_secret',
'chorus_login', 'chorus_password', 'chorus_service'}:
return pool.get('account.credential.chorus')
return super().multivalue_model(field)
class CredentialChorus(ModelSQL, CompanyValueMixin):
__name__ = 'account.credential.chorus'
chorus_piste_client_id = fields.Char("Piste Client ID", strip=False)
chorus_piste_client_secret = fields.Char(
"Piste Client Secret", strip=False)
chorus_login = fields.Char("Login", strip=False)
chorus_password = fields.Char("Password", strip=False)
chorus_service = fields.Selection([
(None, ""),
('service-qualif', "Qualification"),
('service', "Production"),
], "Service")
@classmethod
def get_session(cls):
pool = Pool()
Configuration = pool.get('account.configuration')
config = Configuration(1)
client = BackendApplicationClient(
client_id=config.chorus_piste_client_id)
session = OAuth2Session(client=client)
cls._get_token(session)
return session
@classmethod
def _get_token(cls, session):
pool = Pool()
Configuration = pool.get('account.configuration')
config = Configuration(1)
return session.fetch_token(
OAUTH_TOKEN_URL[config.chorus_service],
client_id=config.chorus_piste_client_id,
client_secret=config.chorus_piste_client_secret)
@classmethod
def post(cls, path, payload, session=None):
pool = Pool()
Configuration = pool.get('account.configuration')
configuration = Configuration(1)
if not session:
session = cls.get_session()
base_url = API_URL[configuration.chorus_service]
url = posixpath.join(base_url, path)
account = (
f'{configuration.chorus_login}:{configuration.chorus_password}')
headers = {
'cpro-account': base64.b64encode(account.encode('utf-8')),
}
timeout = config.getfloat(
'account_fr_chorus', 'requests_timeout', default=300)
try:
resp = session.post(
url, headers=headers, json=payload,
verify=True, timeout=timeout)
except TokenExpiredError:
cls._get_token(session)
resp = session.post(
url, headers=headers, json=payload,
verify=True, timeout=timeout)
resp.raise_for_status()
return resp.json()
@classmethod
def check_modification(cls, mode, records, values=None, external=False):
pool = Pool()
Warning = pool.get('res.user.warning')
super().check_modification(
mode, records, values=values, external=external)
if mode == 'write' and external:
for record in records:
for field in [
'chorus_piste_client_id', 'chorus_piste_client_secret',
'chorus_login', 'chorus_password', 'chorus_service']:
if (field in values
and getattr(record, field)
and getattr(record, field) != values[field]):
warning_name = Warning.format(
'chorus_credential', [record])
if Warning.check(warning_name):
raise ChorusCredentialWarning(
warning_name,
gettext('account_fr_chorus'
'.msg_chorus_credential_modified'))
class Invoice(metaclass=PoolMeta):
__name__ = 'account.invoice'
@classmethod
def _post(cls, invoices):
pool = Pool()
InvoiceChorus = pool.get('account.invoice.chorus')
posted_invoices = {
i for i in invoices if i.state in {'draft', 'validated'}}
super()._post(invoices)
invoices_chorus = []
for invoice in posted_invoices:
if invoice.type == 'out' and invoice.party.chorus:
invoices_chorus.append(InvoiceChorus(invoice=invoice))
InvoiceChorus.save(invoices_chorus)
class InvoiceChorus(
Workflow, ModelSQL, ModelView, _SyntaxMixin, metaclass=PoolMeta):
__name__ = 'account.invoice.chorus'
_history = True
invoice = fields.Many2One(
'account.invoice', "Invoice", required=True,
domain=[
('type', '=', 'out'),
('state', 'in', If(Bool(Eval('number')),
['posted', 'paid'],
['posted'])),
])
syntax = fields.Selection('get_syntaxes', "Syntax", required=True)
filename = fields.Function(fields.Char("Filename"), 'get_filename')
number = fields.Char(
"Number", readonly=True, strip=False,
states={
'required': Eval('state') == 'sent',
})
date = fields.Date(
"Date", readonly=True,
states={
'required': Eval('state') == 'sent',
})
data = fields.Binary(
"Data", filename='filename',
file_id=file_id, store_prefix=store_prefix, readonly=True)
data_file_id = fields.Char("Data File ID", readonly=True)
state = fields.Selection([
('draft', "Draft"),
('sent', "Sent"),
('done', "Done"),
('exception', "Exception"),
], "State", readonly=True, required=True, sort=False)
@classmethod
def __setup__(cls):
cls.number.search_unaccented = False
super().__setup__()
t = cls.__table__()
cls._sql_constraints = [
('invoice_unique', Unique(t, t.invoice),
'account_fr_chorus.msg_invoice_unique'),
]
cls._transitions |= {
('draft', 'sent'),
('sent', 'done'),
('sent', 'exception'),
('exception', 'sent'),
}
cls._buttons.update(
send={
'invisible': ~Eval('state').in_(['draft', 'exception']),
'depends': ['state'],
},
update={
'invisible': Eval('state') != 'sent',
'depends': ['state'],
},
)
@classmethod
def __register__(cls, module):
cursor = Transaction().connection.cursor()
table = cls.__table__()
table_h = cls.__table_handler__(module)
update_state = not table_h.column_exist('state')
super().__register__(module)
# Migration from 6.8: fill state
if update_state:
cursor.execute(*table.update([table.state], ['done']))
@classmethod
def default_syntax(cls):
pool = Pool()
Configuration = pool.get('account.configuration')
config = Configuration(1)
return config.chorus_syntax
def get_filename(self, name):
filename = EDOC2FILENAME[self.syntax] % self.invoice.number
return filename.replace('/', '-')
@classmethod
def default_state(cls):
return 'draft'
@classmethod
def order_number(cls, tables):
table, _ = tables[None]
return [CharLength(table.number), table.number]
def get_rec_name(self, name):
return self.invoice.rec_name
@classmethod
def validate(cls, records):
super().validate(records)
for record in records:
addresses = [
record.invoice.company.party.address_get('invoice'),
record.invoice.invoice_address]
for address in addresses:
if not address.siret:
raise InvoiceChorusValidationError(
gettext('account_fr_chorus'
'.msg_invoice_address_no_siret',
invoice=record.invoice.rec_name,
address=address.rec_name))
@classmethod
def check_modification(cls, mode, invoices, values=None, external=False):
super().check_modification(
mode, invoices, values=values, external=external)
if mode == 'delete':
for invoice in invoices:
if invoice.number:
raise AccessError(gettext(
'account_fr_chorus.msg_invoice_delete_sent',
invoice=invoice.rec_name))
def _send_context(self):
return {
'company': self.invoice.company.id,
}
@classmethod
def send(cls, records=None):
transaction = Transaction()
if not records:
records = cls.search([
('invoice.company', '=',
transaction.context.get('company')),
('state', '=', 'draft'),
])
cls.send_button(records)
@classmethod
@ModelView.button
@Workflow.transition('sent')
def send_button(cls, records=None):
"""Send invoice to Chorus
The transaction is committed after each invoice.
"""
pool = Pool()
Credential = pool.get('account.credential.chorus')
transaction = Transaction()
sessions = defaultdict(Credential.get_session)
cls.lock(records)
for record in records:
# Use clear cache after a commit
record = cls(record.id)
record.lock()
context = record._send_context()
with transaction.set_context(**context):
payload = record.get_payload()
resp = Credential.post(
'cpro/factures/v1/deposer/flux', payload,
session=sessions[tuple(context.items())])
if resp['codeRetour']:
logger.error(
"Error when sending invoice %d to chorus: %s",
record.id, resp['libelle'])
else:
record.number = resp['numeroFluxDepot']
record.date = datetime.datetime.strptime(
resp['dateDepot'], '%Y-%m-%d').date()
record.state = 'sent'
record.save()
Transaction().commit()
def get_payload(self):
pool = Pool()
Doc = pool.get(self.syntax)
with Transaction().set_context(account_fr_chorus=True):
self.data = Doc(self.invoice).render(None)
return {
'fichierFlux': base64.b64encode(self.data).decode('ascii'),
'nomFichier': self.filename,
'syntaxeFlux': EDOC2SYNTAX[self.syntax],
'avecSignature': False,
}
@classmethod
def update(cls, records=None):
transaction = Transaction()
if not records:
records = cls.search([
('invoice.company', '=',
transaction.context.get('company')),
('state', '=', 'sent'),
])
cls.update_button(records)
@classmethod
@ModelView.button
def update_button(cls, records=None):
"Update state from Chorus"
pool = Pool()
Credential = pool.get('account.credential.chorus')
transaction = Transaction()
sessions = defaultdict(Credential.get_session)
succeeded, failed = [], []
for record in records:
if not record.number:
continue
context = record._send_context()
with transaction.set_context(**context):
payload = {
'numeroFluxDepot': record.number,
}
resp = Credential.post(
'cpro/transverses/v1/consulterCR', payload,
session=sessions[tuple(context.items())])
if resp['codeRetour']:
logger.info(
"Error when retrieve information about %d: %s",
record.id, resp['libelle'])
elif resp['etatCourantFlux'] in SUCCEEDED:
succeeded.append(record)
elif resp['etatCourantFlux'] in FAILED:
failed.append(record)
if failed:
cls.fail(failed)
if succeeded:
cls.succeed(succeeded)
@classmethod
@Workflow.transition('done')
def succeed(cls, records):
pass
@classmethod
@Workflow.transition('exception')
def fail(cls, records):
pass