Compare commits

..

6 Commits
19.0 ... 17.0

20 changed files with 656 additions and 388 deletions

58
README.md Normal file
View File

@ -0,0 +1,58 @@
# 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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
from . import product_template from . import product_template
from . import hr_expense from . import hr_expense
from . import hr_expense_payment_wizard from . import hr_expense_sheet
from . import account_move_line from . import account_move_line
from . import account_move from . import account_move
from . import hr_expense_realization from . import hr_expense_realization
from . import account_payment from . import account_payment
from . import res_company from . import res_company
from . import res_config_settings from . import res_config_settings
from . import hr_expense_payment_wizard

View File

@ -9,10 +9,10 @@ class AccountMove(models.Model):
store=True, store=True,
) )
@api.depends('expense_ids') @api.depends('payment_id.expense_sheet_id')
def _compute_is_expense_payment(self): def _compute_is_expense_payment(self):
for move in self: for move in self:
move.is_expense_payment = bool(move.sudo().expense_ids) move.is_expense_payment = bool(move.payment_id and move.payment_id.expense_sheet_id)
def _get_hr_expense_base_class(self): def _get_hr_expense_base_class(self):
""" Returns the hr_expense class in the MRO to jump over it. """ """ Returns the hr_expense class in the MRO to jump over it. """
@ -22,6 +22,6 @@ class AccountMove(models.Model):
def write(self, vals): def write(self, vals):
# Surgical Jumper to bypass hr_expense's account.move lock # Surgical Jumper to bypass hr_expense's account.move lock
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class and self.env.context.get('skip_expense_lock'): if hr_expense_class and self._context.get('skip_expense_lock'):
return super(hr_expense_class, self).write(vals) return super(hr_expense_class, self).write(vals)
return super().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 # 2. Surgical Jumper to bypass hr_expense's account.move.line lock
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class and self.env.context.get('skip_expense_lock'): if hr_expense_class and self._context.get('skip_expense_lock'):
return super(hr_expense_class, self).write(vals) return super(hr_expense_class, self).write(vals)
return super().write(vals) return super().write(vals)
@ -36,6 +36,6 @@ class AccountMoveLine(models.Model):
def unlink(self): def unlink(self):
# Surgical Jumper to bypass hr_expense's account.move.line lock # Surgical Jumper to bypass hr_expense's account.move.line lock
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class and self.env.context.get('skip_expense_lock'): if hr_expense_class and self._context.get('skip_expense_lock'):
return super(hr_expense_class, self).unlink() return super(hr_expense_class, self).unlink()
return super().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. # 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. # 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() hr_expense_class = self._get_hr_expense_base_class()
if self.env.context.get('skip_expense_lock') and hr_expense_class: if self._context.get('skip_expense_lock') and hr_expense_class:
super(hr_expense_class, self)._synchronize_to_moves(changed_fields) super(hr_expense_class, self)._synchronize_to_moves(changed_fields)
else: else:
super()._synchronize_to_moves(changed_fields) super()._synchronize_to_moves(changed_fields)
@ -56,7 +56,7 @@ class AccountPayment(models.Model):
def _synchronize_from_moves(self, changed_fields): def _synchronize_from_moves(self, changed_fields):
# 1. Standard sync with jumper support # 1. Standard sync with jumper support
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if self.env.context.get('skip_expense_lock') and hr_expense_class: if self._context.get('skip_expense_lock') and hr_expense_class:
super(hr_expense_class, self)._synchronize_from_moves(changed_fields) super(hr_expense_class, self)._synchronize_from_moves(changed_fields)
else: else:
super()._synchronize_from_moves(changed_fields) super()._synchronize_from_moves(changed_fields)
@ -88,7 +88,7 @@ class AccountPayment(models.Model):
# Propagate bypass flag during writes to avoid locked checks # Propagate bypass flag during writes to avoid locked checks
hr_expense_class = self._get_hr_expense_base_class() hr_expense_class = self._get_hr_expense_base_class()
if hr_expense_class: if hr_expense_class:
if self.env.context.get('skip_expense_lock') or any(p.expense_ids and p.state == 'draft' for p in self): if self._context.get('skip_expense_lock') or any(p.expense_sheet_id and p.state == 'draft' for p in self):
return super(hr_expense_class, self.with_context(skip_expense_lock=True)).write(vals) return super(hr_expense_class, self.with_context(skip_expense_lock=True)).write(vals)
return super().write(vals) return super().write(vals)
@ -102,10 +102,10 @@ class AccountPayment(models.Model):
res = super().action_cancel() res = super().action_cancel()
for payment in self: for payment in self:
if payment.expense_ids: if payment.expense_sheet_id:
payment.expense_ids.invalidate_recordset(['state']) payment.expense_sheet_id.invalidate_recordset(['state'])
if payment.realization_id and payment.realization_id.expense_id: if payment.realization_id and payment.realization_id.expense_sheet_id:
payment.realization_id.expense_id.invalidate_recordset(['state']) payment.realization_id.expense_sheet_id.invalidate_recordset(['state'])
return res return res
def action_draft(self): def action_draft(self):
@ -117,10 +117,10 @@ class AccountPayment(models.Model):
res = super().action_draft() res = super().action_draft()
for payment in self: for payment in self:
if payment.expense_ids: if payment.expense_sheet_id:
payment.expense_ids.invalidate_recordset(['state']) payment.expense_sheet_id.invalidate_recordset(['state'])
if payment.realization_id and payment.realization_id.expense_id: if payment.realization_id and payment.realization_id.expense_sheet_id:
payment.realization_id.expense_id.invalidate_recordset(['state']) payment.realization_id.expense_sheet_id.invalidate_recordset(['state'])
return res return res
def _seek_for_lines(self): def _seek_for_lines(self):
@ -128,7 +128,7 @@ class AccountPayment(models.Model):
Override _seek_for_lines to explicitly force the destination_account_id Override _seek_for_lines to explicitly force the destination_account_id
to be recognized as the counterpart_line. to be recognized as the counterpart_line.
Native Odoo sometimes misclassifies the advance/expense account (e.g. 115101) Native Odoo sometimes misclassifies the advance/expense account (e.g. 118101)
as a write-off line if it's not strictly a receivable/payable account type. 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, Simultaneously, if a tax deduction account (e.g. 217103 PPh 23) is a payable account type,
Odoo erroneously classifies the deduction as the counterpart. Odoo erroneously classifies the deduction as the counterpart.

View File

@ -1,16 +1,10 @@
from odoo import api, fields, models, _, Command from odoo import api, fields, models, _
from datetime import timedelta
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_round from odoo.tools import float_round
from odoo.tools.misc import clean_context
class HrExpense(models.Model): class HrExpense(models.Model):
_inherit = 'hr.expense' _inherit = 'hr.expense'
state = fields.Selection(selection_add=[
('wait_post', 'Wait Post')
], ondelete={'wait_post': 'set default'})
@api.depends('product_id', 'company_id', 'payment_mode') @api.depends('product_id', 'company_id', 'payment_mode')
def _compute_account_id(self): def _compute_account_id(self):
super()._compute_account_id() super()._compute_account_id()
@ -57,20 +51,6 @@ class HrExpense(models.Model):
realization_ids = fields.One2many('hr.expense.realization', 'expense_id', string='Realizations') realization_ids = fields.One2many('hr.expense.realization', 'expense_id', string='Realizations')
realization_count = fields.Integer(string='Realization Count', compute='_compute_realization_count') realization_count = fields.Integer(string='Realization Count', compute='_compute_realization_count')
receipt_status = fields.Selection([
('pending', 'Pending Receipts'),
('received', 'Receipts Received'),
('none', 'No Receipt Required')
], string='Receipt Status', compute='_compute_receipt_status', store=True, tracking=True)
amount_paid = fields.Monetary(
string='Payment Total',
compute='_compute_amount_paid',
currency_field='currency_id',
store=True,
help="Total amount paid by the finance team."
)
@api.depends('realization_ids') @api.depends('realization_ids')
def _compute_realization_count(self): def _compute_realization_count(self):
for expense in self: for expense in self:
@ -81,52 +61,6 @@ class HrExpense(models.Model):
for expense in self: for expense in self:
expense.realization_total_amount = sum(expense.realization_ids.mapped('total_amount')) expense.realization_total_amount = sum(expense.realization_ids.mapped('total_amount'))
@api.depends('receipt_received', 'payment_mode')
def _compute_receipt_status(self):
for expense in self:
if expense.payment_mode != 'company_account':
expense.receipt_status = 'none'
elif expense.receipt_received:
expense.receipt_status = 'received'
else:
expense.receipt_status = 'pending'
@api.depends('account_move_id.payment_state', 'total_amount')
def _compute_amount_paid(self):
for expense in self:
total_paid = 0.0
if expense.account_move_id:
move = expense.account_move_id
if move.payment_state in ('paid', 'in_payment'):
total_paid = expense.total_amount
expense.amount_paid = total_paid
@api.depends('account_move_id.state', 'receipt_received', 'realization_ids.state', 'approval_state')
def _compute_state(self):
# Store original states to detect transition to 'paid'
original_states = {expense.id: expense.state for expense in self}
super()._compute_state()
for expense in self:
# Check for Company Account expenses
if expense.payment_mode == 'company_account':
# If Odoo thought it was 'paid' (fully or partially paid/in_payment),
# we may need to hold it at 'wait_post' until realization is complete.
if expense.state == 'paid':
realizations = expense.realization_ids
has_posted_realization = realizations and all(r.state == 'posted' for r in realizations)
if expense.receipt_status != 'received' or not has_posted_realization:
expense.state = 'wait_post'
if original_states.get(expense.id) != 'paid' and expense.state == 'paid':
# Transitioned to 'Paid'
today = fields.Date.today()
if not expense.receipt_due_date:
due_days = expense.product_id.receipt_due_days or 0
expense.receipt_due_date = today + timedelta(days=due_days)
@api.model_create_multi @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
for vals in vals_list: for vals in vals_list:
@ -185,69 +119,8 @@ class HrExpense(models.Model):
res['price_unit'] = float_round(res['price_unit'], precision_digits=self.currency_id.decimal_places or 2) res['price_unit'] = float_round(res['price_unit'], precision_digits=self.currency_id.decimal_places or 2)
return res return res
def action_submit(self): def action_submit_expenses(self):
for expense in self: for expense in self:
if expense.payment_mode == 'own_account' and expense.nb_attachment == 0: if expense.payment_mode == 'own_account' and expense.nb_attachment == 0:
raise ValidationError(_("You must attach at least one receipt for reimbursement expenses (Paid By: Employee).")) raise ValidationError(_("You must attach at least one receipt for reimbursement expenses (Paid By: Employee)."))
return super().action_submit() return super().action_submit_expenses()
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' _name = 'hr.expense.payment.wizard'
_description = 'Expense Payment Wizard' _description = 'Expense Payment Wizard'
expense_id = fields.Many2one('hr.expense', string='Expense', required=True) expense_sheet_id = fields.Many2one('hr.expense.sheet', required=True)
amount = fields.Monetary(string='Payment Amount', required=True, readonly=True) amount = fields.Monetary(string='Payment Amount', required=True)
currency_id = fields.Many2one('res.currency', related='expense_id.currency_id') currency_id = fields.Many2one('res.currency', related='expense_sheet_id.currency_id')
company_id = fields.Many2one('res.company', related='expense_id.company_id') company_id = fields.Many2one('res.company', related='expense_sheet_id.company_id')
partner_id = fields.Many2one( partner_id = fields.Many2one(
'res.partner', 'res.partner',
@ -38,19 +38,18 @@ class HrExpensePaymentWizard(models.TransientModel):
self.payment_method_line_id = available_payment_methods[0].id self.payment_method_line_id = available_payment_methods[0].id
else: else:
self.payment_method_line_id = False self.payment_method_line_id = False
self.payment_method_line_id = False
def action_create_payment(self): def action_create_payment(self):
self.ensure_one() self.ensure_one()
# Find 115101 account (Uang Muka Operasional) # Find 118101 account
uang_muka_account = self.env['account.account'].search([ uang_muka_account = self.env['account.account'].search([
('code', '=', '115101'), ('code', '=', '118101'),
('company_id', '=', self.company_id.id) ('company_id', '=', self.company_id.id)
], limit=1) ], limit=1)
if not uang_muka_account: if not uang_muka_account:
raise UserError(_("Account 115101 Uang Muka Operasional not found for this company!")) raise UserError(_("Account 118101 Uang Muka Operasional not found for this company!"))
payment_vals = { payment_vals = {
'date': self.payment_date, 'date': self.payment_date,
@ -61,19 +60,23 @@ class HrExpensePaymentWizard(models.TransientModel):
'journal_id': self.journal_id.id, 'journal_id': self.journal_id.id,
'currency_id': self.currency_id.id, 'currency_id': self.currency_id.id,
'payment_method_line_id': self.payment_method_line_id.id, 'payment_method_line_id': self.payment_method_line_id.id,
'ref': self.expense_id.name, 'ref': self.expense_sheet_id.name,
'destination_account_id': uang_muka_account.id, 'destination_account_id': uang_muka_account.id,
'expense_ids': [Command.set(self.expense_id.ids)],
} }
payment = self.env['account.payment'].create(payment_vals) payment = self.env['account.payment'].create(payment_vals)
payment.action_post() payment.action_post()
# In Odoo 19, the payment move is linked to the expense via expense_ids on the payment. # Link the payment's move to the expense sheet
# Standard Odoo should handle the state update if we followed the right hooks, payment.move_id.write({'expense_sheet_id': self.expense_sheet_id.id})
# but since we are doing a custom flow, let's trigger it.
# Update expense status # Update sheet status to Paid
self.expense_id.action_recompute_state() 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()
return {'type': 'ir.actions.act_window_close'} return {'type': 'ir.actions.act_window_close'}

View File

@ -88,8 +88,9 @@ class HrExpenseRealization(models.Model):
self.state = 'confirmed' self.state = 'confirmed'
if self.expense_id: if self.expense_id:
self.expense_id.write({'receipt_received': True}) self.expense_id.write({'receipt_received': True})
# Explicitly trigger recompute of the status # Explicitly trigger recompute of the sheet status
self.expense_id._compute_receipt_status() if self.expense_id.sheet_id:
self.expense_id.sheet_id._compute_receipt_status()
def action_apply_default_account(self): def action_apply_default_account(self):
self.ensure_one() self.ensure_one()
@ -108,7 +109,7 @@ class HrExpenseRealization(models.Model):
raise UserError(_("Please specify the Journal before posting.")) raise UserError(_("Please specify the Journal before posting."))
# 1. Determine accounts # 1. Determine accounts
# Advance Account (Product's expense account, e.g. 115101) # Advance Account (Product's expense account, e.g. 118101)
product = self.expense_id.product_id.with_company(self.company_id) 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 advance_account = product.property_account_expense_company_id or product.property_account_expense_id
if not advance_account: if not advance_account:
@ -154,7 +155,12 @@ class HrExpenseRealization(models.Model):
# Credit for Advance clearing # Credit for Advance clearing
# We clear the remaining advance amount in the first realization for an expense. # We clear the remaining advance amount in the first realization for an expense.
original_paid = self.expense_id.total_amount # 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
# Find already cleared advance amount from previous posted moves targeting this expense and advance account # Find already cleared advance amount from previous posted moves targeting this expense and advance account
cleared_before = sum(self.env['account.move.line'].search([ cleared_before = sum(self.env['account.move.line'].search([
@ -268,7 +274,7 @@ class HrExpenseRealization(models.Model):
('employee_id', '=', employee_id), ('employee_id', '=', employee_id),
('payment_mode', '=', 'company_account'), ('payment_mode', '=', 'company_account'),
('receipt_received', '=', False), ('receipt_received', '=', False),
('state', 'in', ['approved', 'posted', 'paid']) ('state', 'in', ['approved', 'done'])
], ],
fields=['id', 'name', 'date', 'total_amount', 'currency_id'] fields=['id', 'name', 'date', 'total_amount', 'currency_id']
) )

290
models/hr_expense_sheet.py Normal file
View File

@ -0,0 +1,290 @@
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', 'account.account',
string="Expense Account (Employee)", string="Expense Account (Employee)",
company_dependent=True, company_dependent=True,
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card'))]", domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card')), ('deprecated', '=', False)]",
help="Account used for expenses paid by the employee (to be reimbursed)." help="Account used for expenses paid by the employee (to be reimbursed)."
) )
property_account_expense_company_id = fields.Many2one( property_account_expense_company_id = fields.Many2one(
'account.account', 'account.account',
string="Expense Account (Company)", string="Expense Account (Company)",
company_dependent=True, company_dependent=True,
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card'))]", domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card')), ('deprecated', '=', False)]",
help="Account used for expenses paid by the company." help="Account used for expenses paid by the company."
) )
receipt_due_days = fields.Integer( receipt_due_days = fields.Integer(

View File

@ -2,4 +2,3 @@ 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_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_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_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
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> </div>
<!-- ACTION SELECTION --> <!-- ACTION SELECTION -->
<div t-if="state.screen === 'action_selection'" class="d-flex flex-column align-items-center animate-fade-in h-100 justify-content-center"> <div t-if="state.screen === 'action_selection'" class="d-flex flex-column align-items-center animate-fade-in py-5">
<h3 class="mb-5">What would you like to do?</h3> <h3 class="mb-5">What would you like to do?</h3>
<div class="d-flex gap-5"> <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;"> <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,42 +80,45 @@
</div> </div>
</div> </div>
<!-- Recent Submissions List --> <!-- SUBMITTED EXPENSES LIST -->
<div class="mt-5 w-100 px-3" t-if="state.submittedExpenses.length > 0"> <div t-if="state.submittedExpenses.length > 0" class="mt-5 w-100 animate-fade-in" style="max-width: 800px;">
<h4 class="mb-3 text-start"><i class="fa fa-history me-2"></i>Your Recent Submissions</h4> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="table-responsive rounded shadow-sm bg-white"> <h4 class="m-0 text-dark">Your Recent Submissions</h4>
<table class="table table-hover align-middle mb-0"> <span class="badge bg-secondary" t-esc="state.submittedExpenses.length"/>
<thead class="table-light"> </div>
<tr> <div class="card shadow-sm border-0 overflow-hidden">
<th class="ps-3">Reference / Description</th> <div class="table-responsive">
<th>Date</th> <table class="table table-hover align-middle mb-0">
<th class="text-end">Total</th> <thead class="bg-light">
<th class="text-center">Status</th>
<th class="text-center pe-3">Payment</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.submittedExpenses" t-as="exp" t-key="exp.id">
<tr> <tr>
<td class="ps-3"> <th class="ps-4">Report Name</th>
<div class="fw-bold text-primary" t-esc="exp.sequences"/> <th>Expense Sequences</th>
<div class="small text-muted" t-esc="exp.name"/> <th>Date</th>
<th>Total</th>
<th class="text-center">Report Status</th>
<th class="text-center">Payment Status</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"/>
</td> </td>
<td t-esc="exp.date"/> <td>
<td class="text-end fw-bold" t-esc="exp.total_amount"/> <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 class="text-center"> <td class="text-center">
<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"/> <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"/>
</td> </td>
<td class="text-center pe-3"> <td class="text-center">
<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"/> <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> </td>
</tr> </tr>
</t> </tbody>
</tbody> </table>
</table> </div>
</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> </div>
</div> </div>

View File

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

View File

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

View File

@ -7,29 +7,31 @@
<field name="inherit_id" ref="hr_expense.hr_expense_view_form"/> <field name="inherit_id" ref="hr_expense.hr_expense_view_form"/>
<field name="priority">1000</field> <field name="priority">1000</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<!-- Update header: Add wait_post to statusbar --> <!-- Remove all standard Submit Buttons -->
<xpath expr="//field[@name='state'][@statusbar_visible]" position="attributes"> <xpath expr="(//header//button[@name='action_submit_expenses'])[1]" position="replace"/>
<attribute name="statusbar_visible">draft,submitted,approved,posted,wait_post,paid</attribute> <xpath expr="(//header//button[@name='action_submit_expenses'])[1]" position="replace"/>
</xpath>
<xpath expr="//header//button[@name='action_post']" position="attributes"> <!-- Remove all standard Attach Receipt widgets -->
<attribute name="invisible">state != 'approved' or payment_mode == 'company_account'</attribute> <xpath expr="(//header//widget[@name='attach_document'])[1]" position="replace"/>
</xpath> <xpath expr="(//header//widget[@name='attach_document'])[1]" position="replace"/>
<xpath expr="//header//button[@name='action_post']" position="after">
<button name="action_open_payment_wizard" <!-- Remove Split Expense to re-add with correct logic -->
string="Register Payment" <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" type="object"
data-hotkey="y" class="oe_highlight o_expense_submit"
class="oe_highlight o_expense_sheet_post" invisible="sheet_id"
invisible="state != 'approved' or payment_mode != 'company_account'" data-hotkey="v"/>
groups="account.group_account_invoice"/> <widget name="attach_document"
string="Attach Receipt"
action="attach_document"
highlight="nb_attachment &lt; 1 and payment_mode == 'own_account'"
invisible="sheet_id"/>
</xpath> </xpath>
<!-- Attach Receipt widget -->
<xpath expr="//header//widget[@name='attach_document']" position="attributes">
<attribute name="highlight">nb_attachment == 0 and payment_mode == 'own_account'</attribute>
</xpath>
<xpath expr="//div[hasclass('oe_title')]" position="before"> <xpath expr="//div[hasclass('oe_title')]" position="before">
<div class="oe_button_box" name="button_box"> <div class="oe_button_box" name="button_box">
<button name="action_view_realizations" <button name="action_view_realizations"
@ -45,7 +47,7 @@
type="object" type="object"
class="oe_stat_button" class="oe_stat_button"
icon="fa-plus-square-o" icon="fa-plus-square-o"
invisible="payment_mode != 'company_account' or state not in ['paid', 'posted', 'wait_post'] or realization_count != 0"/> invisible="payment_mode != 'company_account' or state not in ['done', 'reported'] or realization_count != 0"/>
</div> </div>
</xpath> </xpath>
<xpath expr="//sheet" position="before"> <xpath expr="//sheet" position="before">
@ -65,11 +67,6 @@
<field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/> <field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/>
<field name="receipt_received" widget="boolean_toggle" invisible="not receipt_due_date"/> <field name="receipt_received" widget="boolean_toggle" invisible="not receipt_due_date"/>
<field name="receipt_overdue" invisible="1"/> <field name="receipt_overdue" invisible="1"/>
<field name="receipt_status" widget="badge"
decoration-info="receipt_status == 'pending'"
decoration-success="receipt_status == 'received'"
decoration-muted="receipt_status == 'none'"/>
<field name="amount_paid" invisible="payment_mode != 'own_account'"/>
</xpath> </xpath>
</field> </field>
</record> </record>
@ -83,19 +80,10 @@
<xpath expr="//field[@name='name']" position="before"> <xpath expr="//field[@name='name']" position="before">
<field name="sequence_name" optional="show"/> <field name="sequence_name" optional="show"/>
</xpath> </xpath>
<xpath expr="//field[@name='state']" position="attributes">
<attribute name="decoration-warning">state == 'wait_post'</attribute>
</xpath>
<xpath expr="//field[@name='total_amount']" position="after"> <xpath expr="//field[@name='total_amount']" position="after">
<field name="amount_paid" optional="show" sum="Total Payment" string="Payment Total"/>
<field name="realization_total_amount" optional="show" sum="Total Realization" invisible="payment_mode != 'company_account'"/> <field name="realization_total_amount" optional="show" sum="Total Realization" invisible="payment_mode != 'company_account'"/>
</xpath> </xpath>
<xpath expr="//field[@name='state']" position="after"> <xpath expr="//field[@name='state']" position="after">
<field name="receipt_status" widget="badge"
decoration-info="receipt_status == 'pending'"
decoration-success="receipt_status == 'received'"
decoration-muted="receipt_status == 'none'"
optional="show"/>
<field name="receipt_due_date" optional="show" widget="remaining_days" decoration-danger="receipt_overdue"/> <field name="receipt_due_date" optional="show" widget="remaining_days" decoration-danger="receipt_overdue"/>
<field name="receipt_received" optional="show" widget="boolean_toggle"/> <field name="receipt_received" optional="show" widget="boolean_toggle"/>
<field name="receipt_overdue" column_invisible="True"/> <field name="receipt_overdue" column_invisible="True"/>
@ -109,16 +97,14 @@
<field name="model">hr.expense</field> <field name="model">hr.expense</field>
<field name="inherit_id" ref="hr_expense.hr_expense_view_search"/> <field name="inherit_id" ref="hr_expense.hr_expense_view_search"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//filter[@name='my_open_expenses']" position="after"> <xpath expr="//filter[@name='no_report']" position="after">
<separator/> <separator/>
<filter string="Wait Post" name="filter_wait_post" domain="[('state', '=', 'wait_post')]"/>
<filter string="Overdue Receipts" name="filter_receipt_overdue" domain="[('receipt_overdue', '=', True), ('receipt_received', '=', False)]"/> <filter string="Overdue Receipts" name="filter_receipt_overdue" domain="[('receipt_overdue', '=', True), ('receipt_received', '=', False)]"/>
<filter string="Receipt Received" name="filter_receipt_received" domain="[('receipt_received', '=', True)]"/> <filter string="Receipt Received" name="filter_receipt_received" domain="[('receipt_received', '=', True)]"/>
<filter string="Receipt Missing" name="filter_receipt_missing" domain="[('receipt_received', '=', False), ('receipt_due_date', '!=', False)]"/> <filter string="Receipt Missing" name="filter_receipt_missing" domain="[('receipt_received', '=', False), ('receipt_due_date', '!=', False)]"/>
<filter string="Pending Receipts" name="filter_receipt_pending" domain="[('receipt_status', '=', 'pending')]"/>
</xpath> </xpath>
<xpath expr="//group" position="inside"> <xpath expr="//group" position="inside">
<filter string="Receipt Status" name="group_receipt_status" context="{'group_by': 'receipt_status'}"/> <filter string="Receipt Status" name="group_receipt_status" context="{'group_by': 'receipt_received'}"/>
<filter string="Receipt Due Date" name="group_receipt_due" context="{'group_by': 'receipt_due_date'}"/> <filter string="Receipt Due Date" name="group_receipt_due" context="{'group_by': 'receipt_due_date'}"/>
</xpath> </xpath>
</field> </field>
@ -127,7 +113,7 @@
<!-- Report Action: Overdue Receipts --> <!-- Report Action: Overdue Receipts -->
<record id="action_hr_expense_overdue_receipts" model="ir.actions.act_window"> <record id="action_hr_expense_overdue_receipts" model="ir.actions.act_window">
<field name="name">Overdue Receipts</field> <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="view_mode">list,pivot,form</field>
<field name="domain">[('receipt_overdue', '=', True), ('receipt_received', '=', False)]</field> <field name="domain">[('receipt_overdue', '=', True), ('receipt_received', '=', False)]</field>
<field name="context">{'search_default_group_receipt_due': 1}</field> <field name="context">{'search_default_group_receipt_due': 1}</field>
@ -145,14 +131,119 @@
parent="hr_expense.menu_hr_expense_reports" parent="hr_expense.menu_hr_expense_reports"
action="action_hr_expense_overdue_receipts" action="action_hr_expense_overdue_receipts"
sequence="20"/> sequence="20"/>
<record id="view_hr_expense_sheet_form_inherit_realization" model="ir.ui.view">
<field name="name">hr.expense.sheet.form.inherit.realization</field>
<field name="model">hr.expense.sheet</field>
<field name="inherit_id" ref="hr_expense.view_hr_expense_sheet_form"/>
<field name="arch" type="xml">
<!-- Update standard Reset to Draft and Cancel buttons to include 'wait_post' -->
<xpath expr="//header//button[@name='action_reset_expense_sheets']" position="attributes">
<attribute name="invisible">state not in ['approve', 'post', 'done', 'cancel', 'wait_post']</attribute>
</xpath>
<xpath expr="//header//button[@name='action_refuse_expense_sheets']" position="attributes">
<attribute name="invisible">state not in ['submit', 'approve', 'post', 'wait_post']</attribute>
</xpath>
<xpath expr="//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 --> <!-- Update My Reports Action to include Wait Post in SearchPanel if applicable -->
<record id="hr_expense.hr_expense_actions_my_all" model="ir.actions.act_window"> <record id="hr_expense.action_hr_expense_sheet_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> <field name="context">{ 'searchpanel_default_state': ["draft", "submit", "approve", "post", "wait_post", "done"], 'search_default_my_reports': 1, 'search_default_not_refused_reports': 1 }</field>
</record> </record>
<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> </odoo>

View File

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

View File

@ -6,11 +6,20 @@
<field name="inherit_id" ref="hr_expense.res_config_settings_view_form"/> <field name="inherit_id" ref="hr_expense.res_config_settings_view_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//block[@name='expenses_setting_container']" position="after"> <xpath expr="//block[@name='expenses_setting_container']" position="after">
<block title="Expense Kiosk" name="expense_kiosk_settings"> <block title="Expense Kiosk" id="expense_kiosk_settings">
<setting string="Kiosk URL" help="Separate URL for Expense Kiosk. No login required for employees. Submissions still require PIN."> <setting title="Separate URL for Expense Kiosk" help="No login required for employees. Submissions still require PIN.">
<field name="expense_kiosk_url" widget="url" readonly="1"/> <div class="content-group">
<div class="mt-2"> <div class="mt16">
<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."/> <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>
</div> </div>
</setting> </setting>
</block> </block>