from odoo import api, fields, models from datetime import timedelta 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', '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: # 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.receipt_status != 'received' or not has_posted_realization: sheet.state = 'wait_post' 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' 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()