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
243 lines
8.6 KiB
Python
243 lines
8.6 KiB
Python
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,
|
|
}
|