diff --git a/models/__init__.py b/models/__init__.py index 422493a..9304cea 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 hr_expense_payment_wizard from . import account_move_line from . import account_move diff --git a/models/hr_expense.py b/models/hr_expense.py index 42cb246..1a03019 100644 --- a/models/hr_expense.py +++ b/models/hr_expense.py @@ -238,3 +238,16 @@ class HrExpense(models.Model): realizations.filtered(lambda r: r.state != 'posted').write({'state': 'draft'}) return super().action_reset_expense_sheets() + def action_open_payment_wizard(self): + self.ensure_one() + return { + 'name': _('Register Payment'), + 'type': 'ir.actions.act_window', + 'res_model': 'hr.expense.payment.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_expense_id': self.id, + 'default_amount': self.total_amount, + } + } diff --git a/models/hr_expense_payment_wizard.py b/models/hr_expense_payment_wizard.py index 25226b6..d224af2 100644 --- a/models/hr_expense_payment_wizard.py +++ b/models/hr_expense_payment_wizard.py @@ -5,10 +5,10 @@ class HrExpensePaymentWizard(models.TransientModel): _name = 'hr.expense.payment.wizard' _description = 'Expense Payment Wizard' - expense_sheet_id = fields.Many2one('hr.expense.sheet', required=True) + expense_id = fields.Many2one('hr.expense', string='Expense', required=True) amount = fields.Monetary(string='Payment Amount', required=True, readonly=True) - currency_id = fields.Many2one('res.currency', related='expense_sheet_id.currency_id') - company_id = fields.Many2one('res.company', related='expense_sheet_id.company_id') + currency_id = fields.Many2one('res.currency', related='expense_id.currency_id') + company_id = fields.Many2one('res.company', related='expense_id.company_id') partner_id = fields.Many2one( 'res.partner', @@ -38,6 +38,7 @@ class HrExpensePaymentWizard(models.TransientModel): self.payment_method_line_id = available_payment_methods[0].id else: self.payment_method_line_id = False + self.payment_method_line_id = False def action_create_payment(self): self.ensure_one() @@ -60,23 +61,19 @@ class HrExpensePaymentWizard(models.TransientModel): 'journal_id': self.journal_id.id, 'currency_id': self.currency_id.id, 'payment_method_line_id': self.payment_method_line_id.id, - 'ref': self.expense_sheet_id.name, + 'ref': self.expense_id.name, 'destination_account_id': uang_muka_account.id, - 'expense_ids': [Command.set(self.expense_sheet_id.expense_line_ids.ids)], + 'expense_ids': [Command.set(self.expense_id.ids)], } payment = self.env['account.payment'].create(payment_vals) payment.action_post() - # Link the payment's move to the expense sheet (if field exists, standard Odoo usually has it) - if hasattr(payment.move_id, 'expense_sheet_id'): - payment.move_id.write({'expense_sheet_id': self.expense_sheet_id.id}) + # In Odoo 19, the payment move is linked to the expense via expense_ids on the payment. + # Standard Odoo should handle the state update if we followed the right hooks, + # but since we are doing a custom flow, let's trigger it. - # Update sheet status to Paid - self.expense_sheet_id.write({ - 'state': 'done', - }) - # Our custom logic will push it to 'wait_post' if realization is pending - self.expense_sheet_id.action_recompute_state() + # Update expense status + self.expense_id.action_recompute_state() return {'type': 'ir.actions.act_window_close'} diff --git a/models/hr_expense_sheet.py b/models/hr_expense_sheet.py deleted file mode 100644 index 7b215b9..0000000 --- a/models/hr_expense_sheet.py +++ /dev/null @@ -1,269 +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'): - # Odoo 19 uses 'approve' instead of 'approval_state' field often, but let's be safe - sheet.state = 'approve' if sheet.state != 'cancel' else 'cancel' - - # 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) - - 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 = 'approve' - - if original_states.get(sheet.id) != 'done' and sheet.state == 'done': - # Transitioned to 'Paid/Done' - 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() - return super()._do_refuse(reason) - - def _do_reverse_moves(self): - """ - Overriding to handle account.payment explicitly. - """ - self = self.with_context(clean_context(self.env.context)) - if self.account_move_ids: - 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. """ - 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() - - def action_open_payment_wizard(self): - self.ensure_one() - return { - 'name': _('Register Payment'), - 'type': 'ir.actions.act_window', - 'res_model': 'hr.expense.payment.wizard', - 'view_mode': 'form', - 'target': 'new', - 'context': { - 'default_expense_sheet_id': self.id, - 'default_amount': self.total_amount, - } - } diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv index 2952b4d..bf37d0b 100644 --- a/security/ir.model.access.csv +++ b/security/ir.model.access.csv @@ -2,3 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_hr_expense_realization_user,hr.expense.realization,model_hr_expense_realization,hr_expense.group_hr_expense_user,1,1,1,0 access_hr_expense_realization_manager,hr.expense.realization,model_hr_expense_realization,hr_expense.group_hr_expense_manager,1,1,1,1 access_hr_expense_realization_line_user,hr.expense.realization.line,model_hr_expense_realization_line,hr_expense.group_hr_expense_user,1,1,1,1 +access_hr_expense_payment_wizard,hr.expense.payment.wizard,model_hr_expense_payment_wizard,base.group_user,1,1,1,1 diff --git a/views/hr_expense_payment_wizard_views.xml b/views/hr_expense_payment_wizard_views.xml index 281447e..266726e 100644 --- a/views/hr_expense_payment_wizard_views.xml +++ b/views/hr_expense_payment_wizard_views.xml @@ -8,7 +8,7 @@ - + diff --git a/views/hr_expense_views.xml b/views/hr_expense_views.xml index d28e98a..de051a5 100644 --- a/views/hr_expense_views.xml +++ b/views/hr_expense_views.xml @@ -12,14 +12,17 @@ draft,submitted,approved,posted,wait_post,paid - - -