# 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): """Return *value* as Decimal without binary floating-point artifacts.""" 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 reverses those signs once more to restore the exact signs of the source invoice. """ debit = self._credit(**values) self.__class__._debit_force_positive_sign(debit) return debit @classmethod def _debit_force_positive_sign(cls, debit_note): """Undo the sign reversal performed by Tryton's core ``_credit``. Do not use ``abs`` here: invoices may legitimately contain negative adjustment lines or manual taxes. A debit note must preserve those relative signs rather than turn every component positive. """ touched = [] for line in debit_note.lines: if line.type != 'line': continue quantity = _decimal(line.quantity) new_quantity = -quantity if quantity != new_quantity: line.quantity = new_quantity touched.append(line) if touched and all(getattr(line, 'id', None) for line in touched): Line = Pool().get('account.invoice.line') Line.save(touched) for tax in getattr(debit_note, 'taxes', None) or []: tax.base = -_decimal(tax.base) tax.amount = -_decimal(tax.amount) @classmethod def _debit_apply_factor(cls, origin, debit_note, factor): if factor == _ONE: return lines = [line for line in debit_note.lines if line.type == 'line'] touched = [] for line in lines: unit_price = _decimal(line.unit_price) scale = _unit_price_scale(line) line.unit_price = _round_decimal(unit_price * factor, scale) touched.append(line) if touched and all(getattr(line, 'id', None) for line in touched): Line = Pool().get('account.invoice.line') 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 target_untaxed = _decimal(origin.untaxed_amount) * factor current_untaxed = sum( (_decimal(cls._debit_line_amount(line)) for line in lines), _ZERO) difference = target_untaxed - current_untaxed if abs(difference) < Decimal('0.0001'): return last = lines[-1] quantity = _decimal(last.quantity) if quantity == _ZERO: return new_unit_price = _decimal(last.unit_price) + difference / quantity last.unit_price = _round_decimal( new_unit_price, _unit_price_scale(last)) if getattr(last, 'id', None): Line = Pool().get('account.invoice.line') Line.save([last]) @classmethod def _debit_line_amount(cls, line): on_change_with_amount = getattr(line, 'on_change_with_amount', None) if on_change_with_amount: return on_change_with_amount() 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' if debit_mode == 'amount' and len(invoices) != 1: raise UserError(gettext( 'account_debit_note.msg_amount_multiple_invoices')) 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