from odoo import api, fields, models, _, Command from datetime import timedelta from odoo.exceptions import UserError, ValidationError from odoo.tools.misc import clean_context class HrExpenseSheet(models.Model): _inherit = 'hr.expense.sheet' state = fields.Selection(selection_add=[ ('wait_post', 'Wait Post') ], ondelete={'wait_post': 'set default'}) @api.depends('account_move_ids.payment_state', 'account_move_ids.amount_residual', 'account_move_ids.state', 'expense_line_ids.receipt_received', 'expense_line_ids.realization_ids.state') def _compute_state(self): # Store original states to detect transition to 'done' original_states = {sheet.id: sheet.state for sheet in self} super()._compute_state() for sheet in self: # FIX: If we have moves but they are ALL canceled/draft, Odoo super() incorrectly sets state='post' or 'done'. # We must force it back to approval_state (Approved) or draft. active_moves = sheet.account_move_ids.filtered(lambda m: m.state == 'posted') if not active_moves: if sheet.state in ('post', 'done', 'wait_post'): sheet.state = sheet.approval_state or 'draft' # Check for Company Account expenses company_paid = sheet.expense_line_ids.filtered(lambda e: e.payment_mode == 'company_account') if company_paid: # If Odoo thought it was 'done' (fully or partially paid/in_payment), # we may need to hold it at 'wait_post' until realization is complete. if sheet.state == 'done': realizations = company_paid.mapped('realization_ids') has_posted_realization = realizations and all(r.state == 'posted' for r in realizations) # Also consider payment state: if it's NOT paid or in_payment, it should definitely stay in the state super() set (e.g. 'posted') # Standard Odoo sets state='done' when payment_state is 'paid' or 'in_payment'. if sheet.payment_state in ('paid', 'in_payment'): if sheet.receipt_status != 'received' or not has_posted_realization: sheet.state = 'wait_post' else: # If not paid, it should drop back sheet.state = sheet.approval_state or 'draft' if original_states.get(sheet.id) != 'done' and sheet.state == 'done': # Transitioned to 'Paid' today = fields.Date.today() for expense in sheet.expense_line_ids: 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) 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) realization_total_amount = fields.Monetary( string='Realization Total', compute='_compute_realization_total_amount', currency_field='currency_id', store=True ) expense_sequences = fields.Char( string='Expense Sequences', compute='_compute_expense_sequences', store=True, help="Concatenated sequences of all expenses in this report." ) 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 (Total - Residual)." ) @api.depends('account_move_ids.line_ids.matched_debit_ids', 'account_move_ids.line_ids.matched_credit_ids', 'total_amount', 'amount_residual') def _compute_amount_paid(self): for sheet in self: total_paid = 0.0 seen_statement_line_ids = set() seen_payment_ids = set() seen_move_line_ids = set() # Find all relevant lines of the sheet's moves (Payable or Clearing accounts) reconcilable_lines = sheet.account_move_ids.line_ids.filtered( lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable') or l.account_id.reconcile ) for line in reconcilable_lines: # Get the counterpart lines from partial reconciliations partials = line.matched_debit_ids | line.matched_credit_ids for partial in partials: counterpart = partial.debit_move_id if partial.credit_move_id == line else partial.credit_move_id # If the counterpart is from a Bank/Cash journal, it's a "Payment" if counterpart.journal_id.type in ('bank', 'cash'): st_line = counterpart.move_id.statement_line_id payment = counterpart.payment_id if st_line: if st_line.id not in seen_statement_line_ids: total_paid += abs(st_line.amount) seen_statement_line_ids.add(st_line.id) elif payment: if payment.id not in seen_payment_ids: total_paid += payment.amount seen_payment_ids.add(payment.id) else: # Fallback to the specific move line's balance (absolute) if counterpart.id not in seen_move_line_ids: total_paid += abs(counterpart.balance) seen_move_line_ids.add(counterpart.id) # If no bank transactions found but report is clearly paid/partially paid, # fall back to standard calculation for non-bank flows (e.g. manual journal reconciliation) if not total_paid and sheet.total_amount != sheet.amount_residual: total_paid = sheet.total_amount - sheet.amount_residual sheet.amount_paid = total_paid @api.depends('expense_line_ids.realization_total_amount') def _compute_realization_total_amount(self): for sheet in self: sheet.realization_total_amount = sum(sheet.expense_line_ids.mapped('realization_total_amount')) @api.depends('expense_line_ids.sequence_name') def _compute_expense_sequences(self): for sheet in self: sequences = sheet.expense_line_ids.mapped('sequence_name') # Filter out false values and joins them sheet.expense_sequences = ", ".join(filter(None, sequences)) @api.depends('expense_line_ids.receipt_received', 'expense_line_ids.payment_mode') def _compute_receipt_status(self): for sheet in self: company_paid_expenses = sheet.expense_line_ids.filtered(lambda e: e.payment_mode == 'company_account') if not company_paid_expenses: sheet.receipt_status = 'none' elif all(e.receipt_received for e in company_paid_expenses): sheet.receipt_status = 'received' else: sheet.receipt_status = 'pending' @api.depends('account_move_ids.payment_state', 'account_move_ids.amount_residual', 'account_move_ids.state') def _compute_from_account_move_ids(self): """ Overriding to fix the 'IN PAYMENT' ribbon issue. Standard Odoo assumes 'paid' if any move exists for company_account. We check if the moves are actually in 'posted' state. """ for sheet in self: if sheet.payment_mode == 'company_account': if sheet.account_move_ids: # Filter for moves that are NOT canceled active_moves = sheet.account_move_ids.filtered(lambda m: m.state == 'posted') if active_moves: # If there are active moves that are not reversed if active_moves - active_moves.filtered('reversal_move_id'): sheet.payment_state = 'paid' sheet.amount_residual = 0. else: sheet.payment_state = 'reversed' sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual')) else: # Moves exist but none are 'posted' (e.g. they are all 'cancel' or 'draft') sheet.payment_state = 'not_paid' sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual')) else: sheet.payment_state = 'not_paid' sheet.amount_residual = 0.0 else: # Standard Odoo logic for own_account if sheet.account_move_ids: sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual')) sheet.payment_state = sheet.account_move_ids[:1].payment_state else: sheet.amount_residual = 0.0 sheet.payment_state = 'not_paid' def _do_refuse(self, reason): """ Bypass the standard Odoo lock: 'You cannot cancel an expense sheet linked to a journal entry'. We allow it but we'll try to cancel the moves first. """ self._do_reverse_moves() # Explicitly call the original _do_refuse but WITHOUT the check, # but since we already reversed/deleted moves, the original check won't trigger. return super()._do_refuse(reason) def _do_reverse_moves(self): """ Overriding to handle account.payment explicitly. Odoo's _do_reverse_moves calls _reverse_moves, which fails for payments. """ self = self.with_context(clean_context(self.env.context)) moves = self.account_move_ids if moves: for sheet in self: # Handle payments linked to this sheet payments = sheet.account_move_ids.mapped('payment_id') if payments: # Cancel the payments directly for payment in payments: if payment.state == 'posted': payment.action_cancel() elif payment.state == 'draft': payment.action_cancel() # Standard reversal for non-payment moves (if any) non_payment_moves = sheet.account_move_ids.filtered(lambda m: not m.payment_id) if non_payment_moves: non_payment_moves._reverse_moves( default_values_list=[{'invoice_date': fields.Date.context_today(move), 'ref': False} for move in non_payment_moves], cancel=True ) # Unlink draft/canceled moves (including payment moves that are now draft/cancel) sheet.account_move_ids.filtered(lambda m: m.state in ('draft', 'cancel')).unlink() def action_reset_expense_sheets(self): """ Overriding reset to handle realizations. If a realization is posted, we should probably warn or at least prevent resetting if we want strict audit. For now, we'll allow it but cancel any draft/confirmed realizations. """ for sheet in self: realizations = sheet.expense_line_ids.mapped('realization_ids') posted_realizations = realizations.filtered(lambda r: r.state == 'posted') if posted_realizations: raise UserError(_("You cannot reset this report 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 the sheet realizations.filtered(lambda r: r.state != 'posted').write({'state': 'draft'}) return super().action_reset_expense_sheets() def action_refuse_expense_sheets(self): """ Handle realizations on refusal as well. """ for sheet in self: realizations = sheet.expense_line_ids.mapped('realization_ids') if realizations.filtered(lambda r: r.state == 'posted'): raise UserError(_("You cannot refuse this report because it has Posted Realizations. Revert them first.")) realizations.write({'state': 'draft'}) return super().action_refuse_expense_sheets() def action_recompute_state(self): """ Public wrapper to allow triggering recompute from a button. """ self._compute_state() self._compute_from_account_move_ids()