Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dccf0cd9a | |||
| 7da10ccb15 | |||
| fce5f0e286 | |||
| 30b7c6a513 | |||
| 6ec8a90f05 | |||
| 6fe3b042b9 |
58
README.md
Normal file
58
README.md
Normal 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
|
||||||
68
README.rst
68
README.rst
@ -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
|
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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'}
|
||||||
|
|||||||
@ -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
290
models/hr_expense_sheet.py
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
|
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 < 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>
|
||||||
|
|||||||
@ -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"/>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user