# 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 collections import defaultdict from decimal import Decimal from sql.functions import CharLength from trytond.i18n import gettext from trytond.model import ( DeactivableMixin, Index, ModelSQL, ModelView, Workflow, fields) from trytond.model.exceptions import AccessError from trytond.modules.company.model import ( employee_field, reset_employee, set_employee) from trytond.modules.currency.fields import Monetary from trytond.modules.product import price_digits from trytond.pool import Pool from trytond.pyson import Bool, Eval, If from trytond.transaction import Transaction class Type(DeactivableMixin, ModelSQL, ModelView): 'Customer Complaint Type' __name__ = 'sale.complaint.type' name = fields.Char('Name', required=True) origin = fields.Many2One('ir.model', 'Origin', required=True, domain=[('model', 'in', ['sale.sale', 'sale.line', 'account.invoice', 'account.invoice.line'])]) class Complaint(Workflow, ModelSQL, ModelView): 'Customer Complaint' __name__ = 'sale.complaint' _rec_name = 'number' _states = { 'readonly': Eval('state') != 'draft', } number = fields.Char("Number", readonly=True) reference = fields.Char("Reference") date = fields.Date('Date', states=_states) customer = fields.Many2One( 'party.party', "Customer", required=True, states=_states, context={ 'company': Eval('company', -1), }, depends={'company'}) company = fields.Many2One( 'company.company', 'Company', required=True, states={ 'readonly': _states['readonly'] | Eval('origin'), }) type = fields.Many2One('sale.complaint.type', 'Type', required=True, states=_states) origin = fields.Reference('Origin', selection='get_origin', domain={ 'sale.sale': [ If(Eval('customer'), ('party', '=', Eval('customer')), ()), ('company', '=', Eval('company')), ('state', 'in', ['confirmed', 'processing', 'done']), ], 'sale.line': [ ('type', '=', 'line'), If(Eval('customer'), ('sale.party', '=', Eval('customer')), ()), ('sale.company', '=', Eval('company')), ('sale.state', 'in', ['confirmed', 'processing', 'done']), ], 'account.invoice': [ If(Eval('customer'), ('party', '=', Eval('customer')), ()), ('company', '=', Eval('company')), ('type', '=', 'out'), ('state', 'in', ['posted', 'paid']), ], 'account.invoice.line': [ ('type', '=', 'line'), If(Eval('customer'), ('invoice.party', '=', Eval('customer')), ()), ('invoice.company', '=', Eval('company')), ('invoice.type', '=', 'out'), ('invoice.state', 'in', ['posted', 'paid']), ], }, states={ 'readonly': ((Eval('state') != 'draft') | Bool(Eval('actions', [0]))), 'required': Bool(Eval('origin_model')), }, depends={'origin_model'}) origin_id = fields.Function(fields.Integer('Origin ID'), 'on_change_with_origin_id') origin_model = fields.Function(fields.Char('Origin Model'), 'on_change_with_origin_model') description = fields.Text('Description', states=_states) actions = fields.One2Many('sale.complaint.action', 'complaint', 'Actions', states={ 'readonly': ((Eval('state') != 'draft') | (If(~Eval('origin_id', 0), 0, Eval('origin_id', 0)) <= 0)), }, depends={'origin_model'}) submitted_by = employee_field( "Submitted By", states=['waiting', 'approved', 'rejected', 'done', 'cancelled']) approved_by = employee_field( "Approved By", states=['approved', 'rejected', 'done', 'cancelled']) rejected_by = employee_field( "Rejected By", states=['approved', 'rejected', 'done', 'cancelled']) cancelled_by = employee_field( "Cancelled By", states=['cancelled']) state = fields.Selection([ ('draft', 'Draft'), ('waiting', 'Waiting'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('done', 'Done'), ('cancelled', 'Cancelled'), ], "State", readonly=True, required=True, sort=False) @classmethod def __setup__(cls): cls.number.search_unaccented = False cls.reference.search_unaccented = False super(Complaint, cls).__setup__() t = cls.__table__() cls._sql_indexes.update({ Index(t, (t.reference, Index.Similarity())), Index( t, (t.state, Index.Equality()), where=t.state.in_(['draft', 'waiting', 'approved'])), }) cls._order.insert(0, ('date', 'DESC')) cls._transitions |= set(( ('draft', 'waiting'), ('waiting', 'draft'), ('waiting', 'approved'), ('waiting', 'rejected'), ('approved', 'done'), ('approved', 'draft'), ('draft', 'cancelled'), ('waiting', 'cancelled'), ('done', 'draft'), ('rejected', 'draft'), ('cancelled', 'draft'), )) cls._buttons.update({ 'cancel': { 'invisible': ~Eval('state').in_(['draft', 'waiting']), 'depends': ['state'], }, 'draft': { 'invisible': ~Eval('state').in_( ['waiting', 'done', 'cancelled']), 'icon': If(Eval('state').in_(['done', 'cancelled']), 'tryton-undo', 'tryton-back'), 'depends': ['state'], }, 'wait': { 'invisible': ~Eval('state').in_(['draft']), 'depends': ['state'], }, 'approve': { 'invisible': ~Eval('state').in_(['waiting']), 'depends': ['state'], }, 'reject': { 'invisible': ~Eval('state').in_(['waiting']), 'depends': ['state'], }, 'process': { 'invisible': ~Eval('state').in_(['approved']), 'depends': ['state'], }, }) actions_domains = cls._actions_domains() actions_domain = [('action', 'in', actions_domains.pop(None))] for model, actions in actions_domains.items(): actions_domain = If(Eval('origin_model') == model, [('action', 'in', actions)], actions_domain) cls.actions.domain = [actions_domain] @classmethod def __register__(cls, module_name): table_h = cls.__table_handler__(module_name) # Migration from 6.4: rename employee into submitted_by if (table_h.column_exist('employee') and not table_h.column_exist('submitted_by')): table_h.column_rename('employee', 'submitted_by') super(Complaint, cls).__register__(module_name) @classmethod def _actions_domains(cls): return { None: [], 'sale.sale': ['sale_return'], 'sale.line': ['sale_return'], 'account.invoice': ['credit_note'], 'account.invoice.line': ['credit_note'], } @classmethod def order_number(cls, tables): table, _ = tables[None] return [CharLength(table.number), table.number] @staticmethod def default_date(): pool = Pool() Date = pool.get('ir.date') return Date.today() @staticmethod def default_company(): return Transaction().context.get('company') @staticmethod def default_state(): return 'draft' @fields.depends('type') def get_origin(self): if self.type: origin = self.type.origin return [('', ''), (origin.model, origin.name)] else: return [] @fields.depends('origin', 'customer') def on_change_origin(self): pool = Pool() Sale = pool.get('sale.sale') SaleLine = pool.get('sale.line') Invoice = pool.get('account.invoice') InvoiceLine = pool.get('account.invoice.line') if not self.customer and self.origin and self.origin.id >= 0: if isinstance(self.origin, Sale): self.customer = self.origin.party elif isinstance(self.origin, SaleLine): self.customer = self.origin.sale.party elif isinstance(self.origin, Invoice): self.customer = self.origin.party elif isinstance(self.origin, InvoiceLine) and self.origin.invoice: self.customer = self.origin.invoice.party @fields.depends('origin') def on_change_with_origin_id(self, name=None): if self.origin: return self.origin.id @fields.depends('origin') def on_change_with_origin_model(self, name=None): if self.origin: return self.origin.__class__.__name__ @classmethod def view_attributes(cls): return super().view_attributes() + [ ('/tree', 'visual', If(Eval('state') == 'cancelled', 'muted', '')), ] @classmethod def create(cls, vlist): pool = Pool() Configuration = pool.get('sale.configuration') config = Configuration(1) vlist = [v.copy() for v in vlist] default_company = cls.default_company() for values in vlist: if values.get('number') is None: values['number'] = config.get_multivalue( 'complaint_sequence', company=values.get('company', default_company)).get() return super(Complaint, cls).create(vlist) @classmethod def copy(cls, complaints, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('number', None) default.setdefault('submitted_by') default.setdefault('approved_by') default.setdefault('rejected_by') default.setdefault('cancelled_by') return super(Complaint, cls).copy(complaints, default=default) @classmethod def delete(cls, complaints): for complaint in complaints: if complaint.state != 'draft': raise AccessError( gettext('sale_complaint.msg_complaint_delete_draft', complaint=complaint.rec_name)) super(Complaint, cls).delete(complaints) @classmethod @ModelView.button @Workflow.transition('cancelled') @set_employee('cancelled_by') def cancel(cls, complaints): pass @classmethod @ModelView.button @Workflow.transition('draft') @reset_employee( 'submitted_by', 'approved_by', 'rejected_by', 'cancelled_by') def draft(cls, complaints): pass @classmethod @ModelView.button @Workflow.transition('waiting') @set_employee('submitted_by') def wait(cls, complaints): pass @classmethod @ModelView.button @Workflow.transition('approved') @set_employee('approved_by') def approve(cls, complaints): pool = Pool() Configuration = pool.get('sale.configuration') transaction = Transaction() context = transaction.context config = Configuration(1) with transaction.set_context( queue_scheduled_at=config.sale_process_after, queue_batch=context.get('queue_batch', True)): cls.__queue__.process(complaints) @classmethod @ModelView.button @Workflow.transition('rejected') @set_employee('rejected_by') def reject(cls, complaints): pass @classmethod @ModelView.button @Workflow.transition('done') def process(cls, complaints): pool = Pool() Action = pool.get('sale.complaint.action') results = defaultdict(list) actions = defaultdict(list) for complaint in complaints: for action in complaint.actions: if action.result: continue result = action.do() results[result.__class__].append(result) actions[result.__class__].append(action) for kls, records in results.items(): kls.save(records) for action, record in zip(actions[kls], records): action.result = record Action.save(sum(list(actions.values()), [])) class Action(ModelSQL, ModelView): 'Customer Complaint Action' __name__ = 'sale.complaint.action' _states = { 'readonly': ((Eval('complaint_state') != 'draft') | Bool(Eval('result'))), } _line_states = { 'invisible': ~Eval('_parent_complaint', {} ).get('origin_model', 'sale.line').in_( ['sale.line', 'account.invoice.line']), 'readonly': _states['readonly'], } complaint = fields.Many2One( 'sale.complaint', 'Complaint', required=True, ondelete='CASCADE', states=_states) action = fields.Selection([ ('sale_return', 'Create Sale Return'), ('credit_note', 'Create Credit Note'), ], 'Action', states=_states) sale_lines = fields.One2Many( 'sale.complaint.action-sale.line', 'action', "Sale Lines", states={ 'invisible': Eval('_parent_complaint', {} ).get('origin_model', 'sale.sale') != 'sale.sale', 'readonly': _states['readonly'], }, help='Leave empty for all lines.') invoice_lines = fields.One2Many( 'sale.complaint.action-account.invoice.line', 'action', "Invoice Lines", states={ 'invisible': Eval('_parent_complaint', {} ).get('origin_model', 'account.invoice.line' ) != 'account.invoice', 'readonly': _states['readonly'], }, help='Leave empty for all lines.') quantity = fields.Float( "Quantity", digits='unit', states=_line_states, help='Leave empty for the same quantity.') unit = fields.Function(fields.Many2One('product.uom', 'Unit', states=_line_states), 'on_change_with_unit') unit_price = Monetary( "Unit Price", currency='currency', digits=price_digits, states=_line_states, help='Leave empty for the same price.') amount = fields.Function(Monetary( "Amount", 'currency', digits='currency'), 'on_change_with_amount') currency = fields.Function(fields.Many2One( 'currency.currency', "Currency"), 'on_change_with_currency') result = fields.Reference('Result', selection='get_result', readonly=True) complaint_state = fields.Function( fields.Selection('get_complaint_states', "Complaint State"), 'on_change_with_complaint_state') @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('complaint') @fields.depends('complaint', '_parent_complaint.origin_model', '_parent_complaint.origin') def on_change_with_unit(self, name=None): if (self.complaint and self.complaint.origin_model in { 'sale.line', 'account.invoice.line'}): return self.complaint.origin.unit @fields.depends( 'quantity', 'unit_price', 'currency', 'sale_lines', 'invoice_lines', 'complaint', '_parent_complaint.origin_model', '_parent_complaint.origin') def on_change_with_amount(self, name=None): if self.complaint: if self.complaint.origin_model in { 'sale.line', 'account.invoice.line'}: if self.quantity is not None: quantity = self.quantity else: quantity = self.complaint.origin.quantity if self.unit_price is not None: unit_price = self.unit_price else: unit_price = self.complaint.origin.unit_price amount = Decimal(str(quantity)) * unit_price if self.currency: amount = self.currency.round(amount) return amount elif self.complaint.origin_model == 'sale.sale': if not self.sale_lines: if self.complaint and self.complaint.origin: return self.complaint.origin.untaxed_amount else: return sum( getattr(l, 'amount', None) or Decimal(0) for l in self.sale_lines) elif self.complaint.origin_model == 'account.invoice': if not self.invoice_lines: if self.complaint and self.complaint.origin: return self.complaint.origin.untaxed_amount else: return sum( getattr(l, 'amount', None) or Decimal(0) for l in self.invoice_lines) @fields.depends( 'complaint', '_parent_complaint.origin_model', '_parent_complaint.origin') def on_change_with_currency(self, name=None): if (self.complaint and self.complaint.origin_model in { 'sale.sale', 'sale.line', 'account.invoice', 'account.invoice.line'}): return self.complaint.origin.currency @classmethod def get_complaint_states(cls): pool = Pool() Complaint = pool.get('sale.complaint') return Complaint.fields_get(['state'])['state']['selection'] @fields.depends('complaint', '_parent_complaint.state') def on_change_with_complaint_state(self, name=None): if self.complaint: return self.complaint.state @classmethod def _get_result(cls): 'Return list of Model names for result Reference' return ['sale.sale', 'account.invoice'] @classmethod def get_result(cls): pool = Pool() Model = pool.get('ir.model') get_name = Model.get_name models = cls._get_result() return [(None, '')] + [(m, get_name(m)) for m in models] @classmethod def copy(cls, actions, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('result', None) return super().copy(actions, default=default) def do(self): return getattr(self, 'do_%s' % self.action)() def do_sale_return(self): pool = Pool() Sale = pool.get('sale.sale') Line = pool.get('sale.line') if isinstance(self.complaint.origin, (Sale, Line)): default = {} if isinstance(self.complaint.origin, Sale): sale = self.complaint.origin if self.sale_lines: sale_lines = [l.line for l in self.sale_lines] line2qty = { l.line.id: l.get_quantity() for l in self.sale_lines} line2price = { l.line.id: l.get_unit_price() for l in self.sale_lines} default['quantity'] = lambda o: line2qty.get(o['id']) default['unit_price'] = lambda o: line2price.get(o['id']) else: sale_lines = [l for l in sale.lines if l.type == 'line'] elif isinstance(self.complaint.origin, Line): sale_line = self.complaint.origin sale = sale_line.sale sale_lines = [sale_line] if self.quantity is not None: default['quantity'] = self.quantity if self.unit_price is not None: default['unit_price'] = self.unit_price return_sale, = Sale.copy([sale], default={'lines': None}) default['sale'] = return_sale.id Line.copy(sale_lines, default=default) else: return return_sale.origin = self.complaint for line in return_sale.lines: if line.type == 'line': line.quantity *= -1 return_sale.lines = return_sale.lines # Force saving return return_sale def do_credit_note(self): pool = Pool() Invoice = pool.get('account.invoice') Line = pool.get('account.invoice.line') if isinstance(self.complaint.origin, (Invoice, Line)): line2qty = line2price = {} if isinstance(self.complaint.origin, Invoice): invoice = self.complaint.origin if self.invoice_lines: invoice_lines = [l.line for l in self.invoice_lines] line2qty = {l.line: l.quantity for l in self.invoice_lines if l.quantity is not None} line2price = {l.line: l.unit_price for l in self.invoice_lines if l.unit_price is not None} else: invoice_lines = [ l for l in invoice.lines if l.type == 'line'] elif isinstance(self.complaint.origin, Line): invoice_line = self.complaint.origin invoice = invoice_line.invoice invoice_lines = [invoice_line] if self.quantity is not None: line2qty = {invoice_line: self.quantity} if self.unit_price is not None: line2price = {invoice_line: self.unit_price} with Transaction().set_context(_account_invoice_correction=True): credit_note, = Invoice.copy([invoice], default={ 'lines': [], 'taxes': [], }) # Copy each line one by one to get negative and positive lines # following each other for invoice_line in invoice_lines: qty = line2qty.get(invoice_line, invoice_line.quantity) unit_price = invoice_line.unit_price - line2price.get( invoice_line, invoice_line.unit_price) Line.copy([invoice_line], default={ 'invoice': credit_note.id, 'quantity': -qty, 'origin': str(self.complaint), }) credit_line, = Line.copy([invoice_line], default={ 'invoice': credit_note.id, 'quantity': qty, 'unit_price': unit_price, 'origin': str(self.complaint), }) credit_note.update_taxes() else: return return credit_note @classmethod def delete(cls, actions): for action in actions: if action.result: raise AccessError( gettext('sale_complaint.msg_action_delete_result', action=action.rec_name)) super(Action, cls).delete(actions) class _Action_Line: __slots__ = () _states = { 'readonly': ( (Eval('complaint_state') != 'draft') | Bool(Eval('_parent_action.result', True))), } action = fields.Many2One( 'sale.complaint.action', "Action", ondelete='CASCADE', required=True) quantity = fields.Float( "Quantity", digits='unit', states=_states) unit = fields.Function( fields.Many2One('product.uom', "Unit"), 'on_change_with_unit') unit_price = Monetary( "Unit Price", currency='currency', digits=price_digits, states=_states, help='Leave empty for the same price.') amount = fields.Function(Monetary( "Amount", currency='currency', digits='currency'), 'on_change_with_amount') currency = fields.Function(fields.Many2One( 'currency.currency', "Currency"), 'on_change_with_currency') complaint_state = fields.Function( fields.Selection('get_complaint_states', "Complaint State"), 'on_change_with_complaint_state') complaint_origin_id = fields.Function( fields.Integer("Complaint Origin ID"), 'on_change_with_complaint_origin_id') @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('action') def on_change_with_unit(self, name=None): raise NotImplementedError @fields.depends('currency', methods=['get_quantity', 'get_unit_price']) def on_change_with_amount(self, name=None): quantity = self.get_quantity() or 0 unit_price = self.get_unit_price() or Decimal(0) amount = Decimal(str(quantity)) * unit_price if self.currency: amount = self.currency.round(amount) return amount def get_quantity(self): raise NotImplementedError def get_unit_price(self): raise NotImplementedError @fields.depends('action', '_parent_action.currency') def on_change_with_currency(self, name=None): return self.action.currency if self.action else None @classmethod def get_complaint_states(cls): pool = Pool() Complaint = pool.get('sale.complaint') return Complaint.fields_get(['state'])['state']['selection'] @fields.depends('action', '_parent_action.complaint', '_parent_action._parent_complaint.state') def on_change_with_complaint_state(self, name=None): if self.action and self.action.complaint: return self.action.complaint.state @fields.depends('action', '_parent_action.complaint', '_parent_action._parent_complaint.origin_id') def on_change_with_complaint_origin_id(self, name=None): if self.action and self.action.complaint: return self.action.complaint.origin_id class Action_SaleLine(_Action_Line, ModelView, ModelSQL): 'Customer Complaint Action - Sale Line' __name__ = 'sale.complaint.action-sale.line' line = fields.Many2One( 'sale.line', "Sale Line", ondelete='RESTRICT', required=True, domain=[ ('type', '=', 'line'), ('sale', '=', Eval('complaint_origin_id', -1)), ]) @fields.depends('line') def on_change_with_unit(self, name=None): return self.line.unit if self.line else None @fields.depends('quantity', 'line') def get_quantity(self): if self.quantity is not None: return self.quantity elif self.line: return self.line.quantity @fields.depends('unit_price', 'line') def get_unit_price(self): if self.unit_price is not None: return self.unit_price elif self.line: return self.line.unit_price class Action_InvoiceLine(_Action_Line, ModelView, ModelSQL): 'Customer Complaint Action - Invoice Line' __name__ = 'sale.complaint.action-account.invoice.line' line = fields.Many2One( 'account.invoice.line', 'Invoice Line', ondelete='RESTRICT', required=True, domain=[ ('type', '=', 'line'), ('invoice', '=', Eval('complaint_origin_id', -1)), ]) @fields.depends('line') def on_change_with_unit(self, name=None): return self.line.unit if self.line else None @fields.depends('quantity', 'line') def get_quantity(self): if self.quantity is not None: return self.quantity elif self.line: return self.line.quantity @fields.depends('unit_price', 'line') def get_unit_price(self): if self.unit_price is not None: return self.unit_price elif self.line: return self.line.unit_price