# 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)