feat: implement expense realization module with receipt tracking and automated journal entries

This commit is contained in:
Suherdy Yacob 2026-04-01 13:56:03 +07:00
parent 7d70bab223
commit 4128ac60e6
9 changed files with 484 additions and 4 deletions

View File

@ -6,8 +6,11 @@
'author': 'Suherdy Yacob', 'author': 'Suherdy Yacob',
'depends': ['hr_expense', 'account'], 'depends': ['hr_expense', 'account'],
'data': [ 'data': [
'security/ir.model.access.csv',
'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_realization_views.xml',
], ],
'installable': True, 'installable': True,
'application': False, 'application': False,

12
data/ir_sequence_data.xml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_hr_expense_realization" model="ir.sequence">
<field name="name">Expense Realization</field>
<field name="code">hr.expense.realization</field>
<field name="prefix">RLZ/%(year)s/%(month)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@ -2,3 +2,4 @@ from . import product_template
from . import hr_expense from . import hr_expense
from . import hr_expense_sheet from . import hr_expense_sheet
from . import account_move_line from . import account_move_line
from . import hr_expense_realization

View File

@ -1,4 +1,5 @@
from odoo import api, fields, models, _ from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import float_round from odoo.tools import float_round
class HrExpense(models.Model): class HrExpense(models.Model):
@ -37,6 +38,46 @@ class HrExpense(models.Model):
store=True, store=True,
help="True if receipt is not received and past due date." help="True if receipt is not received and past due date."
) )
realization_ids = fields.One2many('hr.expense.realization', 'expense_id', string='Realizations')
realization_count = fields.Integer(string='Realization Count', compute='_compute_realization_count')
@api.depends('realization_ids')
def _compute_realization_count(self):
for expense in self:
expense.realization_count = len(expense.realization_ids)
def action_create_realization(self):
self.ensure_one()
if self.payment_mode != 'company_account':
raise UserError(_("Realization is only for company-paid expenses."))
# Check if already has a realization
if self.realization_count > 0:
return self.action_view_realizations()
return {
'name': _('Create Realization'),
'type': 'ir.actions.act_window',
'res_model': 'hr.expense.realization',
'view_mode': 'form',
'context': {
'default_expense_id': self.id,
'default_employee_id': self.employee_id.id,
},
'target': 'current',
}
def action_view_realizations(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("hr_expense_account_split.action_hr_expense_realization")
if self.realization_count > 1:
action['domain'] = [('expense_id', '=', self.id)]
elif self.realization_count == 1:
res = self.env['hr.expense.realization'].search([('expense_id', '=', self.id)], limit=1)
action['views'] = [(self.env.ref('hr_expense_account_split.hr_expense_realization_view_form').id, 'form')]
action['res_id'] = res.id
return action
@api.depends('receipt_due_date', 'receipt_received') @api.depends('receipt_due_date', 'receipt_received')
def _compute_receipt_overdue(self): def _compute_receipt_overdue(self):

View File

@ -0,0 +1,134 @@
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError, ValidationError
class HrExpenseRealization(models.Model):
_name = 'hr.expense.realization'
_description = 'Expense Realization'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'date desc, id desc'
name = fields.Char(string='Reference', required=True, copy=False, default=lambda self: _('New'))
expense_id = fields.Many2one(
'hr.expense',
string='Source Expense',
required=True,
domain="[('payment_mode', '=', 'company_account')]",
ondelete='cascade'
)
employee_id = fields.Many2one('hr.employee', string='Employee', related='expense_id.employee_id', store=True)
company_id = fields.Many2one('res.company', string='Company', related='expense_id.company_id', store=True)
currency_id = fields.Many2one('res.currency', string='Currency', related='expense_id.currency_id', store=True)
date = fields.Date(string='Date', default=fields.Date.context_today, required=True)
description = fields.Text(string='Description')
line_ids = fields.One2many('hr.expense.realization.line', 'realization_id', string='Receipt Lines')
total_amount = fields.Monetary(string='Total Amount', compute='_compute_total_amount', store=True, currency_field='currency_id')
state = fields.Selection([
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('posted', 'Posted')
], string='Status', default='draft', tracking=True)
default_counterpart_account_id = fields.Many2one('account.account', string='Default Counterpart Account', tracking=True, groups="account.group_account_invoice")
journal_id = fields.Many2one('account.journal', string='Journal', tracking=True, groups="account.group_account_invoice", default=lambda self: self.env['account.journal'].search([('name', '=', 'Realisasi')], limit=1))
move_id = fields.Many2one('account.move', string='Journal Entry', readonly=True, groups="account.group_account_invoice", help="Reference to the first journal entry created.")
@api.depends('line_ids.amount')
def _compute_total_amount(self):
for rec in self:
rec.total_amount = sum(rec.line_ids.mapped('amount'))
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('hr.expense.realization') or _('New')
return super().create(vals_list)
def action_confirm(self):
self.ensure_one()
if not self.line_ids:
raise UserError(_("Please add at least one receipt line."))
self.state = 'confirmed'
if self.expense_id:
self.expense_id.write({'receipt_received': True})
# Explicitly trigger recompute of the sheet status
if self.expense_id.sheet_id:
self.expense_id.sheet_id._compute_receipt_status()
def action_apply_default_account(self):
self.ensure_one()
if not self.default_counterpart_account_id:
raise UserError(_("Please set a Default Counterpart Account first."))
for line in self.line_ids:
if not line.counterpart_account_id:
line.counterpart_account_id = self.default_counterpart_account_id
def action_post(self):
self.ensure_one()
if self.state != 'confirmed':
raise UserError(_("Only confirmed realizations can be posted."))
if not self.journal_id:
raise UserError(_("Please specify the Journal before posting."))
# Determine the Expense Account
product = self.expense_id.product_id.with_company(self.company_id)
expense_account = product.property_account_expense_company_id or product.property_account_expense_id
if not expense_account:
expense_account = self.env['ir.property']._get('property_account_expense_categ_id', 'product.category')
if not expense_account:
raise UserError(_("No expense account found for the product or its category."))
moves = self.env['account.move']
for line in self.line_ids:
if not line.counterpart_account_id:
raise UserError(_("Please specify a Counterpart Account for the receipt: %s") % line.description)
move_vals = {
'journal_id': self.journal_id.id,
'date': self.date,
'ref': f"Realization: {self.expense_id.name} - {line.description}",
'move_type': 'entry',
'line_ids': [
Command.create({
'name': f"Realization: {self.expense_id.name} ({line.description})",
'account_id': expense_account.id,
'debit': 0.0,
'credit': line.amount,
'partner_id': self.employee_id.sudo().work_contact_id.id,
'expense_id': self.expense_id.id,
}),
Command.create({
'name': f"Realization Counterpart: {line.description}",
'account_id': line.counterpart_account_id.id,
'debit': line.amount,
'credit': 0.0,
'partner_id': self.employee_id.sudo().work_contact_id.id,
}),
],
}
move = self.env['account.move'].create(move_vals)
move.action_post()
line.move_id = move.id
moves |= move
self.write({
'state': 'posted',
'move_id': moves[0].id if moves else False
})
class HrExpenseRealizationLine(models.Model):
_name = 'hr.expense.realization.line'
_description = 'Expense Realization Line'
realization_id = fields.Many2one('hr.expense.realization', string='Realization', ondelete='cascade', required=True)
currency_id = fields.Many2one('res.currency', related='realization_id.currency_id')
description = fields.Char(string='Description', required=True)
amount = fields.Monetary(string='Amount', required=True, currency_field='currency_id')
attachment_id = fields.Binary(string='Receipt Attachment')
attachment_name = fields.Char(string='Attachment Name')
counterpart_account_id = fields.Many2one('account.account', string='Counterpart Account', groups="account.group_account_invoice")
move_id = fields.Many2one('account.move', string='Journal Entry', readonly=True, groups="account.group_account_invoice")

View File

@ -19,3 +19,20 @@ class HrExpenseSheet(models.Model):
if not expense.receipt_due_date: if not expense.receipt_due_date:
due_days = expense.product_id.receipt_due_days or 0 due_days = expense.product_id.receipt_due_days or 0
expense.receipt_due_date = today + timedelta(days=due_days) 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)
@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'

View File

@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hr_expense_realization_user,hr.expense.realization,model_hr_expense_realization,hr_expense.group_hr_expense_user,1,1,1,0
access_hr_expense_realization_manager,hr.expense.realization,model_hr_expense_realization,hr_expense.group_hr_expense_manager,1,1,1,1
access_hr_expense_realization_line_user,hr.expense.realization.line,model_hr_expense_realization_line,hr_expense.group_hr_expense_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_hr_expense_realization_user hr.expense.realization model_hr_expense_realization hr_expense.group_hr_expense_user 1 1 1 0
3 access_hr_expense_realization_manager hr.expense.realization model_hr_expense_realization hr_expense.group_hr_expense_manager 1 1 1 1
4 access_hr_expense_realization_line_user hr.expense.realization.line model_hr_expense_realization_line hr_expense.group_hr_expense_user 1 1 1 1

View File

@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Realization Tree View -->
<record id="hr_expense_realization_view_tree" model="ir.ui.view">
<field name="name">hr.expense.realization.view.tree</field>
<field name="model">hr.expense.realization</field>
<field name="arch" type="xml">
<tree string="Expense Realization">
<field name="name"/>
<field name="date"/>
<field name="employee_id"/>
<field name="expense_id"/>
<field name="total_amount" sum="Total Amount"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-warning="state == 'confirmed'" decoration-success="state == 'posted'"/>
</tree>
</field>
</record>
<!-- Realization Form View -->
<record id="hr_expense_realization_view_form" model="ir.ui.view">
<field name="name">hr.expense.realization.view.form</field>
<field name="model">hr.expense.realization</field>
<field name="arch" type="xml">
<form string="Expense Realization">
<header>
<button name="action_confirm" string="Confirm" type="object" class="oe_highlight" invisible="state != 'draft'"/>
<button name="action_post" string="Post Journal" type="object" class="oe_highlight" groups="account.group_account_invoice" invisible="state != 'confirmed'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,posted"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="expense_id" readonly="state != 'draft'"/>
<field name="employee_id"/>
<field name="date" readonly="state == 'posted'"/>
</group>
<group>
<field name="currency_id" invisible="1"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="total_amount"/>
<field name="move_id" invisible="not move_id"/>
</group>
</group>
<group string="Accounting Information" invisible="state == 'draft'" groups="account.group_account_invoice">
<group name="accounting_main">
<field name="journal_id" required="state == 'confirmed'" readonly="state == 'posted'"/>
<label for="default_counterpart_account_id" string="Counterpart Account"/>
<div class="o_row">
<field name="default_counterpart_account_id" placeholder="Select Default Account..." readonly="state == 'posted'"/>
<button name="action_apply_default_account"
type="object"
string="Set on Lines"
class="btn btn-secondary"
invisible="state == 'posted' or not default_counterpart_account_id"/>
</div>
</group>
<group name="accounting_info">
<div class="alert alert-info" role="alert" invisible="state != 'confirmed'">
Use the "Set on Lines" button to quickly assign the counterpart account to all receipt lines.
</div>
</group>
</group>
<notebook>
<page string="Receipts" name="receipt_lines">
<field name="line_ids" readonly="state == 'posted'">
<tree editable="bottom">
<field name="description"/>
<field name="amount" sum="Total"/>
<field name="attachment_id" filename="attachment_name" widget="binary"/>
<field name="attachment_name" column_invisible="1"/>
<field name="counterpart_account_id" groups="account.group_account_invoice" required="parent.state == 'confirmed'"/>
<field name="move_id" groups="account.group_account_invoice" widget="many2one_clickable" readonly="1" invisible="not move_id"/>
</tree>
</field>
</page>
<page string="Notes" name="notes">
<field name="description" placeholder="Add some notes..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="activity_ids" widget="mail_activity"/>
<field name="message_ids" widget="mail_thread"/>
</div>
</form>
</field>
</record>
<!-- Realization Search View -->
<record id="hr_expense_realization_view_search" model="ir.ui.view">
<field name="name">hr.expense.realization.view.search</field>
<field name="model">hr.expense.realization</field>
<field name="arch" type="xml">
<search string="Search Realization">
<field name="name"/>
<field name="employee_id"/>
<field name="expense_id"/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
<filter string="Posted" name="posted" domain="[('state', '=', 'posted')]"/>
<group expand="0" string="Group By">
<filter string="Employee" name="group_employee" context="{'group_by': 'employee_id'}"/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'date'}"/>
</group>
</search>
</field>
</record>
<!-- Realization Action -->
<record id="action_hr_expense_realization" model="ir.actions.act_window">
<field name="name">Realization Report</field>
<field name="res_model">hr.expense.realization</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="hr_expense_realization_view_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new Realization Report
</p>
<p>
Manage employee receipts and their final accounting reconciliation.
</p>
</field>
</record>
<!-- Menu Structure Adjustment -->
<!-- 1. Create a new parent menu "Expense Reports" -->
<menuitem id="menu_expense_reports_parent"
name="Expense Reports"
parent="hr_expense.menu_hr_expense_root"
sequence="2"/>
<!-- 2. Move the standard "Expense Reports" menu under it and rename it to "Expenses" -->
<menuitem id="hr_expense.menu_hr_expense_report"
name="Expenses"
parent="menu_expense_reports_parent"
sequence="1"/>
<!-- 3. Add the "Realization Report" submenu under it -->
<menuitem id="menu_hr_expense_realization"
name="Realization Report"
parent="menu_expense_reports_parent"
action="action_hr_expense_realization"
sequence="2"/>
</odoo>

View File

@ -5,17 +5,65 @@
<field name="name">hr.expense.view.form.receipt</field> <field name="name">hr.expense.view.form.receipt</field>
<field name="model">hr.expense</field> <field name="model">hr.expense</field>
<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="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='account_id']" position="after"> <!-- Remove all standard Submit Buttons -->
<field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/> <xpath expr="(//header//button[@name='action_submit_expenses'])[1]" position="replace"/>
<field name="receipt_received" widget="boolean_toggle" invisible="not receipt_due_date"/> <xpath expr="(//header//button[@name='action_submit_expenses'])[1]" position="replace"/>
<field name="receipt_overdue" invisible="1"/>
<!-- Remove all standard Attach Receipt widgets -->
<xpath expr="(//header//widget[@name='attach_document'])[1]" position="replace"/>
<xpath expr="(//header//widget[@name='attach_document'])[1]" position="replace"/>
<!-- Remove Split Expense to re-add with correct logic -->
<xpath expr="//header//button[@name='action_split_wizard']" position="replace"/>
<!-- Re-add ONLY one Create Report button as primary -->
<xpath expr="//header" position="inside">
<button name="action_submit_expenses"
string="Create Report"
type="object"
class="oe_highlight o_expense_submit"
invisible="sheet_id"
data-hotkey="v"/>
<!-- Re-add Attach Receipt but hide it on new records -->
<widget name="attach_document"
string="Attach Receipt"
action="attach_document"
highlight="nb_attachment &lt; 1"
invisible="not id or sheet_id"/>
<!-- Re-add Split Expense with correct logic -->
<button name="action_split_wizard" string="Split Expense" type="object" invisible="not id or sheet_id or product_has_cost"/>
</xpath>
<xpath expr="//div[hasclass('oe_title')]" position="before">
<div class="oe_button_box" name="button_box">
<button name="action_view_realizations"
type="object"
class="oe_stat_button"
icon="fa-file-text-o"
invisible="realization_count == 0">
<field name="realization_count" widget="statinfo" string="Realization"/>
</button>
<!-- Button to create new realization if none exists -->
<button name="action_create_realization"
string="Realization"
type="object"
class="oe_stat_button"
icon="fa-plus-square-o"
invisible="payment_mode != 'company_account' or state != 'done' or realization_count != 0"/>
</div>
</xpath> </xpath>
<xpath expr="//sheet" position="before"> <xpath expr="//sheet" position="before">
<div class="alert alert-danger mb-2" role="alert" invisible="not receipt_overdue"> <div class="alert alert-danger mb-2" role="alert" invisible="not receipt_overdue">
This receipt is <strong>Overdue</strong>! Please submit the original receipt as soon as possible. This receipt is <strong>Overdue</strong>! Please submit the original receipt as soon as possible.
</div> </div>
</xpath> </xpath>
<xpath expr="//field[@name='account_id']" position="after">
<field name="realization_count" invisible="1"/>
<field name="receipt_due_date" widget="date" invisible="not receipt_due_date"/>
<field name="receipt_received" widget="boolean_toggle" invisible="not receipt_due_date"/>
<field name="receipt_overdue" invisible="1"/>
</xpath>
</field> </field>
</record> </record>
@ -73,4 +121,72 @@
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">
<!-- Restrict Posting to Accountants -->
<xpath expr="//button[@name='action_sheet_move_create']" position="attributes">
<attribute name="groups">account.group_account_invoice</attribute>
</xpath>
<xpath expr="//button[@name='action_register_payment']" position="attributes">
<attribute name="groups">account.group_account_invoice</attribute>
</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', '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', '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="before">
<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="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>
</odoo> </odoo>