# This file is part of account_debit_note. # The COPYRIGHT file at the top level of this repository contains # the full copyright notices and license terms. import logging from decimal import Decimal, ROUND_HALF_UP from trytond.exceptions import UserError from trytond.i18n import gettext from trytond.model import ModelView, fields from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval from trytond.wizard import Button, StateAction, StateView, Wizard logger = logging.getLogger(__name__) _DEBIT_MODE = [ ('full', 'Total (100%)'), ('percent', 'Percentage'), ('amount', 'Amount'), ] _DEBIT_AMOUNT_BASIS = [ ('untaxed', 'Without Taxes'), ('total', 'With Taxes'), ] _ZERO = Decimal('0') _ONE = Decimal('1') _ONE_HUNDRED = Decimal('100') def _decimal(value): if value is None: return _ZERO return Decimal(str(value)) def _round_decimal(value, scale): quantum = Decimal('1').scaleb(-scale) return value.quantize(quantum, rounding=ROUND_HALF_UP) def _unit_price_scale(line, default=5): try: digits = line.__class__._fields['unit_price'].digits if isinstance(digits, (tuple, list)) and len(digits) == 2: return int(digits[1]) except Exception: logger.debug( 'Could not resolve unit_price digits for invoice line %s', getattr(line, 'id', None), exc_info=True) return int(default) class Invoice(metaclass=PoolMeta): __name__ = 'account.invoice' @classmethod def _debit_compute_factor(cls, origin, mode, percent, amount, basis): if mode == 'full': return _ONE if mode == 'percent': if percent is None: raise UserError(gettext( 'account_debit_note.msg_missing_debit_percent')) percent = _decimal(percent) if percent <= _ZERO: raise UserError(gettext( 'account_debit_note.msg_invalid_debit_percent')) if percent <= _ONE: return percent if percent <= _ONE_HUNDRED: return percent / _ONE_HUNDRED raise UserError(gettext( 'account_debit_note.msg_invalid_debit_percent')) if mode == 'amount': if amount is None: raise UserError(gettext( 'account_debit_note.msg_missing_debit_amount')) amount = _decimal(amount) if amount <= _ZERO: raise UserError(gettext( 'account_debit_note.msg_invalid_debit_amount')) if basis == 'untaxed': base = abs(_decimal(origin.untaxed_amount)) else: base = abs(_decimal(origin.total_amount)) if base <= _ZERO: raise UserError(gettext( 'account_debit_note.msg_invalid_debit_base')) return amount / base raise UserError(gettext( 'account_debit_note.msg_invalid_debit_mode')) def _debit(self, **values): """ Return a new invoice that increases the amount of this invoice. Tryton core exposes Invoice._credit(), which copies the invoice and reverses line signs. Reusing it keeps the same field-copying behavior as the core credit flow; the debit note then restores normal signs. """ debit = self._credit(**values) self.__class__._debit_force_positive_sign(debit) return debit @classmethod def _debit_force_positive_sign(cls, debit_note): Line = Pool().get('account.invoice.line') touched = [] for line in debit_note.lines: if line.type != 'line': continue quantity = _decimal(line.quantity) unit_price = _decimal(line.unit_price) new_quantity = abs(quantity) new_unit_price = abs(unit_price) if quantity != new_quantity or unit_price != new_unit_price: line.quantity = new_quantity line.unit_price = new_unit_price touched.append(line) if touched and all(getattr(line, 'id', None) for line in touched): Line.save(touched) for tax in getattr(debit_note, 'taxes', None) or []: tax.base = abs(_decimal(tax.base)) tax.amount = abs(_decimal(tax.amount)) @classmethod def _debit_apply_factor(cls, origin, debit_note, factor): if factor == _ONE: return Line = Pool().get('account.invoice.line') lines = [line for line in debit_note.lines if line.type == 'line'] touched = [] for line in lines: quantity = _decimal(line.quantity) unit_price = _decimal(line.unit_price) scale = _unit_price_scale(line) line.quantity = abs(quantity) line.unit_price = _round_decimal(abs(unit_price) * factor, scale) touched.append(line) if touched and all(getattr(line, 'id', None) for line in touched): Line.save(touched) cls._debit_adjust_last_line(origin, touched, factor) @classmethod def _debit_adjust_last_line(cls, origin, lines, factor): if not lines: return Line = Pool().get('account.invoice.line') target_untaxed_abs = abs(_decimal(origin.untaxed_amount)) * factor current_untaxed_abs = sum( abs(_decimal(cls._debit_line_amount(line))) for line in lines) difference = target_untaxed_abs - current_untaxed_abs if abs(difference) < Decimal('0.0001'): return last = lines[-1] quantity = _decimal(last.quantity) if quantity == _ZERO: return unit_price_abs = abs(_decimal(last.unit_price)) unit_price_delta_abs = difference / abs(quantity) new_unit_price_abs = unit_price_abs + unit_price_delta_abs if new_unit_price_abs < _ZERO: new_unit_price_abs = _ZERO last.quantity = abs(quantity) last.unit_price = _round_decimal(new_unit_price_abs, _unit_price_scale(last)) if getattr(last, 'id', None): Line.save([last]) @classmethod def _debit_line_amount(cls, line): return _decimal(line.quantity) * _decimal(line.unit_price) @classmethod def _debit_recompute_taxes(cls, debit_note): if hasattr(debit_note, 'on_change_lines'): debit_note.on_change_lines() elif hasattr(debit_note, '_get_taxes'): InvoiceTax = Pool().get('account.invoice.tax') debit_note.taxes = [ InvoiceTax(**values) for values in debit_note._get_taxes().values() ] @classmethod def _debit_prepare_note_from_origin(cls, origin, debit_note, values): """ Extension hook for localization/fiscal modules. The base account_debit_note module only creates the accounting debit note. Fiscal and electronic fields must be provided by their owning modules. """ return None @classmethod def debit(cls, invoices, **values): debit_mode = values.pop('debit_mode', None) or 'full' debit_percent = values.pop('debit_percent', None) debit_amount = values.pop('debit_amount', None) debit_amount_basis = values.pop('debit_amount_basis', None) or 'total' factors = [ cls._debit_compute_factor( origin, debit_mode, debit_percent, debit_amount, debit_amount_basis) for origin in invoices ] new_invoices = [ origin._debit(invoice_date=values.get('invoice_date')) for origin in invoices ] for origin, debit_note, factor in zip(invoices, new_invoices, factors): cls._debit_apply_factor(origin, debit_note, factor) cls._debit_recompute_taxes(debit_note) cls._debit_prepare_note_from_origin(origin, debit_note, values) cls.save(new_invoices) return new_invoices class DebitInvoiceStart(ModelView): 'Debit Invoice' __name__ = 'account.invoice.debit.start' invoice_date = fields.Date('Invoice Date') debit_mode = fields.Selection( _DEBIT_MODE, 'Debit Type', required=True) debit_percent = fields.Numeric( 'Debit Percentage', digits=(16, 4), states={ 'invisible': Eval('debit_mode') != 'percent', 'required': Eval('debit_mode') == 'percent', }, depends=['debit_mode']) debit_amount = fields.Numeric( 'Debit Amount', digits=(16, 2), states={ 'invisible': Eval('debit_mode') != 'amount', 'required': Eval('debit_mode') == 'amount', }, depends=['debit_mode']) debit_amount_basis = fields.Selection( _DEBIT_AMOUNT_BASIS, 'Amount Basis', states={ 'invisible': Eval('debit_mode') != 'amount', 'required': Eval('debit_mode') == 'amount', }, depends=['debit_mode']) @staticmethod def default_debit_mode(): return 'full' @staticmethod def default_debit_amount_basis(): return 'total' class DebitInvoice(Wizard): 'Debit Invoice' __name__ = 'account.invoice.debit' start = StateView('account.invoice.debit.start', 'account_debit_note.debit_start_view_form', [ Button('Cancel', 'end', 'tryton-cancel'), Button('Debit', 'debit', 'tryton-ok', default=True), ]) debit = StateAction('account_invoice.act_invoice_form') def default_start(self, fields_): default = { 'debit_mode': 'full', 'debit_percent': None, 'debit_amount': None, 'debit_amount_basis': 'total', } return default @property def _debit_options(self): return { 'invoice_date': self.start.invoice_date, 'debit_mode': self.start.debit_mode, 'debit_percent': self.start.debit_percent, 'debit_amount': self.start.debit_amount, 'debit_amount_basis': self.start.debit_amount_basis, } def do_debit(self, action): debit_invoices = self.model.debit( self.records, **self._debit_options) data = {'res_id': [i.id for i in debit_invoices]} if len(debit_invoices) == 1: action['views'].reverse() return action, data