feat(fusion_accounting_hr_payroll): payroll -> GL bridge

Replaces Enterprise's hr_payroll_account with a Fusion-native bridge:
- Adds account_debit / account_credit / fusion_analytic_account_id /
  not_computed_in_net to hr.salary.rule (company-dependent GL mapping)
- Adds move_id + move_state + journal_id + _fusion_create_account_move
  to hr.payslip (validated payslip -> balanced account.move)
- Adds move_id + move_state + action_open_move to hr.payslip.run
- Adds journal_id (company-dependent) to hr.payroll.structure
- Adds is_payroll_journal flag to account.journal
- Adds payslip_ids / payslip_count + action_open_payslip on account.move
- Adds payslip_id reverse link on account.move.line
- Adds move_line_id reverse link on hr.payslip.line
- Adds fusion_payroll_journal_id + fusion_payroll_auto_post to res.company
  (with res.config.settings exposure)

Coexistence: detects Enterprise hr_payroll_account at runtime via
ir.module.module and yields move creation to it while both modules are
installed, so payslips do not get duplicate entries. Once the Enterprise
module is uninstalled, this module owns the bridge.

Auto-installs whenever both hr_payroll and fusion_accounting_core are
present on the database.

10 smoke tests verifying field surface + bridge entrypoints all pass on
westin-v19. Full payslip-to-move integration test deferred (needs
seeded payroll structure).

Removes Westin's last payroll-accounting dependency on Enterprise's
accountant umbrella module (Phase 6b of the Fusion Accounting suite).

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-20 00:18:08 -04:00
parent aab4b5e958
commit a730942d24
22 changed files with 780 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
from . import hr_salary_rule
from . import hr_payslip
from . import hr_payslip_line
from . import hr_payslip_run
from . import hr_payroll_structure
from . import account_journal
from . import account_move
from . import account_move_line
from . import res_company
from . import res_config_settings

View File

@@ -0,0 +1,12 @@
from odoo import fields, models
class AccountJournal(models.Model):
_inherit = 'account.journal'
is_payroll_journal = fields.Boolean(
string='Used for Payroll',
help="Marks this journal as the salary / payroll posting journal "
"for the company. Informational; the actual fallback is set "
"on res.company.fusion_payroll_journal_id.",
)

View File

@@ -0,0 +1,41 @@
from odoo import _, fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
payslip_ids = fields.One2many(
comodel_name='hr.payslip',
inverse_name='move_id',
string='Payslips',
readonly=True,
copy=False,
)
payslip_count = fields.Integer(
string='# of Payslips',
compute='_compute_payslip_count',
compute_sudo=True,
)
def _compute_payslip_count(self):
for move in self:
move.payslip_count = len(move.payslip_ids)
def action_open_payslip(self):
self.ensure_one()
action = {
'name': _('Payslips'),
'type': 'ir.actions.act_window',
'res_model': 'hr.payslip',
}
if self.payslip_count == 1:
action.update({
'view_mode': 'form',
'res_id': self.payslip_ids.id,
})
else:
action.update({
'view_mode': 'list,form',
'domain': [('id', 'in', self.payslip_ids.ids)],
})
return action

View File

@@ -0,0 +1,16 @@
from odoo import fields, models
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
payslip_id = fields.Many2one(
'hr.payslip',
string='Source Payslip',
readonly=True,
copy=False,
ondelete='set null',
index='btree_not_null',
help="Payslip this journal item was generated from "
"(populated by the Fusion payroll bridge for reporting).",
)

View File

@@ -0,0 +1,26 @@
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class HrPayrollStructure(models.Model):
_inherit = 'hr.payroll.structure'
journal_id = fields.Many2one(
'account.journal',
string='Salary Journal',
company_dependent=True,
domain="[('type', '=', 'general')]",
help="Default journal used when generating payroll accounting "
"entries for payslips that follow this structure.",
)
@api.constrains('journal_id')
def _check_journal_currency(self):
for record in self.sudo():
journal = record.journal_id
if journal and journal.currency_id and journal.company_id \
and journal.currency_id != journal.company_id.currency_id:
raise ValidationError(_(
"The salary journal must be in the same currency as "
"the company.",
))

View File

@@ -0,0 +1,242 @@
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,
}

View File

@@ -0,0 +1,16 @@
from odoo import fields, models
class HrPayslipLine(models.Model):
_inherit = 'hr.payslip.line'
move_line_id = fields.Many2one(
'account.move.line',
string='Journal Item',
readonly=True,
copy=False,
ondelete='set null',
index='btree_not_null',
help="Account move line this payslip line was rolled up into "
"(set by the Fusion payroll bridge for traceability).",
)

View File

@@ -0,0 +1,29 @@
from odoo import _, fields, models
class HrPayslipRun(models.Model):
_inherit = 'hr.payslip.run'
move_id = fields.Many2one(
'account.move',
string='Batch Accounting Entry',
readonly=True,
copy=False,
ondelete='set null',
)
move_state = fields.Selection(
related='move_id.state',
string='Move State',
)
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,
}

View File

@@ -0,0 +1,35 @@
from odoo import fields, models
class HrSalaryRule(models.Model):
_inherit = 'hr.salary.rule'
account_debit = fields.Many2one(
'account.account',
string='Debit Account',
company_dependent=True,
ondelete='restrict',
help="GL account debited when this rule's amount is posted "
"(typically expense or asset).",
)
account_credit = fields.Many2one(
'account.account',
string='Credit Account',
company_dependent=True,
ondelete='restrict',
help="GL account credited when this rule's amount is posted "
"(typically liability).",
)
fusion_analytic_account_id = fields.Many2one(
'account.analytic.account',
string='Analytic Account',
company_dependent=True,
help="Optional analytic account applied to both legs of the move.",
)
not_computed_in_net = fields.Boolean(
string="Excluded from Net",
default=False,
help="If checked, the result of this rule is excluded from the "
"Net salary line in the journal entry. Set a dedicated "
"debit/credit account so the amount is posted independently.",
)

View File

@@ -0,0 +1,19 @@
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
fusion_payroll_journal_id = fields.Many2one(
'account.journal',
string='Default Payroll Journal',
domain="[('type', '=', 'general'), ('company_id', '=', id)]",
help="Fallback journal used by the Fusion payroll bridge when a "
"payslip's structure does not define one.",
)
fusion_payroll_auto_post = fields.Boolean(
string='Auto-post Payroll Entries',
default=False,
help="When enabled, payroll-generated journal entries are posted "
"immediately. Otherwise they remain in draft for review.",
)

View File

@@ -0,0 +1,16 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fusion_payroll_journal_id = fields.Many2one(
related='company_id.fusion_payroll_journal_id',
string='Default Payroll Journal',
readonly=False,
)
fusion_payroll_auto_post = fields.Boolean(
related='company_id.fusion_payroll_auto_post',
string='Auto-post Payroll Entries',
readonly=False,
)