first commit
This commit is contained in:
443
modules/account_fr_chorus/account.py
Normal file
443
modules/account_fr_chorus/account.py
Normal file
@@ -0,0 +1,443 @@
|
||||
# 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
|
||||
@ModelView.button
|
||||
@Workflow.transition('sent')
|
||||
def send(cls, records=None):
|
||||
"""Send invoice to Chorus
|
||||
|
||||
The transaction is committed after each invoice.
|
||||
"""
|
||||
pool = Pool()
|
||||
Credential = pool.get('account.credential.chorus')
|
||||
transaction = Transaction()
|
||||
|
||||
if not records:
|
||||
records = cls.search([
|
||||
('invoice.company', '=',
|
||||
transaction.context.get('company')),
|
||||
('state', '=', 'draft'),
|
||||
])
|
||||
|
||||
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
|
||||
@ModelView.button
|
||||
def update(cls, records=None):
|
||||
"Update state from Chorus"
|
||||
pool = Pool()
|
||||
Credential = pool.get('account.credential.chorus')
|
||||
transaction = Transaction()
|
||||
|
||||
if not records:
|
||||
records = cls.search([
|
||||
('invoice.company', '=',
|
||||
transaction.context.get('company')),
|
||||
('state', '=', 'sent'),
|
||||
])
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user