diff --git a/controllers/hr_expense_kiosk_controller.py b/controllers/hr_expense_kiosk_controller.py index fb80fc5..ca85e65 100644 --- a/controllers/hr_expense_kiosk_controller.py +++ b/controllers/hr_expense_kiosk_controller.py @@ -153,9 +153,7 @@ class HrExpenseKioskController(http.Controller): }) # Use sudo to allow the public user to trigger the workflow - expense.sudo().action_submit_expenses() - if expense.sheet_id: - expense.sheet_id.sudo().action_submit_sheet() + expense.sudo().action_submit() return {'status': 'ok', 'res_id': expense.id} except Exception as e: diff --git a/models/__init__.py b/models/__init__.py index 4b9255e..1339b5c 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,6 +1,5 @@ from . import product_template from . import hr_expense -from . import hr_expense_sheet from . import account_move_line from . import account_move from . import hr_expense_realization diff --git a/models/hr_expense.py b/models/hr_expense.py index d436529..cf61f62 100644 --- a/models/hr_expense.py +++ b/models/hr_expense.py @@ -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.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() @@ -51,6 +57,20 @@ class HrExpense(models.Model): 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: @@ -61,6 +81,52 @@ class HrExpense(models.Model): 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: @@ -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) return res - def action_submit_expenses(self): + 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_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() diff --git a/models/hr_expense_realization.py b/models/hr_expense_realization.py index 1752ec5..93204e7 100644 --- a/models/hr_expense_realization.py +++ b/models/hr_expense_realization.py @@ -88,9 +88,8 @@ class HrExpenseRealization(models.Model): self.state = 'confirmed' if self.expense_id: self.expense_id.write({'receipt_received': True}) - # Explicitly trigger recompute of the sheet status - if self.expense_id.sheet_id: - self.expense_id.sheet_id._compute_receipt_status() + # Explicitly trigger recompute of the status + self.expense_id._compute_receipt_status() def action_apply_default_account(self): self.ensure_one() @@ -269,7 +268,7 @@ class HrExpenseRealization(models.Model): ('employee_id', '=', employee_id), ('payment_mode', '=', 'company_account'), ('receipt_received', '=', False), - ('state', 'in', ['approved', 'done']) + ('state', 'in', ['approved', 'posted', 'paid']) ], fields=['id', 'name', 'date', 'total_amount', 'currency_id'] ) diff --git a/models/hr_expense_sheet.py b/models/hr_expense_sheet.py deleted file mode 100644 index 0d13bd6..0000000 --- a/models/hr_expense_sheet.py +++ /dev/null @@ -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() diff --git a/views/hr_expense_realization_views.xml b/views/hr_expense_realization_views.xml index 8c74fc1..5f60f82 100644 --- a/views/hr_expense_realization_views.xml +++ b/views/hr_expense_realization_views.xml @@ -152,23 +152,9 @@ - - - - - - - - + sequence="3"/> diff --git a/views/hr_expense_views.xml b/views/hr_expense_views.xml index cee25a8..1daeaad 100644 --- a/views/hr_expense_views.xml +++ b/views/hr_expense_views.xml @@ -7,31 +7,26 @@ 1000 - - - - - - - - - - - - - -