diff --git a/README.md b/README.md index 7b39e4b..da3b7e0 100644 --- a/README.md +++ b/README.md @@ -50,4 +50,4 @@ This module enhances Odoo's standard Expense workflow by providing account-split ## 📋 Technical Notes - **Controller**: `/hr_expense/kiosk/` - **Models**: `hr.expense`, `hr.expense.sheet`, `hr.expense.realization`, `account.payment` -- **JS Framework**: Odoo 17 OWL +- **JS Framework**: Odoo 19 OWL diff --git a/__manifest__.py b/__manifest__.py index ddd7d18..0082fc0 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -10,6 +10,7 @@ 'data/ir_sequence_data.xml', 'views/product_views.xml', 'views/hr_expense_views.xml', + 'views/hr_expense_payment_wizard_views.xml', 'views/hr_expense_realization_views.xml', 'views/hr_expense_kiosk_templates.xml', 'views/res_config_settings_views.xml', diff --git a/models/__init__.py b/models/__init__.py index 1339b5c..422493a 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,5 +1,7 @@ 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 from . import hr_expense_realization diff --git a/models/account_payment.py b/models/account_payment.py index b3eee8b..e227119 100644 --- a/models/account_payment.py +++ b/models/account_payment.py @@ -128,7 +128,7 @@ class AccountPayment(models.Model): Override _seek_for_lines to explicitly force the destination_account_id to be recognized as the counterpart_line. - Native Odoo sometimes misclassifies the advance/expense account (e.g. 118101) + Native Odoo sometimes misclassifies the advance/expense account (e.g. 115101) as a write-off line if it's not strictly a receivable/payable account type. Simultaneously, if a tax deduction account (e.g. 217103 PPh 23) is a payable account type, Odoo erroneously classifies the deduction as the counterpart. diff --git a/models/hr_expense_payment_wizard.py b/models/hr_expense_payment_wizard.py new file mode 100644 index 0000000..25226b6 --- /dev/null +++ b/models/hr_expense_payment_wizard.py @@ -0,0 +1,82 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +class HrExpensePaymentWizard(models.TransientModel): + _name = 'hr.expense.payment.wizard' + _description = 'Expense Payment Wizard' + + expense_sheet_id = fields.Many2one('hr.expense.sheet', 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') + + partner_id = fields.Many2one( + 'res.partner', + string='Vendor', + required=True, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]" + ) + journal_id = fields.Many2one( + 'account.journal', + string='Payment Journal', + required=True, + domain="[('type', 'in', ('bank', 'cash')), ('company_id', '=', company_id)]" + ) + payment_method_line_id = fields.Many2one( + 'account.payment.method.line', + string='Payment Method', + required=True, + domain="[('journal_id', '=', journal_id), ('payment_type', '=', 'outbound')]" + ) + payment_date = fields.Date(string='Payment Date', default=fields.Date.context_today, required=True) + + @api.onchange('journal_id') + def _onchange_journal_id(self): + if self.journal_id: + available_payment_methods = self.journal_id.outbound_payment_method_line_ids + if available_payment_methods: + self.payment_method_line_id = available_payment_methods[0].id + else: + self.payment_method_line_id = False + + def action_create_payment(self): + self.ensure_one() + + # Find 115101 account (Uang Muka Operasional) + uang_muka_account = self.env['account.account'].search([ + ('code', '=', '115101'), + ('company_id', '=', self.company_id.id) + ], limit=1) + + if not uang_muka_account: + raise UserError(_("Account 115101 Uang Muka Operasional not found for this company!")) + + payment_vals = { + 'date': self.payment_date, + 'amount': self.amount, + 'payment_type': 'outbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_id.id, + '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, + 'destination_account_id': uang_muka_account.id, + 'expense_ids': [Command.set(self.expense_sheet_id.expense_line_ids.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}) + + # 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() + + return {'type': 'ir.actions.act_window_close'} diff --git a/models/hr_expense_realization.py b/models/hr_expense_realization.py index 93204e7..f44fbd6 100644 --- a/models/hr_expense_realization.py +++ b/models/hr_expense_realization.py @@ -108,7 +108,7 @@ class HrExpenseRealization(models.Model): raise UserError(_("Please specify the Journal before posting.")) # 1. Determine accounts - # Advance Account (Product's expense account, e.g. 118101) + # Advance Account (Product's expense account, e.g. 115101) product = self.expense_id.product_id.with_company(self.company_id) advance_account = product.property_account_expense_company_id or product.property_account_expense_id if not advance_account: diff --git a/models/hr_expense_sheet.py b/models/hr_expense_sheet.py new file mode 100644 index 0000000..7b215b9 --- /dev/null +++ b/models/hr_expense_sheet.py @@ -0,0 +1,269 @@ +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/views/hr_expense_payment_wizard_views.xml b/views/hr_expense_payment_wizard_views.xml new file mode 100644 index 0000000..281447e --- /dev/null +++ b/views/hr_expense_payment_wizard_views.xml @@ -0,0 +1,38 @@ + + + + hr.expense.payment.wizard.view.form + hr.expense.payment.wizard + +
+ + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Register Payment + hr.expense.payment.wizard + form + new + +
diff --git a/views/hr_expense_views.xml b/views/hr_expense_views.xml index ba17985..d28e98a 100644 --- a/views/hr_expense_views.xml +++ b/views/hr_expense_views.xml @@ -143,7 +143,7 @@ action="action_hr_expense_overdue_receipts" sequence="20"/> - + { 'searchpanel_default_state': ["draft", "submitted", "approved", "posted", "wait_post", "paid"], 'search_default_my_open_expenses': 1 } @@ -151,4 +151,116 @@ { 'searchpanel_default_state': ["draft", "submitted", "approved", "posted", "wait_post", "paid"] } + + + + hr.expense.sheet.form.inherit.realization + hr.expense.sheet + + + + + state not in ['approve', 'post', 'done', 'cancel', 'wait_post'] + + + state not in ['submit', 'approve', 'post', 'wait_post'] + + + state != 'approve' or payment_mode == 'company_account' + + +