hr_expense_account_split/models/hr_expense_realization.py

289 lines
14 KiB
Python

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'))
# Payment tracking
discrepancy_amount = fields.Monetary(string='Discrepancy Amount', compute='_compute_discrepancy', store=True)
payment_ids = fields.One2many('account.payment', 'realization_id', string='Payments')
payment_count = fields.Integer(compute='_compute_payment_count')
show_vendor_payment_btn = fields.Boolean(compute='_compute_button_visibility')
show_customer_payment_btn = fields.Boolean(compute='_compute_button_visibility')
@api.depends('move_id.line_ids.amount_currency', 'state', 'payment_ids', 'payment_ids.state')
def _compute_discrepancy(self):
""" Calculate discrepancy based on balancing accounts (114101/216109) in move_id. """
for rec in self:
if rec.state != 'posted' or not rec.move_id:
rec.discrepancy_amount = 0.0
continue
balancing_lines = rec.move_id.line_ids.filtered(lambda l: l.account_id.code in ('114101', '216109'))
payable_val = sum(balancing_lines.filtered(lambda l: l.account_id.code == '216109').mapped('credit'))
receivable_val = sum(balancing_lines.filtered(lambda l: l.account_id.code == '114101').mapped('debit'))
# positive means we owe employee, negative means employee owes us
rec.discrepancy_amount = payable_val - receivable_val
@api.depends('payment_ids')
def _compute_payment_count(self):
for rec in self:
rec.payment_count = len(rec.payment_ids)
@api.depends('discrepancy_amount', 'state', 'payment_ids', 'payment_ids.state')
def _compute_button_visibility(self):
for rec in self:
# Only show if posted and a discrepancy exists, and no successful payment exists
has_payment = any(p.state not in ('draft', 'cancel') for p in rec.payment_ids)
rec.show_vendor_payment_btn = rec.state == 'posted' and rec.discrepancy_amount > 0 and not has_payment
rec.show_customer_payment_btn = rec.state == 'posted' and rec.discrepancy_amount < 0 and not has_payment
@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."))
# 1. Determine accounts
# Advance Account (Product's expense account, e.g. 118101)
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
if not advance_account:
advance_account = self.env['ir.property']._get('property_account_expense_categ_id', 'product.category')
if not advance_account:
raise UserError(_("No advance account found for the product or its category."))
# Partner specific accounts (114101 and 216109 fallback)
partner = self.employee_id.sudo().work_contact_id
# Biaya yang masih harus dibayar (Payable fallback 216109)
payable_account = partner.property_account_payable_id or self.env['account.account'].search([('code', '=', '216109')], limit=1)
# Piutang Karyawan (Receivable fallback 114101)
receivable_account = partner.property_account_receivable_id or self.env['account.account'].search([('code', '=', '114101')], limit=1)
# Force use specific accounts if they exist even if partner has defaults
karyawan_acc = self.env['account.account'].search([('code', '=', '114101')], limit=1)
if karyawan_acc:
receivable_account = karyawan_acc
biaya_ymh_acc = self.env['account.account'].search([('code', '=', '216109')], limit=1)
if biaya_ymh_acc:
payable_account = biaya_ymh_acc
if not payable_account or not receivable_account:
raise UserError(_("Specific accounts for Piutang Karyawan (114101) or Biaya Lain ymh dibayar (216109) not found."))
# 2. Prepare Balanced Move Lines
move_lines = []
total_realized = 0.0
# Debits for Actual Expenses
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_lines.append(Command.create({
'name': f"Realization Expense: {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,
}))
total_realized += line.amount
# Credit for Advance clearing
# We clear the remaining advance amount in the first realization for an expense.
original_paid = self.expense_id.total_amount
# Find already cleared advance amount from previous posted moves targeting this expense and advance account
cleared_before = sum(self.env['account.move.line'].search([
('expense_id', '=', self.expense_id.id),
('account_id', '=', advance_account.id),
('move_id.state', '=', 'posted')
]).mapped('credit'))
rem_advance = max(0.0, original_paid - cleared_before)
move_lines.append(Command.create({
'name': f"Realization Clear Advance: {self.expense_id.sequence_name}",
'account_id': advance_account.id,
'debit': 0.0,
'credit': rem_advance,
'partner_id': self.employee_id.sudo().work_contact_id.id,
'expense_id': self.expense_id.id,
}))
# Balancing line based on discrepency
diff = total_realized - rem_advance
if diff > 0:
# Under-realized (Spent more than advance) -> Credit Partner Payable (216109)
move_lines.append(Command.create({
'name': f"Realization: Biaya yang masih harus dibayar",
'account_id': payable_account.id,
'debit': 0.0,
'credit': diff,
'partner_id': self.employee_id.sudo().work_contact_id.id,
}))
elif diff < 0:
# Over-realized (Spent less than advance) -> Debit Partner Receivable (114101)
move_lines.append(Command.create({
'name': f"Realization: Piutang karyawan",
'account_id': receivable_account.id,
'debit': abs(diff),
'credit': 0.0,
'partner_id': self.employee_id.sudo().work_contact_id.id,
}))
move_vals = {
'journal_id': self.journal_id.id,
'date': self.date,
'ref': f"Realization: {self.expense_id.sequence_name} ({self.name})",
'move_type': 'entry',
'line_ids': move_lines,
}
move = self.env['account.move'].create(move_vals)
move.action_post()
# Link the move to lines and record
for line in self.line_ids:
line.move_id = move.id
self.write({
'state': 'posted',
'move_id': move.id
})
def action_create_vendor_payment(self):
return self._action_create_payment('outbound')
def action_create_customer_payment(self):
return self._action_create_payment('inbound')
def _action_create_payment(self, payment_type):
""" Opens the account.payment creation form with pre-filled realization data. """
self.ensure_one()
partner = self.employee_id.sudo().work_contact_id
if not partner:
raise UserError(_("Employee must have a work contact to register a payment."))
# Find the correct account to clear from the realization's journal entry
expense_account_id = False
if self.move_id:
balancing_line = self.move_id.line_ids.filtered(lambda l: l.account_id.code in ('114101', '216109'))
if balancing_line:
expense_account_id = balancing_line[0].account_id.id
return {
'name': _('Register Payment'),
'type': 'ir.actions.act_window',
'res_model': 'account.payment',
'view_mode': 'form',
'target': 'new',
'context': {
'default_payment_type': payment_type,
'default_partner_id': partner.id,
'default_partner_type': 'supplier' if payment_type == 'outbound' else 'customer',
'default_amount': abs(self.discrepancy_amount),
'default_ref': f"Realization Balance: {self.name}",
'default_realization_id': self.id,
'default_expense_account_id': expense_account_id, # Required when applying deductions (vendor_payment_diff_amount)
}
}
def action_view_payments(self):
""" Stat button action to view related payments. """
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("account.action_account_payments")
action['domain'] = [('realization_id', '=', self.id)]
action['context'] = {'default_realization_id': self.id}
return action
@api.model
def get_pending_realizations(self, employee_id):
""" Returns expenses for the given employee that are reported/done but NOT yet realized. """
return self.env['hr.expense'].search_read(
domain=[
('employee_id', '=', employee_id),
('payment_mode', '=', 'company_account'),
('receipt_received', '=', False),
('state', 'in', ['approved', 'done'])
],
fields=['id', 'name', 'date', 'total_amount', 'currency_id']
)
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")