281 lines
13 KiB
Python
281 lines
13 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."))
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
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")
|