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 status self.expense_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', 'posted', 'paid']) ], 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")