refactor: remove HrExpenseSheet model and consolidate expense status logic into HrExpense and HrExpenseRealization models
This commit is contained in:
parent
8d38d49c85
commit
4a64d2b73c
@ -153,9 +153,7 @@ class HrExpenseKioskController(http.Controller):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Use sudo to allow the public user to trigger the workflow
|
# Use sudo to allow the public user to trigger the workflow
|
||||||
expense.sudo().action_submit_expenses()
|
expense.sudo().action_submit()
|
||||||
if expense.sheet_id:
|
|
||||||
expense.sheet_id.sudo().action_submit_sheet()
|
|
||||||
|
|
||||||
return {'status': 'ok', 'res_id': expense.id}
|
return {'status': 'ok', 'res_id': expense.id}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
from . import product_template
|
from . import product_template
|
||||||
from . import hr_expense
|
from . import hr_expense
|
||||||
from . import hr_expense_sheet
|
|
||||||
from . import account_move_line
|
from . import account_move_line
|
||||||
from . import account_move
|
from . import account_move
|
||||||
from . import hr_expense_realization
|
from . import hr_expense_realization
|
||||||
|
|||||||
@ -1,10 +1,16 @@
|
|||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _, Command
|
||||||
|
from datetime import timedelta
|
||||||
from odoo.exceptions import UserError, ValidationError
|
from odoo.exceptions import UserError, ValidationError
|
||||||
from odoo.tools import float_round
|
from odoo.tools import float_round
|
||||||
|
from odoo.tools.misc import clean_context
|
||||||
|
|
||||||
class HrExpense(models.Model):
|
class HrExpense(models.Model):
|
||||||
_inherit = 'hr.expense'
|
_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')
|
@api.depends('product_id', 'company_id', 'payment_mode')
|
||||||
def _compute_account_id(self):
|
def _compute_account_id(self):
|
||||||
super()._compute_account_id()
|
super()._compute_account_id()
|
||||||
@ -51,6 +57,20 @@ class HrExpense(models.Model):
|
|||||||
realization_ids = fields.One2many('hr.expense.realization', 'expense_id', string='Realizations')
|
realization_ids = fields.One2many('hr.expense.realization', 'expense_id', string='Realizations')
|
||||||
realization_count = fields.Integer(string='Realization Count', compute='_compute_realization_count')
|
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')
|
@api.depends('realization_ids')
|
||||||
def _compute_realization_count(self):
|
def _compute_realization_count(self):
|
||||||
for expense in self:
|
for expense in self:
|
||||||
@ -61,6 +81,52 @@ class HrExpense(models.Model):
|
|||||||
for expense in self:
|
for expense in self:
|
||||||
expense.realization_total_amount = sum(expense.realization_ids.mapped('total_amount'))
|
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
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
def create(self, vals_list):
|
||||||
for vals in vals_list:
|
for vals in vals_list:
|
||||||
@ -119,8 +185,56 @@ class HrExpense(models.Model):
|
|||||||
res['price_unit'] = float_round(res['price_unit'], precision_digits=self.currency_id.decimal_places or 2)
|
res['price_unit'] = float_round(res['price_unit'], precision_digits=self.currency_id.decimal_places or 2)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def action_submit_expenses(self):
|
def action_submit(self):
|
||||||
for expense in self:
|
for expense in self:
|
||||||
if expense.payment_mode == 'own_account' and expense.nb_attachment == 0:
|
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)."))
|
raise ValidationError(_("You must attach at least one receipt for reimbursement expenses (Paid By: Employee)."))
|
||||||
return super().action_submit_expenses()
|
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.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()
|
||||||
|
|||||||
@ -88,9 +88,8 @@ class HrExpenseRealization(models.Model):
|
|||||||
self.state = 'confirmed'
|
self.state = 'confirmed'
|
||||||
if self.expense_id:
|
if self.expense_id:
|
||||||
self.expense_id.write({'receipt_received': True})
|
self.expense_id.write({'receipt_received': True})
|
||||||
# Explicitly trigger recompute of the sheet status
|
# Explicitly trigger recompute of the status
|
||||||
if self.expense_id.sheet_id:
|
self.expense_id._compute_receipt_status()
|
||||||
self.expense_id.sheet_id._compute_receipt_status()
|
|
||||||
|
|
||||||
def action_apply_default_account(self):
|
def action_apply_default_account(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
@ -269,7 +268,7 @@ class HrExpenseRealization(models.Model):
|
|||||||
('employee_id', '=', employee_id),
|
('employee_id', '=', employee_id),
|
||||||
('payment_mode', '=', 'company_account'),
|
('payment_mode', '=', 'company_account'),
|
||||||
('receipt_received', '=', False),
|
('receipt_received', '=', False),
|
||||||
('state', 'in', ['approved', 'done'])
|
('state', 'in', ['approved', 'posted', 'paid'])
|
||||||
],
|
],
|
||||||
fields=['id', 'name', 'date', 'total_amount', 'currency_id']
|
fields=['id', 'name', 'date', 'total_amount', 'currency_id']
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,266 +0,0 @@
|
|||||||
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()
|
|
||||||
@ -152,23 +152,9 @@
|
|||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Menu Structure Adjustment -->
|
<!-- Menu Structure Adjustment -->
|
||||||
|
|
||||||
<!-- 1. Create a new parent menu "Expense Reports" -->
|
|
||||||
<menuitem id="menu_expense_reports_parent"
|
|
||||||
name="Expense Reports"
|
|
||||||
parent="hr_expense.menu_hr_expense_root"
|
|
||||||
sequence="2"/>
|
|
||||||
|
|
||||||
<!-- 2. Move the standard "Expense Reports" menu under it and rename it to "Expenses" -->
|
|
||||||
<menuitem id="hr_expense.menu_hr_expense_report"
|
|
||||||
name="Expenses"
|
|
||||||
parent="menu_expense_reports_parent"
|
|
||||||
sequence="1"/>
|
|
||||||
|
|
||||||
<!-- 3. Add the "Realization Report" submenu under it -->
|
|
||||||
<menuitem id="menu_hr_expense_realization"
|
<menuitem id="menu_hr_expense_realization"
|
||||||
name="Realization Report"
|
name="Realization Report"
|
||||||
parent="menu_expense_reports_parent"
|
parent="hr_expense.menu_hr_expense_root"
|
||||||
action="action_hr_expense_realization"
|
action="action_hr_expense_realization"
|
||||||
sequence="2"/>
|
sequence="3"/>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@ -7,31 +7,26 @@
|
|||||||
<field name="inherit_id" ref="hr_expense.hr_expense_view_form"/>
|
<field name="inherit_id" ref="hr_expense.hr_expense_view_form"/>
|
||||||
<field name="priority">1000</field>
|
<field name="priority">1000</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<!-- Remove all standard Submit Buttons -->
|
<!-- Update header: Add wait_post to statusbar -->
|
||||||
<xpath expr="(//header//button[@name='action_submit_expenses'])[1]" position="replace"/>
|
<xpath expr="//field[@name='state'][@statusbar_visible]" position="attributes">
|
||||||
<xpath expr="(//header//button[@name='action_submit_expenses'])[1]" position="replace"/>
|
<attribute name="statusbar_visible">draft,submitted,approved,posted,wait_post,paid</attribute>
|
||||||
|
|
||||||
<!-- Remove all standard Attach Receipt widgets -->
|
|
||||||
<xpath expr="(//header//widget[@name='attach_document'])[1]" position="replace"/>
|
|
||||||
<xpath expr="(//header//widget[@name='attach_document'])[1]" position="replace"/>
|
|
||||||
|
|
||||||
<!-- Remove Split Expense to re-add with correct logic -->
|
|
||||||
<xpath expr="//header//button[@name='action_split_wizard']" position="replace"/>
|
|
||||||
|
|
||||||
<!-- Re-add ONLY one Create Report button as primary -->
|
|
||||||
<xpath expr="//header" position="inside">
|
|
||||||
<button name="action_submit_expenses"
|
|
||||||
string="Create Report"
|
|
||||||
type="object"
|
|
||||||
class="oe_highlight o_expense_submit"
|
|
||||||
invisible="sheet_id"
|
|
||||||
data-hotkey="v"/>
|
|
||||||
<widget name="attach_document"
|
|
||||||
string="Attach Receipt"
|
|
||||||
action="attach_document"
|
|
||||||
highlight="nb_attachment < 1 and payment_mode == 'own_account'"
|
|
||||||
invisible="sheet_id"/>
|
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Update header: Add Recompute Status button -->
|
||||||
|
<xpath expr="//header" position="inside">
|
||||||
|
<button name="action_recompute_state"
|
||||||
|
string="Recompute Status"
|
||||||
|
type="object"
|
||||||
|
groups="base.group_erp_manager"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
help="Force refresh the expense status based on current payments and receipts."/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Attach Receipt widget -->
|
||||||
|
<xpath expr="//header//widget[@name='attach_document']" position="attributes">
|
||||||
|
<attribute name="highlight">nb_attachment == 0 and payment_mode == 'own_account'</attribute>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
<xpath expr="//div[hasclass('oe_title')]" position="before">
|
<xpath expr="//div[hasclass('oe_title')]" position="before">
|
||||||
<div class="oe_button_box" name="button_box">
|
<div class="oe_button_box" name="button_box">
|
||||||
<button name="action_view_realizations"
|
<button name="action_view_realizations"
|
||||||
@ -47,7 +42,7 @@
|
|||||||
type="object"
|
type="object"
|
||||||
class="oe_stat_button"
|
class="oe_stat_button"
|
||||||
icon="fa-plus-square-o"
|
icon="fa-plus-square-o"
|
||||||
invisible="payment_mode != 'company_account' or state not in ['done', 'reported'] or realization_count != 0"/>
|
invisible="payment_mode != 'company_account' or state not in ['paid', 'posted', 'wait_post'] or realization_count != 0"/>
|
||||||
</div>
|
</div>
|
||||||
</xpath>
|
</xpath>
|
||||||
<xpath expr="//sheet" position="before">
|
<xpath expr="//sheet" position="before">
|
||||||
@ -67,6 +62,11 @@
|
|||||||
<field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/>
|
<field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/>
|
||||||
<field name="receipt_received" widget="boolean_toggle" invisible="not receipt_due_date"/>
|
<field name="receipt_received" widget="boolean_toggle" invisible="not receipt_due_date"/>
|
||||||
<field name="receipt_overdue" invisible="1"/>
|
<field name="receipt_overdue" invisible="1"/>
|
||||||
|
<field name="receipt_status" widget="badge"
|
||||||
|
decoration-info="receipt_status == 'pending'"
|
||||||
|
decoration-success="receipt_status == 'received'"
|
||||||
|
decoration-muted="receipt_status == 'none'"/>
|
||||||
|
<field name="amount_paid" invisible="payment_mode != 'own_account'"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
@ -80,10 +80,19 @@
|
|||||||
<xpath expr="//field[@name='name']" position="before">
|
<xpath expr="//field[@name='name']" position="before">
|
||||||
<field name="sequence_name" optional="show"/>
|
<field name="sequence_name" optional="show"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='state']" position="attributes">
|
||||||
|
<attribute name="decoration-warning">state == 'wait_post'</attribute>
|
||||||
|
</xpath>
|
||||||
<xpath expr="//field[@name='total_amount']" position="after">
|
<xpath expr="//field[@name='total_amount']" position="after">
|
||||||
|
<field name="amount_paid" optional="show" sum="Total Payment" string="Payment Total"/>
|
||||||
<field name="realization_total_amount" optional="show" sum="Total Realization" invisible="payment_mode != 'company_account'"/>
|
<field name="realization_total_amount" optional="show" sum="Total Realization" invisible="payment_mode != 'company_account'"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
<xpath expr="//field[@name='state']" position="after">
|
<xpath expr="//field[@name='state']" position="after">
|
||||||
|
<field name="receipt_status" widget="badge"
|
||||||
|
decoration-info="receipt_status == 'pending'"
|
||||||
|
decoration-success="receipt_status == 'received'"
|
||||||
|
decoration-muted="receipt_status == 'none'"
|
||||||
|
optional="show"/>
|
||||||
<field name="receipt_due_date" optional="show" widget="remaining_days" decoration-danger="receipt_overdue"/>
|
<field name="receipt_due_date" optional="show" widget="remaining_days" decoration-danger="receipt_overdue"/>
|
||||||
<field name="receipt_received" optional="show" widget="boolean_toggle"/>
|
<field name="receipt_received" optional="show" widget="boolean_toggle"/>
|
||||||
<field name="receipt_overdue" column_invisible="True"/>
|
<field name="receipt_overdue" column_invisible="True"/>
|
||||||
@ -97,14 +106,16 @@
|
|||||||
<field name="model">hr.expense</field>
|
<field name="model">hr.expense</field>
|
||||||
<field name="inherit_id" ref="hr_expense.hr_expense_view_search"/>
|
<field name="inherit_id" ref="hr_expense.hr_expense_view_search"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//filter[@name='no_report']" position="after">
|
<xpath expr="//filter[@name='my_expenses']" position="after">
|
||||||
<separator/>
|
<separator/>
|
||||||
|
<filter string="Wait Post" name="filter_wait_post" domain="[('state', '=', 'wait_post')]"/>
|
||||||
<filter string="Overdue Receipts" name="filter_receipt_overdue" domain="[('receipt_overdue', '=', True), ('receipt_received', '=', False)]"/>
|
<filter string="Overdue Receipts" name="filter_receipt_overdue" domain="[('receipt_overdue', '=', True), ('receipt_received', '=', False)]"/>
|
||||||
<filter string="Receipt Received" name="filter_receipt_received" domain="[('receipt_received', '=', True)]"/>
|
<filter string="Receipt Received" name="filter_receipt_received" domain="[('receipt_received', '=', True)]"/>
|
||||||
<filter string="Receipt Missing" name="filter_receipt_missing" domain="[('receipt_received', '=', False), ('receipt_due_date', '!=', False)]"/>
|
<filter string="Receipt Missing" name="filter_receipt_missing" domain="[('receipt_received', '=', False), ('receipt_due_date', '!=', False)]"/>
|
||||||
|
<filter string="Pending Receipts" name="filter_receipt_pending" domain="[('receipt_status', '=', 'pending')]"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
<xpath expr="//group" position="inside">
|
<xpath expr="//group" position="inside">
|
||||||
<filter string="Receipt Status" name="group_receipt_status" context="{'group_by': 'receipt_received'}"/>
|
<filter string="Receipt Status" name="group_receipt_status" context="{'group_by': 'receipt_status'}"/>
|
||||||
<filter string="Receipt Due Date" name="group_receipt_due" context="{'group_by': 'receipt_due_date'}"/>
|
<filter string="Receipt Due Date" name="group_receipt_due" context="{'group_by': 'receipt_due_date'}"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
@ -131,107 +142,13 @@
|
|||||||
parent="hr_expense.menu_hr_expense_reports"
|
parent="hr_expense.menu_hr_expense_reports"
|
||||||
action="action_hr_expense_overdue_receipts"
|
action="action_hr_expense_overdue_receipts"
|
||||||
sequence="20"/>
|
sequence="20"/>
|
||||||
<record id="view_hr_expense_sheet_form_inherit_realization" model="ir.ui.view">
|
|
||||||
<field name="name">hr.expense.sheet.form.inherit.realization</field>
|
|
||||||
<field name="model">hr.expense.sheet</field>
|
|
||||||
<field name="inherit_id" ref="hr_expense.view_hr_expense_sheet_form"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<!-- Update standard Reset to Draft and Cancel buttons to include 'wait_post' -->
|
|
||||||
<xpath expr="//header//button[@name='action_reset_expense_sheets']" position="attributes">
|
|
||||||
<attribute name="invisible">state not in ['approve', 'post', 'done', 'cancel', 'wait_post']</attribute>
|
|
||||||
</xpath>
|
|
||||||
<xpath expr="//header//button[@name='action_refuse_expense_sheets']" position="attributes">
|
|
||||||
<attribute name="invisible">state not in ['submit', 'approve', 'post', 'wait_post']</attribute>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<xpath expr="//field[@name='state']" position="attributes">
|
<!-- Update Actions to include Wait Post in SearchPanel -->
|
||||||
<attribute name="statusbar_visible">draft,submit,approve,post,wait_post,done</attribute>
|
<record id="hr_expense.hr_expense_actions_my_all" model="ir.actions.act_window">
|
||||||
</xpath>
|
<field name="context">{ 'searchpanel_default_state': ["draft", "submitted", "approved", "posted", "wait_post", "paid"], 'search_default_my_expenses': 1 }</field>
|
||||||
<xpath expr="//header" position="inside">
|
|
||||||
<button name="action_recompute_state"
|
|
||||||
string="Recompute Status"
|
|
||||||
type="object"
|
|
||||||
groups="base.group_erp_manager"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
help="Force refresh the report status based on current payments and receipts."/>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<xpath expr="//field[@name='expense_line_ids']/tree/field[@name='name']" position="before">
|
|
||||||
<field name="sequence_name" column_invisible="not parent.id"/>
|
|
||||||
</xpath>
|
|
||||||
<xpath expr="//field[@name='expense_line_ids']/tree/field[@name='total_amount']" position="after">
|
|
||||||
<field name="realization_total_amount" optional="show" invisible="payment_mode != 'company_account'"/>
|
|
||||||
</xpath>
|
|
||||||
<xpath expr="//field[@name='expense_line_ids']/tree/field[@name='name']" position="after">
|
|
||||||
<field name="payment_mode" column_invisible="True"/>
|
|
||||||
<field name="realization_count" column_invisible="True"/>
|
|
||||||
<!-- Button to Add Receipt if none exists -->
|
|
||||||
<button name="action_create_realization"
|
|
||||||
string="Add Receipt"
|
|
||||||
type="object"
|
|
||||||
icon="fa-plus"
|
|
||||||
class="text-primary"
|
|
||||||
title="Add receipts for this expense"
|
|
||||||
invisible="payment_mode != 'company_account' or parent.state not in ['post', 'wait_post', 'done'] or realization_count != 0"/>
|
|
||||||
<!-- Button to View Receipts if already exist -->
|
|
||||||
<button name="action_view_realizations"
|
|
||||||
string="View Receipts"
|
|
||||||
type="object"
|
|
||||||
icon="fa-external-link"
|
|
||||||
class="text-success"
|
|
||||||
title="View linked receipts"
|
|
||||||
invisible="payment_mode != 'company_account' or parent.state not in ['post', 'wait_post', 'done'] or realization_count == 0"/>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Inherit Expense Report Tree View -->
|
<record id="hr_expense.hr_expense_actions_all" model="ir.actions.act_window">
|
||||||
<record id="view_hr_expense_sheet_tree_inherit_receipt" model="ir.ui.view">
|
<field name="context">{ 'searchpanel_default_state': ["draft", "submitted", "approved", "posted", "wait_post", "paid"] }</field>
|
||||||
<field name="name">hr.expense.sheet.tree.receipt</field>
|
|
||||||
<field name="model">hr.expense.sheet</field>
|
|
||||||
<field name="inherit_id" ref="hr_expense.view_hr_expense_sheet_tree"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<xpath expr="//field[@name='state']" position="attributes">
|
|
||||||
<attribute name="decoration-warning">state == 'wait_post'</attribute>
|
|
||||||
</xpath>
|
|
||||||
<xpath expr="//field[@name='state']" position="before">
|
|
||||||
<field name="expense_sequences" optional="show"/>
|
|
||||||
<field name="amount_paid" optional="show" sum="Total Payment" string="Payment Total"/>
|
|
||||||
<field name="realization_total_amount" optional="show" sum="Total Realization"/>
|
|
||||||
<field name="receipt_status" widget="badge"
|
|
||||||
decoration-info="receipt_status == 'pending'"
|
|
||||||
decoration-success="receipt_status == 'received'"
|
|
||||||
decoration-muted="receipt_status == 'none'"
|
|
||||||
optional="show"/>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Inherit Expense Report Search View -->
|
|
||||||
<record id="hr_expense_sheet_view_search_inherit_receipt" model="ir.ui.view">
|
|
||||||
<field name="name">hr.expense.sheet.search.receipt</field>
|
|
||||||
<field name="model">hr.expense.sheet</field>
|
|
||||||
<field name="inherit_id" ref="hr_expense.hr_expense_sheet_view_search"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<xpath expr="//filter[@name='my_reports']" position="after">
|
|
||||||
<separator/>
|
|
||||||
<filter string="Wait Post" name="filter_wait_post" domain="[('state', '=', 'wait_post')]"/>
|
|
||||||
<filter string="Pending Receipts" name="filter_receipt_pending" domain="[('receipt_status', '=', 'pending')]"/>
|
|
||||||
<filter string="Receipts Received" name="filter_receipt_received" domain="[('receipt_status', '=', 'received')]"/>
|
|
||||||
</xpath>
|
|
||||||
<xpath expr="//group[@name='group_filters']" position="inside">
|
|
||||||
<filter string="Receipt Status" name="group_receipt_status" context="{'group_by': 'receipt_status'}"/>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Update All Reports Action to include Wait Post in SearchPanel -->
|
|
||||||
<record id="hr_expense.action_hr_expense_sheet_all" model="ir.actions.act_window">
|
|
||||||
<field name="context">{ 'searchpanel_default_state': ["draft", "submit", "approve", "post", "wait_post", "done"] }</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Update My Reports Action to include Wait Post in SearchPanel if applicable -->
|
|
||||||
<record id="hr_expense.action_hr_expense_sheet_my_all" model="ir.actions.act_window">
|
|
||||||
<field name="context">{ 'searchpanel_default_state': ["draft", "submit", "approve", "post", "wait_post", "done"], 'search_default_my_reports': 1, 'search_default_not_refused_reports': 1 }</field>
|
|
||||||
</record>
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user