Files
tryton/modules/edocument_peppol/edocument.py
T

365 lines
12 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.
from functools import wraps
from itertools import groupby
from sql.conditionals import NullIf
from sql.operators import Equal
import trytond.config as config
from trytond.model import (
Exclude, MatchMixin, ModelSQL, ModelView, Workflow, dualmethod, fields,
sequence_ordered)
from trytond.pool import Pool
from trytond.pyson import Eval, If, TimeDelta
from trytond.transaction import Transaction
from .exceptions import PeppolServiceError
if config.getboolean('edocument_peppol', 'filestore', default=True):
file_id = 'file_id'
store_prefix = config.get(
'edocument_peppol', 'store_prefix', default=None)
else:
file_id = store_prefix = None
def default_records(func):
@wraps(func)
def wrapper(cls, documents=None):
if documents is None:
documents = cls.search([
('direction', '=', 'out'),
('state', '=', 'processing'),
])
return func(cls, documents)
return wrapper
class Peppol(Workflow, ModelSQL, ModelView):
__name__ = 'edocument.peppol'
_states = {
'readonly': Eval('state') != 'draft',
}
direction = fields.Selection([
('in', "IN"),
('out', "OUT"),
], "Direction", required=True, states=_states)
company = fields.Many2One(
'company.company', "Company", required=True, states=_states)
type = fields.Selection([
(None, ""),
('bis-billing-3', "BIS Billing V3"),
], "Type", translate=False,
states={
'readonly': _states['readonly'],
'required': Eval('state') != 'draft',
})
service = fields.Many2One(
'edocument.peppol.service', "Service",
domain=[
('company', '=', Eval('company', -1)),
If(Eval('state') == 'draft',
('types', 'in', Eval('type')),
()),
],
states={
'readonly': _states['readonly'],
'required': Eval('state') != 'draft',
})
invoice = fields.Many2One(
'account.invoice', "Invoice", ondelete='RESTRICT',
domain=[
('company', '=', Eval('company', -1)),
If(Eval('direction') == 'out', [
('type', '=', 'out'),
('state', 'in', ['posted', 'paid']),
], [
('type', '=', 'in'),
]),
],
states={
'readonly': _states['readonly'],
'invisible': (
~Eval('type').in_([
'bis-billing-3',
])
| ((Eval('state') == 'draft')
& (Eval('direction') == 'in'))),
'required': (
(Eval('direction') == 'out')
& Eval('type').in_([
'bis-billing-3',
])),
})
data = fields.Binary(
"Data",
file_id=file_id, store_prefix=store_prefix,
states={
'readonly': _states['readonly'],
'invisible': (
(Eval('state') == 'draft')
& (Eval('direction') == 'out')),
})
file_id = fields.Char("File ID", readonly=False)
transmission_id = fields.Char("Transmission ID", readonly=True)
document_retried = fields.Many2One(
'edocument.peppol', "Retry", readonly=True,
states={
'invisible': ~Eval('document_retried'),
'required': Eval('state') == 'retried',
})
status = fields.Char(
"Status", readonly=True,
states={
'invisible': ~Eval('status'),
})
state = fields.Selection([
('draft', "Draft"),
('submitted', "Submitted"),
('processing', "Processing"),
('succeeded', "Succeeded"),
('failed', "Failed"),
('retried', "Retried"),
('cancelled', "Cancelled"),
], "State", readonly=True, required=True, sort=False)
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_constraints += [
('service_transmission_id_unique',
Exclude(t,
(t.service, Equal),
(NullIf(t.transmission_id, ''), Equal)),
'edocument_peppol.msg_service_transmission_id_unique'),
]
cls._transitions |= {
('draft', 'submitted'),
('submitted', 'processing'),
('processing', 'processing'),
('processing', 'succeeded'),
('processing', 'failed'),
('failed', 'retried'),
('failed', 'cancelled'),
}
cls._buttons.update(
draft={
'invisible': Eval('state') != 'submitted',
'depends': ['state'],
},
submit={
'invisible': Eval('state') != 'draft',
'depends': ['state'],
},
process={
'invisible': ~Eval('state').in_(['submitted', 'processing']),
'depends': ['state'],
},
update_status={
'invisible': (
(Eval('state') != 'processing')
| (Eval('direction') != 'out')),
'depends': ['state', 'direction'],
},
retry={
'invisible': Eval('state') != 'failed',
'depends': ['state'],
},
cancel={
'invisible': Eval('state') != 'failed',
'depends': ['state'],
})
@classmethod
def default_company(cls):
return Transaction().context.get('company')
@classmethod
def default_state(cls):
return 'draft'
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, documents):
pass
@dualmethod
@ModelView.button
@Workflow.transition('submitted')
def submit(cls, documents):
pool = Pool()
Service = pool.get('edocument.peppol.service')
transaction = Transaction()
for document in documents:
if not document.service:
document.service = Service.get_service(document)
cls.save(documents)
for process_after, sub_documents in groupby(
documents, lambda d: d.service.process_after):
with transaction.set_context(
queue_scheduled_at=process_after):
cls.__queue__.process(documents)
def get_service_pattern(self):
return {
'type': self.type,
}
def render(self):
pool = Pool()
Invoice = pool.get('edocument.ubl.invoice')
assert self.direction == 'out'
if self.type == 'bis-billing-3':
return Invoice(self.invoice).render(
'2', specification='peppol-bis-3')
@dualmethod
@ModelView.button
@Workflow.transition('processing')
def process(cls, documents):
cls.lock(documents)
# write state before calling _process
cls.write(documents, {'state': 'processing'})
for document in documents:
if document.direction == 'out':
document.data = document.render()
cls.__queue__._process(document)
else:
document._process()
cls.save(documents)
def _process(self):
pool = Pool()
Invoice = pool.get('edocument.ubl.invoice')
if self.state != 'processing':
return
self.lock()
if self.direction == 'out':
if self.transmission_id:
return
try:
self.transmission_id = self.service.post(self)
self.save()
except PeppolServiceError as e:
self.fail(status=str(e))
elif self.direction == 'in':
if self.type == 'bis-billing-3':
if self.invoice:
return
self.invoice = Invoice.parse(self.data)
self.save()
self.succeed()
@classmethod
@default_records
@ModelView.button
def update_status(cls, documents):
for document in documents:
document._update_status()
cls.save(documents)
def _update_status(self):
assert self.direction == 'out'
self.service.update_status(self)
@dualmethod
@Workflow.transition('succeeded')
def succeed(cls, documents, status=None):
cls.write(documents, {'status': status})
@dualmethod
@Workflow.transition('failed')
def fail(cls, documents, status=None):
cls.write(documents, {'status': status})
@classmethod
@Workflow.transition('retried')
def retry(cls, documents):
retries = cls.copy(documents)
for document, retry in zip(documents, retries):
document.document_retried = retry
cls.save(documents)
cls.submit(retries)
@classmethod
@Workflow.transition('cancelled')
def cancel(cls, documents):
pass
@classmethod
def copy(cls, documents, default=None):
default = default.copy() if default is not None else {}
default.setdefault('service')
default.setdefault('data')
default.setdefault('transmission_id')
default.setdefault('document_retried')
default.setdefault('status')
return super().copy(documents, default=default)
class PeppolService(sequence_ordered(), MatchMixin, ModelSQL, ModelView):
__name__ = 'edocument.peppol.service'
company = fields.Many2One(
'company.company', "Company", required=True)
service = fields.Selection([
], "Service")
types = fields.MultiSelection(
'get_peppol_types', "Types",
help="The types of document supported by the service provider.")
process_after = fields.TimeDelta(
"Process Document after",
domain=['OR',
('process_after', '=', None),
('process_after', '>=', TimeDelta()),
],
help="The time taken to process a document "
"after it has been submitted.\n"
"Applied if a worker queue is activated.")
@classmethod
def default_company(cls):
return Transaction().context.get('company')
@classmethod
def get_service(cls, document):
pattern = document.get_service_pattern()
for service in cls.search([('company', '=', document.company)]):
if service.match(pattern):
return service
def match(self, pattern, match_none=False):
if 'type' in pattern:
pattern = pattern.copy()
if pattern.pop('type') not in self.types:
return False
return super().match(pattern, match_none=match_none)
@classmethod
def get_peppol_types(cls):
pool = Pool()
Peppol = pool.get('edocument.peppol')
return [
(v, l) for v, l in Peppol.fields_get(['type'])['type']['selection']
if v is not None]
@classmethod
def default_types(cls):
return ['bis-billing-3']
def post(self, document):
if meth := getattr(self, f'_post_{self.service}', None):
return meth(document)
def update_status(self, document):
if meth := getattr(self, f'_update_status_{self.service}', None):
return meth(document)