import logging from collections import defaultdict from odoo import _, api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class HrPayslip(models.Model): _inherit = 'hr.payslip' move_id = fields.Many2one( 'account.move', string='Accounting Entry', readonly=True, copy=False, index='btree_not_null', ) move_state = fields.Selection( related='move_id.state', string='Move State', export_string_translation=False, ) journal_id = fields.Many2one( 'account.journal', string='Salary Journal', domain="[('type', '=', 'general')]", ) @api.model def _fusion_enterprise_bridge_active(self): """Return True when the Enterprise hr_payroll_account module is the authoritative payslip - GL bridge on this database. Used to avoid duplicate move creation while both modules coexist.""" module = self.env['ir.module.module'].sudo().search( [('name', '=', 'hr_payroll_account')], limit=1, ) return bool(module) and module.state == 'installed' def _fusion_resolve_journal(self): """Pick the journal for this payslip's bridge move.""" self.ensure_one() if self.journal_id: return self.journal_id struct = self.struct_id if struct and 'journal_id' in struct._fields and struct.journal_id: return struct.journal_id company = self.company_id or self.env.company return company.fusion_payroll_journal_id or False def _fusion_resolve_partner(self): """Pick the best partner reference for the move lines of this payslip.""" self.ensure_one() employee = self.employee_id if not employee: return False if 'work_contact_id' in employee._fields and employee.work_contact_id: return employee.work_contact_id.id if 'address_home_id' in employee._fields and employee.address_home_id: return employee.address_home_id.id return False def _fusion_get_line_amount(self, line): """Hook so a localisation can override which payslip-line value is posted. Defaults to ``line.total``.""" return line.total or 0.0 def action_payslip_done(self): res = super().action_payslip_done() if self._fusion_enterprise_bridge_active(): return res for slip in self: if slip.move_id: continue journal = slip._fusion_resolve_journal() if not journal: continue try: slip._fusion_create_account_move(journal=journal) except UserError as err: _logger.warning( "Fusion payroll bridge: GL move skipped for slip %s: %s", slip.id, err, ) slip.message_post(body=_( "Fusion Payroll bridge could not create the journal " "entry: %s", ) % err) except Exception: _logger.exception( "Fusion payroll bridge: unexpected failure for slip %s", slip.id, ) return res def action_payslip_cancel(self): if hasattr(super(), 'action_payslip_cancel'): res = super().action_payslip_cancel() else: res = True if self._fusion_enterprise_bridge_active(): return res for slip in self: move = slip.move_id if not move: continue try: if move.state == 'posted': move.button_draft() move.with_context(force_delete=True).unlink() except Exception: _logger.exception( "Fusion payroll bridge: cannot reverse move %s for slip %s", move.id, slip.id, ) return res def _fusion_create_account_move(self, journal=None): """Build a balanced ``account.move`` from this payslip using the ``account_debit`` / ``account_credit`` mapping on each salary rule. Returns the created move (or False if there is nothing to post).""" self.ensure_one() if not self.line_ids: return False journal = journal or self._fusion_resolve_journal() if not journal: raise UserError(_( "No salary journal configured for company %s. " "Set a fallback journal under Accounting Settings - " "Fusion Payroll Bridge.", ) % (self.company_id.display_name if self.company_id else '')) debit_per_account = defaultdict(float) credit_per_account = defaultdict(float) analytic_per_account = {} for line in self.line_ids: rule = line.salary_rule_id amount = self._fusion_get_line_amount(line) if not amount: continue debit_account = rule.account_debit credit_account = rule.account_credit analytic = ( rule.fusion_analytic_account_id if 'fusion_analytic_account_id' in rule._fields else False ) if amount > 0: if debit_account: debit_per_account[debit_account.id] += amount if credit_account: credit_per_account[credit_account.id] += amount else: pos = -amount if debit_account: credit_per_account[debit_account.id] += pos if credit_account: debit_per_account[credit_account.id] += pos if analytic: for acc in (debit_account, credit_account): if acc and acc.id not in analytic_per_account: analytic_per_account[acc.id] = analytic.id partner_id = self._fusion_resolve_partner() line_label = self.display_name or self.number or _('Payslip') move_lines = [] all_accounts = set(debit_per_account) | set(credit_per_account) for account_id in all_accounts: net = ( debit_per_account.get(account_id, 0.0) - credit_per_account.get(account_id, 0.0) ) if abs(net) < 0.005: continue vals = { 'account_id': account_id, 'name': line_label, 'partner_id': partner_id, } if net > 0: vals['debit'] = round(net, 2) vals['credit'] = 0.0 else: vals['debit'] = 0.0 vals['credit'] = round(-net, 2) analytic_id = analytic_per_account.get(account_id) if analytic_id: vals['analytic_distribution'] = {str(analytic_id): 100.0} move_lines.append((0, 0, vals)) if not move_lines: return False total_debit = sum(vals[2]['debit'] for vals in move_lines) total_credit = sum(vals[2]['credit'] for vals in move_lines) if abs(total_debit - total_credit) > 0.01: raise UserError(_( "Payroll move not balanced: debit=%(d).2f, credit=%(c).2f. " "Check the account_debit / account_credit mapping on the " "salary rules of payslip %(name)s.", ) % { 'd': total_debit, 'c': total_credit, 'name': self.display_name, }) move_vals = { 'journal_id': journal.id, 'date': self.date_to or fields.Date.context_today(self), 'ref': self.number or self.display_name, 'line_ids': move_lines, 'move_type': 'entry', } move = self.env['account.move'].sudo().create(move_vals) if self.company_id and self.company_id.fusion_payroll_auto_post: try: move.action_post() except Exception: _logger.exception( "Fusion payroll bridge: auto-post failed for move %s; " "leaving in draft.", move.id, ) self.move_id = move.id return move def action_open_move(self): self.ensure_one() if not self.move_id: return False return { 'type': 'ir.actions.act_window', 'name': _('Journal Entry'), 'res_model': 'account.move', 'view_mode': 'form', 'res_id': self.move_id.id, }