267 lines
13 KiB
Python
267 lines
13 KiB
Python
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', 'account_move_ids.payment_id.is_matched')
|
|
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
|
|
moves = active_moves - active_moves.filtered('reversal_move_id')
|
|
if moves:
|
|
payments = moves.mapped('payment_id')
|
|
unmatched_payments = payments.filtered(lambda p: not p.is_matched)
|
|
|
|
if unmatched_payments:
|
|
sheet.payment_state = 'in_payment'
|
|
else:
|
|
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()
|