Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dccf0cd9a | |||
| 7da10ccb15 | |||
| fce5f0e286 | |||
| 30b7c6a513 | |||
| 6ec8a90f05 | |||
| 6fe3b042b9 |
@ -33,7 +33,12 @@ This module enhances Odoo's standard Expense workflow by providing account-split
|
||||
- **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. Validation & Security
|
||||
### 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.
|
||||
|
||||
|
||||
@ -11,8 +11,10 @@
|
||||
'views/product_views.xml',
|
||||
'views/hr_expense_views.xml',
|
||||
'views/hr_expense_realization_views.xml',
|
||||
'views/hr_expense_payment_wizard_views.xml',
|
||||
'views/hr_expense_kiosk_templates.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'hr_expense_account_split.assets_public_kiosk': [
|
||||
|
||||
@ -79,6 +79,35 @@ class HrExpenseKioskController(http.Controller):
|
||||
return []
|
||||
return request.env['hr.expense.realization'].sudo().get_pending_realizations(employee_id)
|
||||
|
||||
@http.route('/hr_expense/kiosk_get_submitted/<string:token>', type='json', auth='public')
|
||||
def get_submitted(self, token, employee_id):
|
||||
""" Returns submitted expense reports for the employee. """
|
||||
if not self._check_token(token):
|
||||
return []
|
||||
|
||||
sheets = request.env['hr.expense.sheet'].sudo().search([
|
||||
('employee_id', '=', employee_id),
|
||||
('state', 'not in', ['draft', 'cancel'])
|
||||
], order='create_date desc')
|
||||
|
||||
result = []
|
||||
state_selection = dict(request.env['hr.expense.sheet']._fields['state']._description_selection(request.env))
|
||||
payment_selection = dict(request.env['hr.expense.sheet']._fields['payment_state']._description_selection(request.env))
|
||||
|
||||
for sheet in sheets:
|
||||
result.append({
|
||||
'id': sheet.id,
|
||||
'name': sheet.name,
|
||||
'sequences': sheet.expense_sequences or '',
|
||||
'date': sheet.create_date.strftime('%Y-%m-%d'),
|
||||
'total_amount': sheet.currency_id.symbol + " " + "{:,.2f}".format(sheet.total_amount),
|
||||
'state': state_selection.get(sheet.state),
|
||||
'state_raw': sheet.state,
|
||||
'payment_status': payment_selection.get(sheet.payment_state),
|
||||
'payment_state_raw': sheet.payment_state,
|
||||
})
|
||||
return result
|
||||
|
||||
@http.route('/hr_expense/kiosk_submit_realization/<string:token>', type='json', auth='public')
|
||||
def submit_realization(self, token, employee_id, expense_id, lines=None):
|
||||
""" Creates a realization report from the kiosk. """
|
||||
|
||||
@ -7,3 +7,4 @@ from . import hr_expense_realization
|
||||
from . import account_payment
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import hr_expense_payment_wizard
|
||||
|
||||
@ -1,8 +1,19 @@
|
||||
from odoo import models, api
|
||||
from odoo import models, fields, api
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
is_expense_payment = fields.Boolean(
|
||||
string="Is Expense Payment",
|
||||
compute="_compute_is_expense_payment",
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('payment_id.expense_sheet_id')
|
||||
def _compute_is_expense_payment(self):
|
||||
for move in self:
|
||||
move.is_expense_payment = bool(move.payment_id and move.payment_id.expense_sheet_id)
|
||||
|
||||
def _get_hr_expense_base_class(self):
|
||||
""" Returns the hr_expense class in the MRO to jump over it. """
|
||||
mro = type(self).mro()
|
||||
|
||||
@ -87,11 +87,10 @@ class AccountPayment(models.Model):
|
||||
def write(self, vals):
|
||||
# Propagate bypass flag during writes to avoid locked checks
|
||||
hr_expense_class = self._get_hr_expense_base_class()
|
||||
if hr_expense_class and self._context.get('skip_expense_lock'):
|
||||
return super(hr_expense_class, self).write(vals)
|
||||
if hr_expense_class:
|
||||
if self._context.get('skip_expense_lock') or any(p.expense_sheet_id and p.state == 'draft' for p in self):
|
||||
return super(hr_expense_class, self.with_context(skip_expense_lock=True)).write(vals)
|
||||
|
||||
if self.expense_sheet_id and self.state == 'draft':
|
||||
return super(AccountPayment, self.with_context(skip_expense_lock=True)).write(vals)
|
||||
return super().write(vals)
|
||||
|
||||
def action_cancel(self):
|
||||
|
||||
82
models/hr_expense_payment_wizard.py
Normal file
82
models/hr_expense_payment_wizard.py
Normal file
@ -0,0 +1,82 @@
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class HrExpensePaymentWizard(models.TransientModel):
|
||||
_name = 'hr.expense.payment.wizard'
|
||||
_description = 'Expense Payment Wizard'
|
||||
|
||||
expense_sheet_id = fields.Many2one('hr.expense.sheet', required=True)
|
||||
amount = fields.Monetary(string='Payment Amount', required=True)
|
||||
currency_id = fields.Many2one('res.currency', related='expense_sheet_id.currency_id')
|
||||
company_id = fields.Many2one('res.company', related='expense_sheet_id.company_id')
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Vendor',
|
||||
required=True,
|
||||
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]"
|
||||
)
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Payment Journal',
|
||||
required=True,
|
||||
domain="[('type', 'in', ('bank', 'cash')), ('company_id', '=', company_id)]"
|
||||
)
|
||||
payment_method_line_id = fields.Many2one(
|
||||
'account.payment.method.line',
|
||||
string='Payment Method',
|
||||
required=True,
|
||||
domain="[('journal_id', '=', journal_id), ('payment_type', '=', 'outbound')]"
|
||||
)
|
||||
payment_date = fields.Date(string='Payment Date', default=fields.Date.context_today, required=True)
|
||||
|
||||
@api.onchange('journal_id')
|
||||
def _onchange_journal_id(self):
|
||||
if self.journal_id:
|
||||
available_payment_methods = self.journal_id.outbound_payment_method_line_ids
|
||||
if available_payment_methods:
|
||||
self.payment_method_line_id = available_payment_methods[0].id
|
||||
else:
|
||||
self.payment_method_line_id = False
|
||||
|
||||
def action_create_payment(self):
|
||||
self.ensure_one()
|
||||
|
||||
# Find 118101 account
|
||||
uang_muka_account = self.env['account.account'].search([
|
||||
('code', '=', '118101'),
|
||||
('company_id', '=', self.company_id.id)
|
||||
], limit=1)
|
||||
|
||||
if not uang_muka_account:
|
||||
raise UserError(_("Account 118101 Uang Muka Operasional not found for this company!"))
|
||||
|
||||
payment_vals = {
|
||||
'date': self.payment_date,
|
||||
'amount': self.amount,
|
||||
'payment_type': 'outbound',
|
||||
'partner_type': 'supplier',
|
||||
'partner_id': self.partner_id.id,
|
||||
'journal_id': self.journal_id.id,
|
||||
'currency_id': self.currency_id.id,
|
||||
'payment_method_line_id': self.payment_method_line_id.id,
|
||||
'ref': self.expense_sheet_id.name,
|
||||
'destination_account_id': uang_muka_account.id,
|
||||
}
|
||||
|
||||
payment = self.env['account.payment'].create(payment_vals)
|
||||
payment.action_post()
|
||||
|
||||
# Link the payment's move to the expense sheet
|
||||
payment.move_id.write({'expense_sheet_id': self.expense_sheet_id.id})
|
||||
|
||||
# Update sheet status to Paid
|
||||
self.expense_sheet_id.write({
|
||||
'state': 'done',
|
||||
'amount_residual': 0.0,
|
||||
'payment_state': 'paid'
|
||||
})
|
||||
# Our custom logic will push it to 'wait_post' if realization is pending
|
||||
self.expense_sheet_id.action_recompute_state()
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@ -155,7 +155,12 @@ class HrExpenseRealization(models.Model):
|
||||
|
||||
# Credit for Advance clearing
|
||||
# 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
|
||||
cleared_before = sum(self.env['account.move.line'].search([
|
||||
|
||||
@ -81,7 +81,7 @@ class HrExpenseSheet(models.Model):
|
||||
help="Total amount paid by the finance team (Total - Residual)."
|
||||
)
|
||||
|
||||
@api.depends('account_move_ids.line_ids.matched_debit_ids', 'account_move_ids.line_ids.matched_credit_ids', 'total_amount', 'amount_residual')
|
||||
@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
|
||||
@ -120,6 +120,13 @@ class HrExpenseSheet(models.Model):
|
||||
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:
|
||||
@ -169,11 +176,14 @@ class HrExpenseSheet(models.Model):
|
||||
payments = moves.mapped('payment_id')
|
||||
unmatched_payments = payments.filtered(lambda p: not p.is_matched)
|
||||
|
||||
if unmatched_payments:
|
||||
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 = 0.
|
||||
|
||||
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'))
|
||||
@ -264,3 +274,17 @@ class HrExpenseSheet(models.Model):
|
||||
""" 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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ class ExpenseKioskApp extends Component {
|
||||
selectedCategory: null,
|
||||
enteredPin: "",
|
||||
pendingRealizations: [],
|
||||
submittedExpenses: [],
|
||||
selectedAction: null,
|
||||
selectedPaymentMode: null,
|
||||
selectedExpense: null,
|
||||
@ -88,6 +89,7 @@ class ExpenseKioskApp extends Component {
|
||||
|
||||
if (result.status === 'ok') {
|
||||
await this.loadPendingRealizations();
|
||||
await this.loadSubmittedExpenses();
|
||||
this.state.screen = 'action_selection';
|
||||
} else {
|
||||
this.notification.add(result.message, { type: 'danger' });
|
||||
@ -102,6 +104,13 @@ class ExpenseKioskApp extends Component {
|
||||
this.state.pendingRealizations = data;
|
||||
}
|
||||
|
||||
async loadSubmittedExpenses() {
|
||||
const data = await this.rpc(`/hr_expense/kiosk_get_submitted/${this.token}`, {
|
||||
employee_id: this.state.selectedEmployee.id,
|
||||
});
|
||||
this.state.submittedExpenses = data;
|
||||
}
|
||||
|
||||
// Action Selection
|
||||
selectAction(action) {
|
||||
this.state.selectedAction = action;
|
||||
@ -237,6 +246,8 @@ class ExpenseKioskApp extends Component {
|
||||
|
||||
if (result.status === 'ok') {
|
||||
this.state.screen = 'success';
|
||||
await this.loadPendingRealizations();
|
||||
await this.loadSubmittedExpenses();
|
||||
setTimeout(() => {
|
||||
this.backToSelection();
|
||||
}, 3000);
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ACTION SELECTION -->
|
||||
<div t-if="state.screen === 'action_selection'" class="d-flex flex-column align-items-center animate-fade-in 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>
|
||||
<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;">
|
||||
@ -79,6 +79,48 @@
|
||||
<p class="text-muted small">Submit a new reimbursement request</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUBMITTED EXPENSES LIST -->
|
||||
<div t-if="state.submittedExpenses.length > 0" class="mt-5 w-100 animate-fade-in" style="max-width: 800px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="m-0 text-dark">Your Recent Submissions</h4>
|
||||
<span class="badge bg-secondary" t-esc="state.submittedExpenses.length"/>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0 overflow-hidden">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">Report Name</th>
|
||||
<th>Expense Sequences</th>
|
||||
<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>
|
||||
<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">
|
||||
<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 class="text-center">
|
||||
<span t-attf-class="badge rounded-pill #{sheet.payment_state_raw === 'paid' ? 'bg-success' : (sheet.payment_state_raw === 'in_payment' ? 'bg-info' : (sheet.payment_state_raw === 'not_paid' ? 'bg-secondary' : 'bg-warning'))}" t-esc="sheet.payment_status"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PAYMENT MODE SELECTION -->
|
||||
|
||||
17
views/account_move_views.xml
Normal file
17
views/account_move_views.xml
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Override account move form to hide reverse button for expense payments -->
|
||||
<record id="view_move_form_inherit_expense_payment" model="ir.ui.view">
|
||||
<field name="name">account.move.form.inherit.expense.payment</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='%(account.action_view_account_move_reversal)d']" position="attributes">
|
||||
<attribute name="invisible">move_type != 'entry' or state != 'posted' or payment_state == 'reversed' or is_expense_payment</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<field name="is_expense_payment" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
29
views/hr_expense_payment_wizard_views.xml
Normal file
29
views/hr_expense_payment_wizard_views.xml
Normal file
@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_hr_expense_payment_wizard_form" model="ir.ui.view">
|
||||
<field name="name">hr.expense.payment.wizard.form</field>
|
||||
<field name="model">hr.expense.payment.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Register Payment">
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id" widget="res_partner_many2one" context="{'res_partner_search_mode': 'supplier'}"/>
|
||||
<field name="journal_id" options="{'no_create': True}"/>
|
||||
<field name="payment_method_line_id" options="{'no_create': True, 'no_open': True}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="amount" widget="monetary" options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="payment_date"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="expense_sheet_id" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Create Payment" name="action_create_payment" type="object" class="oe_highlight" data-hotkey="q"/>
|
||||
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="z"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@ -143,6 +143,18 @@
|
||||
<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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user