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()