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:
1
fusion_accounting_hr_payroll/__init__.py
Normal file
1
fusion_accounting_hr_payroll/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
59
fusion_accounting_hr_payroll/__manifest__.py
Normal file
59
fusion_accounting_hr_payroll/__manifest__.py
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
'name': 'Fusion Accounting - HR Payroll Bridge',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Human Resources/Payroll',
|
||||
'summary': 'Bridges payroll (hr_payroll) to accounting via account.move creation when payslips are validated.',
|
||||
'description': """
|
||||
Fusion Accounting - HR Payroll Bridge
|
||||
=====================================
|
||||
|
||||
A Fusion-native replacement for Odoo Enterprise's ``hr_payroll_account``
|
||||
module. Removes Westin's last payroll-accounting dependency on the
|
||||
Enterprise ``accountant`` umbrella.
|
||||
|
||||
Scope
|
||||
-----
|
||||
- Adds ``account_debit`` / ``account_credit`` / ``analytic_distribution`` to
|
||||
``hr.salary.rule`` (company-dependent GL mapping per rule).
|
||||
- Adds ``move_id`` + ``journal_id`` + ``_fusion_create_account_move`` to
|
||||
``hr.payslip``: when a payslip is validated, generates a balanced
|
||||
``account.move`` from the salary rule mapping.
|
||||
- Adds ``fusion_payroll_journal_id`` + ``fusion_payroll_auto_post`` to
|
||||
``res.company`` (fallback journal + auto-post toggle).
|
||||
- Reverse links ``payslip_ids`` / ``payslip_count`` on ``account.move``
|
||||
for traceability and reporting.
|
||||
|
||||
Coexistence
|
||||
-----------
|
||||
When Odoo Enterprise's ``hr_payroll_account`` is also installed, this
|
||||
module yields move-creation to it (detected at runtime via
|
||||
``ir.module.module``) so payslips don't get duplicate entries. After
|
||||
``hr_payroll_account`` is uninstalled, this module owns the bridge.
|
||||
|
||||
Auto-install
|
||||
------------
|
||||
Auto-installs whenever both ``hr_payroll`` and ``fusion_accounting_core``
|
||||
are present.
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.ca',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'account',
|
||||
'hr_payroll',
|
||||
'base_iban',
|
||||
],
|
||||
'data': [
|
||||
'data/hr_salary_rule_data.xml',
|
||||
'views/hr_salary_rule_views.xml',
|
||||
'views/hr_payslip_views.xml',
|
||||
'views/hr_payroll_structure_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
],
|
||||
'auto_install': ['hr_payroll', 'fusion_accounting_core'],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'icon': '/fusion_accounting_hr_payroll/static/description/icon.png',
|
||||
}
|
||||
34
fusion_accounting_hr_payroll/data/hr_salary_rule_data.xml
Normal file
34
fusion_accounting_hr_payroll/data/hr_salary_rule_data.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Bridge defaults from the Enterprise hr_payroll_account module.
|
||||
Wrapped in noupdate="1" so re-running -u does not overwrite a
|
||||
customer's account mapping on these rules.
|
||||
|
||||
Each <record> uses xmlid_lookup="ignore" through optional `forcecreate="0"`
|
||||
semantics so that the load is silently skipped when the referenced
|
||||
upstream rule is not present (e.g. on a database without the
|
||||
Enterprise default payroll structures).
|
||||
-->
|
||||
<data noupdate="1">
|
||||
<record id="hr_payroll.default_deduction_salary_rule" model="hr.salary.rule" forcecreate="0">
|
||||
<field name="not_computed_in_net" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_payroll.default_attachment_of_salary_rule" model="hr.salary.rule" forcecreate="0">
|
||||
<field name="not_computed_in_net" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_payroll.default_assignment_of_salary_rule" model="hr.salary.rule" forcecreate="0">
|
||||
<field name="not_computed_in_net" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_payroll.default_child_support" model="hr.salary.rule" forcecreate="0">
|
||||
<field name="not_computed_in_net" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_payroll.default_reimbursement_salary_rule" model="hr.salary.rule" forcecreate="0">
|
||||
<field name="not_computed_in_net" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
10
fusion_accounting_hr_payroll/models/__init__.py
Normal file
10
fusion_accounting_hr_payroll/models/__init__.py
Normal 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
|
||||
12
fusion_accounting_hr_payroll/models/account_journal.py
Normal file
12
fusion_accounting_hr_payroll/models/account_journal.py
Normal 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.",
|
||||
)
|
||||
41
fusion_accounting_hr_payroll/models/account_move.py
Normal file
41
fusion_accounting_hr_payroll/models/account_move.py
Normal 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
|
||||
16
fusion_accounting_hr_payroll/models/account_move_line.py
Normal file
16
fusion_accounting_hr_payroll/models/account_move_line.py
Normal 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).",
|
||||
)
|
||||
26
fusion_accounting_hr_payroll/models/hr_payroll_structure.py
Normal file
26
fusion_accounting_hr_payroll/models/hr_payroll_structure.py
Normal 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.",
|
||||
))
|
||||
242
fusion_accounting_hr_payroll/models/hr_payslip.py
Normal file
242
fusion_accounting_hr_payroll/models/hr_payslip.py
Normal 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,
|
||||
}
|
||||
16
fusion_accounting_hr_payroll/models/hr_payslip_line.py
Normal file
16
fusion_accounting_hr_payroll/models/hr_payslip_line.py
Normal 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).",
|
||||
)
|
||||
29
fusion_accounting_hr_payroll/models/hr_payslip_run.py
Normal file
29
fusion_accounting_hr_payroll/models/hr_payslip_run.py
Normal 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,
|
||||
}
|
||||
35
fusion_accounting_hr_payroll/models/hr_salary_rule.py
Normal file
35
fusion_accounting_hr_payroll/models/hr_salary_rule.py
Normal 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.",
|
||||
)
|
||||
19
fusion_accounting_hr_payroll/models/res_company.py
Normal file
19
fusion_accounting_hr_payroll/models/res_company.py
Normal 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.",
|
||||
)
|
||||
16
fusion_accounting_hr_payroll/models/res_config_settings.py
Normal file
16
fusion_accounting_hr_payroll/models/res_config_settings.py
Normal 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,
|
||||
)
|
||||
BIN
fusion_accounting_hr_payroll/static/description/icon.png
Normal file
BIN
fusion_accounting_hr_payroll/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
1
fusion_accounting_hr_payroll/tests/__init__.py
Normal file
1
fusion_accounting_hr_payroll/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_payslip_to_move
|
||||
108
fusion_accounting_hr_payroll/tests/test_payslip_to_move.py
Normal file
108
fusion_accounting_hr_payroll/tests/test_payslip_to_move.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionPayrollBridge(TransactionCase):
|
||||
"""Smoke tests for the Fusion payroll bridge.
|
||||
|
||||
Verifies that the field surface required to replace Enterprise's
|
||||
``hr_payroll_account`` is present after the module installs.
|
||||
Full payslip-to-move integration is exercised in a separate
|
||||
integration test that needs a seeded payroll structure.
|
||||
"""
|
||||
|
||||
def test_module_installed(self):
|
||||
module = self.env['ir.module.module'].sudo().search(
|
||||
[('name', '=', 'fusion_accounting_hr_payroll')], limit=1,
|
||||
)
|
||||
self.assertTrue(module, "Module record must exist")
|
||||
self.assertEqual(
|
||||
module.state, 'installed',
|
||||
"Module should be in 'installed' state for these tests to run",
|
||||
)
|
||||
|
||||
def test_salary_rule_has_account_fields(self):
|
||||
rule_model = self.env['hr.salary.rule']
|
||||
for fname in (
|
||||
'account_debit',
|
||||
'account_credit',
|
||||
'fusion_analytic_account_id',
|
||||
'not_computed_in_net',
|
||||
):
|
||||
self.assertIn(
|
||||
fname, rule_model._fields,
|
||||
f"hr.salary.rule must expose '{fname}'",
|
||||
)
|
||||
|
||||
def test_payslip_has_move_link(self):
|
||||
slip_model = self.env['hr.payslip']
|
||||
for fname in ('move_id', 'move_state', 'journal_id'):
|
||||
self.assertIn(
|
||||
fname, slip_model._fields,
|
||||
f"hr.payslip must expose '{fname}'",
|
||||
)
|
||||
self.assertTrue(
|
||||
hasattr(slip_model, '_fusion_create_account_move'),
|
||||
"hr.payslip must expose the _fusion_create_account_move bridge",
|
||||
)
|
||||
self.assertTrue(
|
||||
hasattr(slip_model, '_fusion_enterprise_bridge_active'),
|
||||
"hr.payslip must expose the Enterprise-bridge detector",
|
||||
)
|
||||
|
||||
def test_payslip_run_has_move_link(self):
|
||||
run_model = self.env['hr.payslip.run']
|
||||
for fname in ('move_id', 'move_state'):
|
||||
self.assertIn(
|
||||
fname, run_model._fields,
|
||||
f"hr.payslip.run must expose '{fname}'",
|
||||
)
|
||||
|
||||
def test_company_payroll_journal_field(self):
|
||||
co_model = self.env['res.company']
|
||||
for fname in ('fusion_payroll_journal_id', 'fusion_payroll_auto_post'):
|
||||
self.assertIn(
|
||||
fname, co_model._fields,
|
||||
f"res.company must expose '{fname}'",
|
||||
)
|
||||
|
||||
def test_account_move_back_links(self):
|
||||
move_model = self.env['account.move']
|
||||
for fname in ('payslip_ids', 'payslip_count'):
|
||||
self.assertIn(
|
||||
fname, move_model._fields,
|
||||
f"account.move must expose '{fname}'",
|
||||
)
|
||||
line_model = self.env['account.move.line']
|
||||
self.assertIn(
|
||||
'payslip_id', line_model._fields,
|
||||
"account.move.line must expose 'payslip_id'",
|
||||
)
|
||||
|
||||
def test_payslip_line_has_move_line_link(self):
|
||||
line_model = self.env['hr.payslip.line']
|
||||
self.assertIn(
|
||||
'move_line_id', line_model._fields,
|
||||
"hr.payslip.line must expose 'move_line_id'",
|
||||
)
|
||||
|
||||
def test_enterprise_bridge_detector_returns_bool(self):
|
||||
slip_model = self.env['hr.payslip']
|
||||
self.assertIsInstance(
|
||||
slip_model._fusion_enterprise_bridge_active(), bool,
|
||||
)
|
||||
|
||||
def test_account_journal_payroll_flag(self):
|
||||
journal_model = self.env['account.journal']
|
||||
self.assertIn(
|
||||
'is_payroll_journal', journal_model._fields,
|
||||
"account.journal must expose 'is_payroll_journal'",
|
||||
)
|
||||
|
||||
def test_payroll_structure_journal_field(self):
|
||||
struct_model = self.env['hr.payroll.structure']
|
||||
self.assertIn(
|
||||
'journal_id', struct_model._fields,
|
||||
"hr.payroll.structure must expose 'journal_id'",
|
||||
)
|
||||
24
fusion_accounting_hr_payroll/views/account_move_views.xml
Normal file
24
fusion_accounting_hr_payroll/views/account_move_views.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_account_move_view_form" model="ir.ui.view">
|
||||
<field name="name">account.move.form.fusion.payroll.bridge</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="button_box" position="inside">
|
||||
<field name="payslip_count" invisible="1"/>
|
||||
<button class="oe_stat_button"
|
||||
name="action_open_payslip"
|
||||
type="object"
|
||||
icon="fa-user"
|
||||
invisible="not payslip_count"
|
||||
groups="hr.group_hr_user">
|
||||
<div class="o_stat_info">
|
||||
<field name="payslip_count" class="o_stat_value"/>
|
||||
<span class="o_stat_text">Payslips</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_hr_payroll_structure_view_form" model="ir.ui.view">
|
||||
<field name="name">hr.payroll.structure.form.fusion.payroll.bridge</field>
|
||||
<field name="model">hr.payroll.structure</field>
|
||||
<field name="inherit_id" ref="hr_payroll.view_hr_employee_grade_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Fusion Accounting">
|
||||
<field name="journal_id"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
26
fusion_accounting_hr_payroll/views/hr_payslip_views.xml
Normal file
26
fusion_accounting_hr_payroll/views/hr_payslip_views.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_hr_payslip_view_form" model="ir.ui.view">
|
||||
<field name="name">hr.payslip.form.fusion.payroll.bridge</field>
|
||||
<field name="model">hr.payslip</field>
|
||||
<field name="inherit_id" ref="hr_payroll.view_hr_payslip_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="button_box" position="inside">
|
||||
<field name="move_id" invisible="1"/>
|
||||
<field name="move_state" invisible="1"/>
|
||||
<button class="oe_stat_button"
|
||||
name="action_open_move"
|
||||
type="object"
|
||||
icon="fa-bars"
|
||||
invisible="not move_id"
|
||||
groups="account.group_account_readonly">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text" invisible="move_state != 'draft'">Journal Entry (Draft)</span>
|
||||
<span class="o_stat_text" invisible="move_state != 'posted'">Journal Entry (Posted)</span>
|
||||
<span class="o_stat_text" invisible="move_state != 'cancel'">Journal Entry (Canceled)</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
26
fusion_accounting_hr_payroll/views/hr_salary_rule_views.xml
Normal file
26
fusion_accounting_hr_payroll/views/hr_salary_rule_views.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_hr_salary_rule_view_form" model="ir.ui.view">
|
||||
<field name="name">hr.salary.rule.form.fusion.payroll.bridge</field>
|
||||
<field name="model">hr.salary.rule</field>
|
||||
<field name="inherit_id" ref="hr_payroll.hr_salary_rule_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<notebook>
|
||||
<page string="Fusion Accounting" name="fusion_accounting">
|
||||
<group>
|
||||
<group>
|
||||
<field name="account_debit" placeholder="None"/>
|
||||
<field name="account_credit" placeholder="None"/>
|
||||
<field name="fusion_analytic_account_id"
|
||||
groups="analytic.group_analytic_accounting"
|
||||
placeholder="None"/>
|
||||
<field name="not_computed_in_net"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="fusion_hr_payroll_res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.form.fusion.payroll.bridge</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//block[1]" position="before">
|
||||
<block title="Fusion Payroll Bridge" id="fusion_payroll_bridge_block">
|
||||
<setting id="fusion_payroll_journal_setting"
|
||||
string="Default Payroll Journal"
|
||||
help="Fallback journal used by the Fusion payroll bridge when a payslip's structure does not define one.">
|
||||
<field name="fusion_payroll_journal_id"/>
|
||||
</setting>
|
||||
<setting id="fusion_payroll_auto_post_setting"
|
||||
string="Auto-post Payroll Entries"
|
||||
help="When enabled, payroll-generated journal entries are posted immediately. Otherwise they remain in draft for review.">
|
||||
<field name="fusion_payroll_auto_post"/>
|
||||
</setting>
|
||||
</block>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user