323 lines
10 KiB
Python
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
|