Compare commits

...

6 Commits
19.0 ... 17.0

14 changed files with 280 additions and 11 deletions

View File

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

View File

@ -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': [

View File

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

View File

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

View File

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

View File

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

View 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'}

View File

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

View File

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

View File

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

View File

@ -64,7 +64,7 @@
</div>
<!-- ACTION SELECTION -->
<div t-if="state.screen === 'action_selection'" class="d-flex flex-column align-items-center animate-fade-in 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 -->

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

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

View File

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