Compare commits

..

16 Commits
17.0 ... 19.0

Author SHA1 Message Date
b6c634d54a docs: fix alignment of header underline in README.rst 2026-05-06 13:45:04 +07:00
35934e75ab docs: convert README from Markdown to reStructuredText format 2026-05-06 11:33:11 +07:00
f667025d92 refactor: update kiosk controller routes to use jsonrpc type instead of json 2026-05-05 15:29:19 +07:00
6fc645230d refactor: use self.env.context instead of self._context to safely retrieve skip_expense_lock flag 2026-05-05 14:53:55 +07:00
3d96ca446b refactor: use env.context instead of self._context to safely retrieve skip_expense_lock flag 2026-05-05 14:42:50 +07:00
15e9c93dad feat: prevent accidental journal reversals for expense payments by hiding the reverse button 2026-04-30 21:53:04 +07:00
819bce0bd0 refactor: simplify expense kiosk configuration view layout and improve button styling 2026-04-30 17:03:40 +07:00
1d41708bfa refactor: reorient payment wizard to operate on individual expenses instead of expense sheets 2026-04-30 16:46:17 +07:00
f2a4c72101 feat: add payment registration wizard for expense sheets and introduce wait_post state for company account reconciliations 2026-04-30 09:32:15 +07:00
58db2572a4 fix: remove limit from hr_expense search in kiosk controller to fetch all expenses 2026-04-21 12:13:42 +07:00
eb17bb8e2a refactor: consolidate expense lock skip logic in account payment write method 2026-04-21 12:07:39 +07:00
07b70336c6 feat: add history view for recent expense submissions to kiosk dashboard 2026-04-21 11:48:23 +07:00
a981456e93 feat: add search and group-by filters to hr.expense.realization view 2026-04-21 11:08:56 +07:00
bfaacbfe7c refactor: migrate Odoo 19 view components, update payment reference fields, and simplify account domain constraints 2026-04-21 10:59:47 +07:00
4a64d2b73c refactor: remove HrExpenseSheet model and consolidate expense status logic into HrExpense and HrExpenseRealization models 2026-04-21 09:23:39 +07:00
8d38d49c85 migrate to odoo 19.0 2026-04-20 10:51:36 +07:00
20 changed files with 390 additions and 658 deletions

View File

@ -1,58 +0,0 @@
# HR Expense Account Split & Kiosk
This module enhances Odoo's standard Expense workflow by providing account-splitting logic, an **Anonymous Expense Kiosk** for employees, and automated realization accounting.
## 🚀 Features
### 1. Dynamic Sequences
- **Employee Reimbursement (RMBS)**: Expenses paid by the employee follow the prefix `RMBS/YYYY/MM/XXXXX`.
* **Company Advance (KSBN)**: Expenses paid by the company (Kasbon) follow the prefix `KSBN/YYYY/MM/XXXXX`.
* This ensures clear separation between standard reimbursements and company-issued advances at a glance.
### 2. Enhanced Status Workflow
- **Wait Post Status (Yellow)**: A new intermediate status for company-paid expense reports.
- **Workflow**: `Approved` -> `Posted` -> **`Wait Post`** -> `Done`.
- **Logic**: The report moves to **Wait Post** after the advance is paid. It stays here until the employee submits all receipts and the accountant posts the final realization journal.
- **Done (Green)**: Only reached when all realization accounting is completed, ensuring the physical and financial cycles are fully synchronized.
### 3. Account Splitting
- **Dynamic Selection**: Automatically routes expenses to different GL accounts based on the `Paid By` field.
- **Configuration**: Set distinct `Expense Account (Employee)` and `Expense Account (Company)` directly on the Expense Category form.
### 4. Anonymous Expense Kiosk
- **PIN-Protected Access**: Secure employee login via a 4-digit PIN on a tablet interface.
- **Real-Time Totaling**: Automatically summarizes multiple physical receipts into a single realization.
- **Image Optimization**: Client-side JPEG compression (1024px, 70% quality) reduces server storage usage by ~90%.
### 5. Automated Realization Accounting
- **Balanced Journal Entries**: Automatically calculates discrepancies between the advance paid and actual spending.
- **Discrepancy Accounts**:
- **Over-spent (Spent > Paid)**: Balance is moved to `216109 Biaya Lain yang masih harus dibayar` (Liability/Payable).
- **Under-spent (Spent < Paid)**: Balance is moved to `114101 Piutang Karyawan` (Receivable).
- **Discrepancy Settlement**:
- **Create Vendor Payment**: One-click button on the realization form to pay the employee the difference.
- **Create Customer Payment**: One-click button on the realization form to record the employee returning the excess funds.
### 6. Custom Payment Wizard
- **Company Advances**: Replaces the standard Odoo "Post Journal Entries" workflow for company-paid expenses with a custom "Register Payment" wizard.
- **Vendor & Date Selection**: Allows the finance team to specify the exact vendor and payment date during the advance payment.
- **Automated Uang Muka Routing**: The wizard automatically forces the payment debit to `118101 Uang Muka Operasional`, ensuring advances are correctly booked as prepaid expenses before receipts are realized.
### 7. Validation & Security
- **Mandatory Receipts**: Prevents submission of employee-paid expenses without attachments.
- **Overdue Tracking**: Highlights missing realization receipts in red/alerts for company-paid expenses.
## 🛠 Configuration
1. **GL Accounts**:
- **Expenses > Configuration > Expense Categories**.
- Define the two accounts under the **Accounting** tab.
2. **Kiosk Token**:
- URL structure: `/hr_expense/kiosk/d56db48c463444c88b86f14980d7a185`.
3. **Employee PINs**:
- Set 4-digit PINs on employee records for Kiosk access.
## 📋 Technical Notes
- **Controller**: `/hr_expense/kiosk/<token>`
- **Models**: `hr.expense`, `hr.expense.sheet`, `hr.expense.realization`, `account.payment`
- **JS Framework**: Odoo 17 OWL

68
README.rst Normal file
View File

@ -0,0 +1,68 @@
==========================================
HR Expense Account Split & Kiosk (Odoo 19)
==========================================
This module enhances Odoo's standard Expense workflow by providing account-splitting logic,
an **Anonymous Expense Kiosk** for employees, and automated realization accounting.
Features
========
1. Dynamic Sequences
--------------------
* **Employee Reimbursement (RMBS)**: Expenses paid by the employee follow the prefix `RMBS/YYYY/MM/XXXXX`.
* **Company Advance (KSBN)**: Expenses paid by the company (Kasbon) follow the prefix `KSBN/YYYY/MM/XXXXX`.
* This ensures clear separation between standard reimbursements and company-issued advances at a glance.
2. Enhanced Status Workflow (Odoo 19 Refined)
---------------------------------------------
* **Wait Post Status (Yellow)**: A new intermediate status for company-paid expense reports.
* **Workflow**: `Approved` -> `Posted` -> **`Wait Post`** -> `Done`.
* **Logic**: The report moves to **Wait Post** after the advance is paid. It stays here until the employee submits all receipts and the accountant posts the final realization journal.
* **Done (Green)**: Only reached when all realization accounting is completed, ensuring the physical and financial cycles are fully synchronized.
* **Architectural Note**: In Odoo 19, the legacy `hr.expense.sheet` logic has been merged directly into the core `hr.expense` model for better performance and consistency.
3. Account Splitting & Kasbon Logic
-----------------------------------
* **Dynamic Selection**: Automatically routes expenses to different GL accounts based on the `Paid By` field.
* **Configuration**: Set distinct `Expense Account (Employee)` and `Expense Account (Company)` directly on the Expense Category form.
* **Advance Account**: Company-paid advances (Kasbon) use account **115101** for the debit side of the initial payment journal entry.
4. Journal Entry Safeguards
---------------------------
* **Reversal Protection**: The **Reverse Entry** button is automatically hidden on journal entries linked to expenses. This prevents accidental reversals that would cause the accounting ledger to fall out of sync with the expense status.
5. Anonymous Expense Kiosk
--------------------------
* **PIN-Protected Access**: Secure employee login via a 4-digit PIN on a tablet interface.
* **Real-Time Totaling**: Automatically summarizes multiple physical receipts into a single realization.
* **Image Optimization**: Client-side JPEG compression (1024px, 70% quality) reduces server storage usage by ~90%.
6. Automated Realization Accounting
-----------------------------------
* **Balanced Journal Entries**: Automatically calculates discrepancies between the advance paid and actual spending.
* **Discrepancy Accounts**:
* **Over-spent (Spent > Paid)**: Balance is moved to `216109 Biaya Lain yang masih harus dibayar` (Liability/Payable).
* **Under-spent (Spent < Paid)**: Balance is moved to `114101 Piutang Karyawan` (Receivable).
* **Discrepancy Settlement**:
* **Create Vendor Payment**: One-click button on the realization form to pay the employee the difference.
* **Create Customer Payment**: One-click button on the realization form to record the employee returning the excess funds.
Configuration
=============
1. **GL Accounts**:
* **Expenses > Configuration > Expense Categories**.
* Define the two accounts under the **Accounting** tab.
2. **Kiosk Activation**:
* Go to **Expenses > Configuration > Settings**.
* The **Kiosk URL** is displayed in the "Expense Kiosk" block. You can regenerate the token here if needed.
3. **Employee PINs**:
* Set 4-digit PINs on employee records for Kiosk access.
Technical Notes
===============
* **Odoo Version**: 19.0
* **Controller**: `/hr_expense/kiosk/<token>`
* **Models**: `hr.expense`, `hr.expense.realization`, `account.move`, `account.payment`
* **JS Framework**: Odoo 19 OWL

View File

@ -1,6 +1,6 @@
{
'name': 'HR Expense: Split Account by Payment Mode',
'version': '17.0.1.0.1',
'version': '19.0.1.0.1',
'summary': 'Set different expense accounts for Employee paid vs Company paid expenses on Expense Categories.',
'category': 'Human Resources/Expenses',
'author': 'Suherdy Yacob',
@ -10,8 +10,8 @@
'data/ir_sequence_data.xml',
'views/product_views.xml',
'views/hr_expense_views.xml',
'views/hr_expense_realization_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',
'views/account_move_views.xml',

View File

@ -28,7 +28,7 @@ class HrExpenseKioskController(http.Controller):
'json': json,
})
@http.route('/hr_expense/kiosk_data/<string:token>', type='json', auth='public')
@http.route('/hr_expense/kiosk_data/<string:token>', type='jsonrpc', auth='public')
def get_kiosk_data(self, token):
""" Get all employees for selection. """
company = self._check_token(token)
@ -57,7 +57,7 @@ class HrExpenseKioskController(http.Controller):
'categories': products,
}
@http.route('/hr_expense/kiosk_validate_pin/<string:token>', type='json', auth='public')
@http.route('/hr_expense/kiosk_validate_pin/<string:token>', type='jsonrpc', auth='public')
def validate_pin(self, token, employee_id, pin):
""" Validates the 4-digit PIN of the employee. """
if not self._check_token(token):
@ -72,43 +72,45 @@ class HrExpenseKioskController(http.Controller):
else:
return {'status': 'error', 'message': _("Incorrect PIN.")}
@http.route('/hr_expense/kiosk_get_pending/<string:token>', type='json', auth='public')
@http.route('/hr_expense/kiosk_get_pending/<string:token>', type='jsonrpc', auth='public')
def get_pending(self, token, employee_id):
""" Returns pending realizations for the employee. """
if not self._check_token(token):
return []
return request.env['hr.expense.realization'].sudo().get_pending_realizations(employee_id)
@http.route('/hr_expense/kiosk_get_submitted/<string:token>', type='json', auth='public')
@http.route('/hr_expense/kiosk_get_submitted/<string:token>', type='jsonrpc', auth='public')
def get_submitted(self, token, employee_id):
""" Returns submitted expense reports for the employee. """
""" Returns submitted expenses for the employee. """
if not self._check_token(token):
return []
sheets = request.env['hr.expense.sheet'].sudo().search([
expenses = request.env['hr.expense'].sudo().search([
('employee_id', '=', employee_id),
('state', 'not in', ['draft', 'cancel'])
], order='create_date desc')
('state', 'not in', ['draft', 'refused'])
], order='date desc, id desc')
result = []
state_selection = dict(request.env['hr.expense.sheet']._fields['state']._description_selection(request.env))
payment_selection = dict(request.env['hr.expense.sheet']._fields['payment_state']._description_selection(request.env))
state_selection = dict(request.env['hr.expense']._fields['state']._description_selection(request.env))
# Get payment state labels from account.move if possible
payment_selection = dict(request.env['account.move']._fields['payment_state']._description_selection(request.env))
for sheet in sheets:
for exp in expenses:
payment_state = exp.account_move_id.payment_state if exp.account_move_id else 'not_paid'
result.append({
'id': sheet.id,
'name': sheet.name,
'sequences': sheet.expense_sequences or '',
'date': sheet.create_date.strftime('%Y-%m-%d'),
'total_amount': sheet.currency_id.symbol + " " + "{:,.2f}".format(sheet.total_amount),
'state': state_selection.get(sheet.state),
'state_raw': sheet.state,
'payment_status': payment_selection.get(sheet.payment_state),
'payment_state_raw': sheet.payment_state,
'id': exp.id,
'name': exp.name,
'sequences': exp.sequence_name or '',
'date': exp.date.strftime('%Y-%m-%d') if exp.date else '',
'total_amount': exp.currency_id.symbol + " " + "{:,.2f}".format(exp.total_amount),
'state': state_selection.get(exp.state),
'state_raw': exp.state,
'payment_status': payment_selection.get(payment_state, _("Not Paid")),
'payment_state_raw': payment_state,
})
return result
@http.route('/hr_expense/kiosk_submit_realization/<string:token>', type='json', auth='public')
@http.route('/hr_expense/kiosk_submit_realization/<string:token>', type='jsonrpc', auth='public')
def submit_realization(self, token, employee_id, expense_id, lines=None):
""" Creates a realization report from the kiosk. """
if not self._check_token(token):
@ -144,7 +146,7 @@ class HrExpenseKioskController(http.Controller):
except Exception as e:
return {'status': 'error', 'message': str(e)}
@http.route('/hr_expense/kiosk_submit_new_expense/<string:token>', type='json', auth='public')
@http.route('/hr_expense/kiosk_submit_new_expense/<string:token>', type='jsonrpc', auth='public')
def submit_new_expense(self, token, employee_id, product_id, amount, description, payment_mode=None, image_base64=None):
""" Creates a new expense (e.g. out of pocket) from the kiosk. """
if not self._check_token(token):
@ -182,9 +184,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:

View File

@ -1,10 +1,9 @@
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
from . import account_payment
from . import res_company
from . import res_config_settings
from . import hr_expense_payment_wizard

View File

@ -9,10 +9,10 @@ class AccountMove(models.Model):
store=True,
)
@api.depends('payment_id.expense_sheet_id')
@api.depends('expense_ids')
def _compute_is_expense_payment(self):
for move in self:
move.is_expense_payment = bool(move.payment_id and move.payment_id.expense_sheet_id)
move.is_expense_payment = bool(move.sudo().expense_ids)
def _get_hr_expense_base_class(self):
""" Returns the hr_expense class in the MRO to jump over it. """
@ -22,6 +22,6 @@ class AccountMove(models.Model):
def write(self, vals):
# Surgical Jumper to bypass hr_expense's account.move lock
hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class and self._context.get('skip_expense_lock'):
if hr_expense_class and self.env.context.get('skip_expense_lock'):
return super(hr_expense_class, self).write(vals)
return super().write(vals)

View File

@ -28,7 +28,7 @@ class AccountMoveLine(models.Model):
# 2. Surgical Jumper to bypass hr_expense's account.move.line lock
hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class and self._context.get('skip_expense_lock'):
if hr_expense_class and self.env.context.get('skip_expense_lock'):
return super(hr_expense_class, self).write(vals)
return super().write(vals)
@ -36,6 +36,6 @@ class AccountMoveLine(models.Model):
def unlink(self):
# Surgical Jumper to bypass hr_expense's account.move.line lock
hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class and self._context.get('skip_expense_lock'):
if hr_expense_class and self.env.context.get('skip_expense_lock'):
return super(hr_expense_class, self).unlink()
return super().unlink()

View File

@ -42,7 +42,7 @@ class AccountPayment(models.Model):
# Triple Jumper: If we have the bypass flag, we jump over the hr_expense method.
# This works in tandem with our Model-level jumpers in account_move and account_move_line.
hr_expense_class = self._get_hr_expense_base_class()
if self._context.get('skip_expense_lock') and hr_expense_class:
if self.env.context.get('skip_expense_lock') and hr_expense_class:
super(hr_expense_class, self)._synchronize_to_moves(changed_fields)
else:
super()._synchronize_to_moves(changed_fields)
@ -56,7 +56,7 @@ class AccountPayment(models.Model):
def _synchronize_from_moves(self, changed_fields):
# 1. Standard sync with jumper support
hr_expense_class = self._get_hr_expense_base_class()
if self._context.get('skip_expense_lock') and hr_expense_class:
if self.env.context.get('skip_expense_lock') and hr_expense_class:
super(hr_expense_class, self)._synchronize_from_moves(changed_fields)
else:
super()._synchronize_from_moves(changed_fields)
@ -88,7 +88,7 @@ class AccountPayment(models.Model):
# Propagate bypass flag during writes to avoid locked checks
hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class:
if self._context.get('skip_expense_lock') or any(p.expense_sheet_id and p.state == 'draft' for p in self):
if self.env.context.get('skip_expense_lock') or any(p.expense_ids and p.state == 'draft' for p in self):
return super(hr_expense_class, self.with_context(skip_expense_lock=True)).write(vals)
return super().write(vals)
@ -102,10 +102,10 @@ class AccountPayment(models.Model):
res = super().action_cancel()
for payment in self:
if payment.expense_sheet_id:
payment.expense_sheet_id.invalidate_recordset(['state'])
if payment.realization_id and payment.realization_id.expense_sheet_id:
payment.realization_id.expense_sheet_id.invalidate_recordset(['state'])
if payment.expense_ids:
payment.expense_ids.invalidate_recordset(['state'])
if payment.realization_id and payment.realization_id.expense_id:
payment.realization_id.expense_id.invalidate_recordset(['state'])
return res
def action_draft(self):
@ -117,10 +117,10 @@ class AccountPayment(models.Model):
res = super().action_draft()
for payment in self:
if payment.expense_sheet_id:
payment.expense_sheet_id.invalidate_recordset(['state'])
if payment.realization_id and payment.realization_id.expense_sheet_id:
payment.realization_id.expense_sheet_id.invalidate_recordset(['state'])
if payment.expense_ids:
payment.expense_ids.invalidate_recordset(['state'])
if payment.realization_id and payment.realization_id.expense_id:
payment.realization_id.expense_id.invalidate_recordset(['state'])
return res
def _seek_for_lines(self):
@ -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.

View File

@ -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,69 @@ 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.origin_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()
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,
}
}

View File

@ -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)
amount = fields.Monetary(string='Payment Amount', required=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')
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_id.currency_id')
company_id = fields.Many2one('res.company', related='expense_id.company_id')
partner_id = fields.Many2one(
'res.partner',
@ -38,18 +38,19 @@ 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()
# Find 118101 account
# Find 115101 account (Uang Muka Operasional)
uang_muka_account = self.env['account.account'].search([
('code', '=', '118101'),
('code', '=', '115101'),
('company_id', '=', self.company_id.id)
], limit=1)
if not uang_muka_account:
raise UserError(_("Account 118101 Uang Muka Operasional not found for this company!"))
raise UserError(_("Account 115101 Uang Muka Operasional not found for this company!"))
payment_vals = {
'date': self.payment_date,
@ -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_id.ids)],
}
payment = self.env['account.payment'].create(payment_vals)
payment.action_post()
# Link the payment's move to the expense sheet
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',
'amount_residual': 0.0,
'payment_state': 'paid'
})
# 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'}

View File

@ -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()
@ -109,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:
@ -155,12 +154,7 @@ class HrExpenseRealization(models.Model):
# Credit for Advance clearing
# We clear the remaining advance amount in the first realization for an expense.
# Use proportional share of actual amount paid if payment was different from requested total
sheet = self.expense_id.sheet_id
if sheet.total_amount:
original_paid = self.expense_id.total_amount * (sheet.amount_paid / sheet.total_amount)
else:
original_paid = 0.0
original_paid = self.expense_id.total_amount
# Find already cleared advance amount from previous posted moves targeting this expense and advance account
cleared_before = sum(self.env['account.move.line'].search([
@ -274,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']
)

View File

@ -1,290 +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', 'account_move_ids.payment_id.amount', 'account_move_ids.payment_id.state', '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)
# Direct payments linked to moves (handles non-reconciled company advances)
for move in sheet.account_move_ids:
if move.payment_id and move.payment_id.state not in ('draft', 'cancel'):
if move.payment_id.id not in seen_payment_ids:
total_paid += move.payment_id.amount
seen_payment_ids.add(move.payment_id.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 sheet.amount_paid < sheet.total_amount:
sheet.payment_state = 'partial'
elif unmatched_payments:
sheet.payment_state = 'in_payment'
else:
sheet.payment_state = 'paid'
sheet.amount_residual = max(0.0, sheet.total_amount - sheet.amount_paid)
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()
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,
}
}

View File

@ -7,14 +7,14 @@ class ProductTemplate(models.Model):
'account.account',
string="Expense Account (Employee)",
company_dependent=True,
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card')), ('deprecated', '=', False)]",
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card'))]",
help="Account used for expenses paid by the employee (to be reimbursed)."
)
property_account_expense_company_id = fields.Many2one(
'account.account',
string="Expense Account (Company)",
company_dependent=True,
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card')), ('deprecated', '=', False)]",
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card'))]",
help="Account used for expenses paid by the company."
)
receipt_due_days = fields.Integer(

View File

@ -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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_hr_expense_realization_user hr.expense.realization model_hr_expense_realization hr_expense.group_hr_expense_user 1 1 1 0
3 access_hr_expense_realization_manager hr.expense.realization model_hr_expense_realization hr_expense.group_hr_expense_manager 1 1 1 1
4 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
5 access_hr_expense_payment_wizard hr.expense.payment.wizard model_hr_expense_payment_wizard base.group_user 1 1 1 1

View File

@ -64,7 +64,7 @@
</div>
<!-- ACTION SELECTION -->
<div t-if="state.screen === 'action_selection'" class="d-flex flex-column align-items-center animate-fade-in py-5">
<div t-if="state.screen === 'action_selection'" class="d-flex flex-column align-items-center animate-fade-in h-100 justify-content-center">
<h3 class="mb-5">What would you like to do?</h3>
<div class="d-flex gap-5">
<div class="action-card card text-center p-5 shadow border-0 cursor-pointer rounded-4 hover-lift" t-on-click="() => this.selectAction('realization')" style="width: 250px;">
@ -80,45 +80,42 @@
</div>
</div>
<!-- SUBMITTED EXPENSES LIST -->
<div t-if="state.submittedExpenses.length > 0" class="mt-5 w-100 animate-fade-in" style="max-width: 800px;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="m-0 text-dark">Your Recent Submissions</h4>
<span class="badge bg-secondary" t-esc="state.submittedExpenses.length"/>
</div>
<div class="card shadow-sm border-0 overflow-hidden">
<div class="table-responsive">
<!-- Recent Submissions List -->
<div class="mt-5 w-100 px-3" t-if="state.submittedExpenses.length > 0">
<h4 class="mb-3 text-start"><i class="fa fa-history me-2"></i>Your Recent Submissions</h4>
<div class="table-responsive rounded shadow-sm bg-white">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<thead class="table-light">
<tr>
<th class="ps-4">Report Name</th>
<th>Expense Sequences</th>
<th class="ps-3">Reference / Description</th>
<th>Date</th>
<th>Total</th>
<th class="text-center">Report Status</th>
<th class="text-center">Payment Status</th>
<th class="text-end">Total</th>
<th class="text-center">Status</th>
<th class="text-center pe-3">Payment</th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.submittedExpenses" t-as="sheet" t-key="sheet.id">
<td class="ps-4">
<div class="fw-bold" t-esc="sheet.name"/>
<t t-foreach="state.submittedExpenses" t-as="exp" t-key="exp.id">
<tr>
<td class="ps-3">
<div class="fw-bold text-primary" t-esc="exp.sequences"/>
<div class="small text-muted" t-esc="exp.name"/>
</td>
<td>
<small class="text-muted" t-esc="sheet.sequences"/>
</td>
<td class="text-muted" t-esc="sheet.date"/>
<td class="fw-bold text-primary" t-esc="sheet.total_amount"/>
<td t-esc="exp.date"/>
<td class="text-end fw-bold" t-esc="exp.total_amount"/>
<td class="text-center">
<span t-attf-class="badge rounded-pill #{sheet.state_raw === 'approve' ? 'bg-info' : (sheet.state_raw === 'post' ? 'bg-primary' : (sheet.state_raw === 'done' ? 'bg-success' : (sheet.state_raw === 'cancel' ? 'bg-danger' : 'bg-warning')))}" t-esc="sheet.state"/>
<span t-attf-class="badge rounded-pill #{['approved', 'paid', 'posted'].includes(exp.state_raw) ? 'bg-success' : (['submitted', 'reported'].includes(exp.state_raw) ? 'bg-info' : (exp.state_raw === 'refused' ? 'bg-danger' : 'bg-warning'))}" t-esc="exp.state"/>
</td>
<td class="text-center">
<span t-attf-class="badge rounded-pill #{sheet.payment_state_raw === 'paid' ? 'bg-success' : (sheet.payment_state_raw === 'in_payment' ? 'bg-info' : (sheet.payment_state_raw === 'not_paid' ? 'bg-secondary' : 'bg-warning'))}" t-esc="sheet.payment_status"/>
<td class="text-center pe-3">
<span t-attf-class="badge rounded-pill #{exp.payment_state_raw === 'paid' ? 'bg-success' : (exp.payment_state_raw === 'in_payment' ? 'bg-info' : (exp.payment_state_raw === 'not_paid' ? 'bg-secondary' : 'bg-warning'))}" t-esc="exp.payment_status"/>
</td>
</tr>
</t>
</tbody>
</table>
</div>
<div class="text-muted small mt-2 text-start">
<i class="fa fa-info-circle me-1"></i> Showing your last 15 submissions.
</div>
</div>
</div>

View File

@ -1,29 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_hr_expense_payment_wizard_form" model="ir.ui.view">
<field name="name">hr.expense.payment.wizard.form</field>
<record id="hr_expense_payment_wizard_view_form" model="ir.ui.view">
<field name="name">hr.expense.payment.wizard.view.form</field>
<field name="model">hr.expense.payment.wizard</field>
<field name="arch" type="xml">
<form string="Register Payment">
<sheet>
<group>
<group>
<field name="partner_id" widget="res_partner_many2one" context="{'res_partner_search_mode': 'supplier'}"/>
<field name="journal_id" options="{'no_create': True}"/>
<field name="payment_method_line_id" options="{'no_create': True, 'no_open': True}"/>
</group>
<group>
<field name="amount" widget="monetary" options="{'currency_field': 'currency_id'}"/>
<field name="payment_date"/>
<field name="expense_id" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="currency_id" invisible="1"/>
<field name="expense_sheet_id" invisible="1"/>
<field name="partner_id" widget="res_partner_many2one" context="{'res_partner_search_mode': 'supplier'}"/>
<field name="journal_id" widget="selection"/>
<field name="payment_method_line_id" widget="selection"/>
</group>
<group>
<field name="payment_date"/>
<field name="amount" widget="monetary" options="{'currency_field': 'currency_id'}"/>
</group>
</group>
</sheet>
<footer>
<button string="Create Payment" name="action_create_payment" type="object" class="oe_highlight" data-hotkey="q"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="z"/>
<button name="action_create_payment" string="Create Payment" type="object" class="oe_highlight" data-hotkey="q"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
<record id="action_hr_expense_payment_wizard" model="ir.actions.act_window">
<field name="name">Register Payment</field>
<field name="res_model">hr.expense.payment.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@ -5,14 +5,14 @@
<field name="name">hr.expense.realization.view.tree</field>
<field name="model">hr.expense.realization</field>
<field name="arch" type="xml">
<tree string="Expense Realization">
<list string="Expense Realization">
<field name="name"/>
<field name="date"/>
<field name="employee_id"/>
<field name="expense_id"/>
<field name="total_amount" sum="Total Amount"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-warning="state == 'confirmed'" decoration-success="state == 'posted'"/>
</tree>
</list>
</field>
</record>
@ -90,14 +90,14 @@
<notebook>
<page string="Receipts" name="receipt_lines">
<field name="line_ids" readonly="state == 'posted'">
<tree editable="bottom">
<list editable="bottom">
<field name="description"/>
<field name="amount" sum="Total"/>
<field name="attachment_id" filename="attachment_name" widget="binary"/>
<field name="attachment_name" column_invisible="1"/>
<field name="counterpart_account_id" groups="account.group_account_invoice" required="parent.state == 'confirmed'"/>
<field name="move_id" groups="account.group_account_invoice" widget="many2one_clickable" readonly="1" invisible="not move_id"/>
</tree>
</list>
</field>
</page>
<page string="Notes" name="notes">
@ -115,8 +115,8 @@
</record>
<!-- Realization Search View -->
<record id="hr_expense_realization_view_search" model="ir.ui.view">
<field name="name">hr.expense.realization.view.search</field>
<record id="hr_expense_realization_search_view" model="ir.ui.view">
<field name="name">hr.expense.realization.search</field>
<field name="model">hr.expense.realization</field>
<field name="arch" type="xml">
<search string="Search Realization">
@ -126,8 +126,10 @@
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
<filter string="Posted" name="posted" domain="[('state', '=', 'posted')]"/>
<group expand="0" string="Group By">
<separator/>
<group name="group_by">
<filter string="Employee" name="group_employee" context="{'group_by': 'employee_id'}"/>
<filter string="Expense" name="group_expense_id" context="{'group_by': 'expense_id'}"/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'date'}"/>
</group>
@ -139,8 +141,8 @@
<record id="action_hr_expense_realization" model="ir.actions.act_window">
<field name="name">Realization Report</field>
<field name="res_model">hr.expense.realization</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="hr_expense_realization_view_search"/>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="hr_expense_realization_search_view"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new Realization Report
@ -152,23 +154,9 @@
</record>
<!-- 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"
name="Realization Report"
parent="menu_expense_reports_parent"
parent="hr_expense.menu_hr_expense_root"
action="action_hr_expense_realization"
sequence="2"/>
sequence="3"/>
</odoo>

View File

@ -7,31 +7,29 @@
<field name="inherit_id" ref="hr_expense.hr_expense_view_form"/>
<field name="priority">1000</field>
<field name="arch" type="xml">
<!-- Remove all standard Submit Buttons -->
<xpath expr="(//header//button[@name='action_submit_expenses'])[1]" position="replace"/>
<xpath expr="(//header//button[@name='action_submit_expenses'])[1]" position="replace"/>
<!-- 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 &lt; 1 and payment_mode == 'own_account'"
invisible="sheet_id"/>
<!-- Update header: Add wait_post to statusbar -->
<xpath expr="//field[@name='state'][@statusbar_visible]" position="attributes">
<attribute name="statusbar_visible">draft,submitted,approved,posted,wait_post,paid</attribute>
</xpath>
<xpath expr="//header//button[@name='action_post']" position="attributes">
<attribute name="invisible">state != 'approved' or payment_mode == 'company_account'</attribute>
</xpath>
<xpath expr="//header//button[@name='action_post']" position="after">
<button name="action_open_payment_wizard"
string="Register Payment"
type="object"
data-hotkey="y"
class="oe_highlight o_expense_sheet_post"
invisible="state != 'approved' or payment_mode != 'company_account'"
groups="account.group_account_invoice"/>
</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">
<div class="oe_button_box" name="button_box">
<button name="action_view_realizations"
@ -47,7 +45,7 @@
type="object"
class="oe_stat_button"
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>
</xpath>
<xpath expr="//sheet" position="before">
@ -67,6 +65,11 @@
<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_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>
</field>
</record>
@ -80,10 +83,19 @@
<xpath expr="//field[@name='name']" position="before">
<field name="sequence_name" optional="show"/>
</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">
<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'"/>
</xpath>
<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_received" optional="show" widget="boolean_toggle"/>
<field name="receipt_overdue" column_invisible="True"/>
@ -97,14 +109,16 @@
<field name="model">hr.expense</field>
<field name="inherit_id" ref="hr_expense.hr_expense_view_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='no_report']" position="after">
<xpath expr="//filter[@name='my_open_expenses']" position="after">
<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="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="Pending Receipts" name="filter_receipt_pending" domain="[('receipt_status', '=', 'pending')]"/>
</xpath>
<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'}"/>
</xpath>
</field>
@ -113,7 +127,7 @@
<!-- Report Action: Overdue Receipts -->
<record id="action_hr_expense_overdue_receipts" model="ir.actions.act_window">
<field name="name">Overdue Receipts</field>
<field name="res_model">hr.expense</field>
<field name="res_model" >hr.expense</field>
<field name="view_mode">list,pivot,form</field>
<field name="domain">[('receipt_overdue', '=', True), ('receipt_received', '=', False)]</field>
<field name="context">{'search_default_group_receipt_due': 1}</field>
@ -131,119 +145,14 @@
parent="hr_expense.menu_hr_expense_reports"
action="action_hr_expense_overdue_receipts"
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="//header//button[@name='action_sheet_move_create']" position="attributes">
<attribute name="invisible">state != 'approve' or payment_mode == 'company_account'</attribute>
</xpath>
<xpath expr="//header//button[@name='action_sheet_move_create']" position="after">
<button name="action_open_payment_wizard"
string="Register Payment"
type="object"
data-hotkey="y"
class="oe_highlight o_expense_sheet_post"
invisible="state != 'approve' or payment_mode != 'company_account'"
groups="account.group_account_invoice"/>
</xpath>
<xpath expr="//field[@name='state']" position="attributes">
<attribute name="statusbar_visible">draft,submit,approve,post,wait_post,done</attribute>
</xpath>
<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>
<!-- Inherit Expense Report Tree View -->
<record id="view_hr_expense_sheet_tree_inherit_receipt" model="ir.ui.view">
<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 id="hr_expense.hr_expense_actions_my_all" model="ir.actions.act_window">
<field name="context">{ 'searchpanel_default_state': ["draft", "submitted", "approved", "posted", "wait_post", "paid"], 'search_default_my_open_expenses': 1 }</field>
</record>
<record id="hr_expense.hr_expense_actions_all" model="ir.actions.act_window">
<field name="context">{ 'searchpanel_default_state': ["draft", "submitted", "approved", "posted", "wait_post", "paid"] }</field>
</record>
</odoo>

View File

@ -23,7 +23,7 @@
<field name="model">product.template</field>
<field name="inherit_id" ref="account.product_template_form_view"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='payables']" position="inside">
<xpath expr="//field[@name='property_account_expense_id']" position="after">
<field name="property_account_expense_employee_id" invisible="not can_be_expensed"/>
<field name="property_account_expense_company_id" invisible="not can_be_expensed"/>
<field name="receipt_due_days" invisible="not can_be_expensed"/>

View File

@ -6,20 +6,11 @@
<field name="inherit_id" ref="hr_expense.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@name='expenses_setting_container']" position="after">
<block title="Expense Kiosk" id="expense_kiosk_settings">
<setting title="Separate URL for Expense Kiosk" help="No login required for employees. Submissions still require PIN.">
<div class="content-group">
<div class="mt16">
<label for="expense_kiosk_url" string="Kiosk URL" class="col-lg-3 o_light_label"/>
<field name="expense_kiosk_url" widget="url" readonly="1" class="oe_inline"/>
</div>
<div class="mt8">
<button name="action_regenerate_expense_kiosk_key"
type="object"
string="Regenerate Token"
class="btn btn-secondary btn-sm"
confirm="Are you sure you want to regenerate the kiosk token? All existing kiosk links will stop working."/>
</div>
<block title="Expense Kiosk" name="expense_kiosk_settings">
<setting string="Kiosk URL" help="Separate URL for Expense Kiosk. No login required for employees. Submissions still require PIN.">
<field name="expense_kiosk_url" widget="url" readonly="1"/>
<div class="mt-2">
<button name="action_regenerate_expense_kiosk_key" type="object" string="Regenerate Token" class="btn btn-link" icon="fa-refresh" confirm="Are you sure you want to regenerate the kiosk token? All existing kiosk links will stop working."/>
</div>
</setting>
</block>