241 lines
11 KiB
Python
241 lines
11 KiB
Python
from odoo import api, fields, models, _, Command
|
|
from datetime import timedelta
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.tools import float_round
|
|
from odoo.tools.misc import clean_context
|
|
|
|
class HrExpense(models.Model):
|
|
_inherit = 'hr.expense'
|
|
|
|
state = fields.Selection(selection_add=[
|
|
('wait_post', 'Wait Post')
|
|
], ondelete={'wait_post': 'set default'})
|
|
|
|
@api.depends('product_id', 'company_id', 'payment_mode')
|
|
def _compute_account_id(self):
|
|
super()._compute_account_id()
|
|
for expense in self:
|
|
if not expense.product_id:
|
|
continue
|
|
|
|
# Use specific accounts based on payment mode if configured
|
|
product = expense.product_id.with_company(expense.company_id)
|
|
if expense.payment_mode == 'own_account':
|
|
if product.property_account_expense_employee_id:
|
|
expense.account_id = product.property_account_expense_employee_id
|
|
elif expense.payment_mode == 'company_account':
|
|
if product.property_account_expense_company_id:
|
|
expense.account_id = product.property_account_expense_company_id
|
|
|
|
sequence_name = fields.Char(string='Sequence', readonly=True, copy=False, default=lambda self: _('New'))
|
|
realization_total_amount = fields.Monetary(
|
|
string='Realization Total',
|
|
compute='_compute_realization_total_amount',
|
|
store=True,
|
|
currency_field='currency_id',
|
|
help="Total amount from all physical receipts realized for this expense."
|
|
)
|
|
|
|
receipt_due_date = fields.Date(
|
|
string="Receipt Due Date",
|
|
readonly=True,
|
|
help="Date the employee must submit the receipt."
|
|
)
|
|
receipt_received = fields.Boolean(
|
|
string="Receipt Received",
|
|
default=False,
|
|
tracking=True,
|
|
help="Mark if original receipt has been received."
|
|
)
|
|
receipt_overdue = fields.Boolean(
|
|
string="Receipt Overdue",
|
|
compute='_compute_receipt_overdue',
|
|
store=True,
|
|
help="True if receipt is not received and past due date."
|
|
)
|
|
|
|
realization_ids = fields.One2many('hr.expense.realization', 'expense_id', string='Realizations')
|
|
realization_count = fields.Integer(string='Realization Count', compute='_compute_realization_count')
|
|
|
|
receipt_status = fields.Selection([
|
|
('pending', 'Pending Receipts'),
|
|
('received', 'Receipts Received'),
|
|
('none', 'No Receipt Required')
|
|
], string='Receipt Status', compute='_compute_receipt_status', store=True, tracking=True)
|
|
|
|
amount_paid = fields.Monetary(
|
|
string='Payment Total',
|
|
compute='_compute_amount_paid',
|
|
currency_field='currency_id',
|
|
store=True,
|
|
help="Total amount paid by the finance team."
|
|
)
|
|
|
|
@api.depends('realization_ids')
|
|
def _compute_realization_count(self):
|
|
for expense in self:
|
|
expense.realization_count = len(expense.realization_ids)
|
|
|
|
@api.depends('realization_ids.total_amount')
|
|
def _compute_realization_total_amount(self):
|
|
for expense in self:
|
|
expense.realization_total_amount = sum(expense.realization_ids.mapped('total_amount'))
|
|
|
|
@api.depends('receipt_received', 'payment_mode')
|
|
def _compute_receipt_status(self):
|
|
for expense in self:
|
|
if expense.payment_mode != 'company_account':
|
|
expense.receipt_status = 'none'
|
|
elif expense.receipt_received:
|
|
expense.receipt_status = 'received'
|
|
else:
|
|
expense.receipt_status = 'pending'
|
|
|
|
@api.depends('account_move_id.payment_state', 'total_amount')
|
|
def _compute_amount_paid(self):
|
|
for expense in self:
|
|
total_paid = 0.0
|
|
if expense.account_move_id:
|
|
move = expense.account_move_id
|
|
if move.payment_state in ('paid', 'in_payment'):
|
|
total_paid = expense.total_amount
|
|
expense.amount_paid = total_paid
|
|
|
|
@api.depends('account_move_id.state', 'receipt_received', 'realization_ids.state', 'approval_state')
|
|
def _compute_state(self):
|
|
# Store original states to detect transition to 'paid'
|
|
original_states = {expense.id: expense.state for expense in self}
|
|
|
|
super()._compute_state()
|
|
|
|
for expense in self:
|
|
# Check for Company Account expenses
|
|
if expense.payment_mode == 'company_account':
|
|
# If Odoo thought it was 'paid' (fully or partially paid/in_payment),
|
|
# we may need to hold it at 'wait_post' until realization is complete.
|
|
if expense.state == 'paid':
|
|
realizations = expense.realization_ids
|
|
has_posted_realization = realizations and all(r.state == 'posted' for r in realizations)
|
|
|
|
if expense.receipt_status != 'received' or not has_posted_realization:
|
|
expense.state = 'wait_post'
|
|
|
|
if original_states.get(expense.id) != 'paid' and expense.state == 'paid':
|
|
# Transitioned to 'Paid'
|
|
today = fields.Date.today()
|
|
if not expense.receipt_due_date:
|
|
due_days = expense.product_id.receipt_due_days or 0
|
|
expense.receipt_due_date = today + timedelta(days=due_days)
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
if vals.get('sequence_name', _('New')) == _('New'):
|
|
payment_mode = vals.get('payment_mode', 'own_account')
|
|
seq_code = 'hr.expense.sequence.reimbursement' if payment_mode == 'own_account' else 'hr.expense.sequence.kasbon'
|
|
vals['sequence_name'] = self.env['ir.sequence'].next_by_code(seq_code) or _('New')
|
|
return super().create(vals_list)
|
|
|
|
def action_create_realization(self):
|
|
self.ensure_one()
|
|
if self.payment_mode != 'company_account':
|
|
raise UserError(_("Realization is only for company-paid expenses."))
|
|
|
|
# Check if already has a realization
|
|
if self.realization_count > 0:
|
|
return self.action_view_realizations()
|
|
|
|
return {
|
|
'name': _('Create Realization'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'hr.expense.realization',
|
|
'view_mode': 'form',
|
|
'context': {
|
|
'default_expense_id': self.id,
|
|
'default_employee_id': self.employee_id.id,
|
|
},
|
|
'target': 'current',
|
|
}
|
|
|
|
def action_view_realizations(self):
|
|
self.ensure_one()
|
|
action = self.env["ir.actions.actions"]._for_xml_id("hr_expense_account_split.action_hr_expense_realization")
|
|
if self.realization_count > 1:
|
|
action['domain'] = [('expense_id', '=', self.id)]
|
|
elif self.realization_count == 1:
|
|
res = self.env['hr.expense.realization'].search([('expense_id', '=', self.id)], limit=1)
|
|
action['views'] = [(self.env.ref('hr_expense_account_split.hr_expense_realization_view_form').id, 'form')]
|
|
action['res_id'] = res.id
|
|
return action
|
|
|
|
@api.depends('receipt_due_date', 'receipt_received')
|
|
def _compute_receipt_overdue(self):
|
|
today = fields.Date.today()
|
|
for expense in self:
|
|
if expense.receipt_due_date and not expense.receipt_received and expense.receipt_due_date < today:
|
|
expense.receipt_overdue = True
|
|
else:
|
|
expense.receipt_overdue = False
|
|
|
|
def _prepare_move_lines_vals(self):
|
|
res = super()._prepare_move_lines_vals()
|
|
if res.get('price_unit'):
|
|
# Round the price to the currency's decimal places to avoid floating point artifacts (e.g. ...0001)
|
|
# We use precision_digits=2 which is the standard for IDR/USD etc.
|
|
res['price_unit'] = float_round(res['price_unit'], precision_digits=self.currency_id.decimal_places or 2)
|
|
return res
|
|
|
|
def action_submit(self):
|
|
for expense in self:
|
|
if expense.payment_mode == 'own_account' and expense.nb_attachment == 0:
|
|
raise ValidationError(_("You must attach at least one receipt for reimbursement expenses (Paid By: Employee)."))
|
|
return super().action_submit()
|
|
|
|
def _do_refuse(self, reason):
|
|
""" Bypass the standard Odoo lock: 'You cannot cancel an expense linked to a journal entry'. """
|
|
self._do_reverse_moves()
|
|
# Handle realizations on refusal as well
|
|
for expense in self:
|
|
realizations = expense.realization_ids
|
|
if realizations.filtered(lambda r: r.state == 'posted'):
|
|
raise UserError(_("You cannot refuse this expense because it has Posted Realizations. Revert them first."))
|
|
realizations.write({'state': 'draft'})
|
|
return super()._do_refuse(reason)
|
|
|
|
def _do_reverse_moves(self):
|
|
self = self.with_context(clean_context(self.env.context))
|
|
for expense in self:
|
|
if expense.account_move_id:
|
|
move = expense.sudo().account_move_id
|
|
payment = move.origin_payment_id
|
|
if payment:
|
|
if payment.state in ('posted', 'draft'):
|
|
payment.action_cancel()
|
|
|
|
if move.state == 'posted':
|
|
move._reverse_moves(
|
|
default_values_list=[{'invoice_date': fields.Date.context_today(move), 'ref': False}],
|
|
cancel=True
|
|
)
|
|
|
|
# After reversal or if it was draft/cancel, we can unlink or at least it won't block _do_refuse
|
|
if move.state in ('draft', 'cancel'):
|
|
move.unlink()
|
|
|
|
def action_recompute_state(self):
|
|
""" Public wrapper to allow triggering recompute from a button. """
|
|
self._compute_state()
|
|
|
|
def action_reset_expense_sheets(self):
|
|
""" Overriding reset to handle realizations. """
|
|
for expense in self:
|
|
realizations = expense.realization_ids
|
|
posted_realizations = realizations.filtered(lambda r: r.state == 'posted')
|
|
if posted_realizations:
|
|
raise UserError(_("You cannot reset this expense because it has one or more Posted Realizations (%s). Please reverse or cancel the realization journal entries first.") % ", ".join(posted_realizations.mapped('name')))
|
|
|
|
# Reset draft/confirmed ones back to draft if resetting
|
|
realizations.filtered(lambda r: r.state != 'posted').write({'state': 'draft'})
|
|
|
|
return super().action_reset_expense_sheets()
|