Files
account_debit_note/invoice.py
T

323 lines
10 KiB
Python

# 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