429 lines
16 KiB
Python
429 lines
16 KiB
Python
"""
|
|
Fusion Accounting - Integration Bridge Modules
|
|
===============================================
|
|
|
|
Provides optional glue code between Fusion Accounting and other Odoo
|
|
applications. Each bridge class extends a core accounting model with
|
|
fields and methods that only become meaningful when the target module
|
|
(fleet, hr_expense, helpdesk) is installed.
|
|
|
|
All dependencies are **soft**: the bridges use ``try/except ImportError``
|
|
guards so that Fusion Accounting installs and operates normally even
|
|
when the partner modules are absent.
|
|
|
|
Bridges
|
|
-------
|
|
* **FusionFleetBridge** -- tags journal-item expenses to fleet vehicles.
|
|
* **FusionExpenseBridge** -- creates journal entries from approved
|
|
HR expense sheets.
|
|
* **FusionHelpdeskBridge** -- generates credit notes linked to helpdesk
|
|
tickets for rapid customer resolution.
|
|
|
|
Copyright (c) Nexa Systems Inc. - All rights reserved.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from odoo import api, fields, models, Command, _
|
|
from odoo.exceptions import UserError, ValidationError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Soft-dependency probes
|
|
# ---------------------------------------------------------------------------
|
|
# Each flag is True only when the corresponding Odoo module is importable.
|
|
# The flags are evaluated at *module-load* time, so they reflect the state
|
|
# of the Odoo installation rather than the database registry.
|
|
|
|
_fleet_available = False
|
|
try:
|
|
from odoo.addons.fleet import models as _fleet_models # noqa: F401
|
|
_fleet_available = True
|
|
except ImportError:
|
|
_logger.debug("fleet module not available -- FusionFleetBridge will be inert.")
|
|
|
|
_hr_expense_available = False
|
|
try:
|
|
from odoo.addons.hr_expense import models as _hr_expense_models # noqa: F401
|
|
_hr_expense_available = True
|
|
except ImportError:
|
|
_logger.debug("hr_expense module not available -- FusionExpenseBridge will be inert.")
|
|
|
|
_helpdesk_available = False
|
|
try:
|
|
from odoo.addons.helpdesk import models as _helpdesk_models # noqa: F401
|
|
_helpdesk_available = True
|
|
except ImportError:
|
|
_logger.debug("helpdesk module not available -- FusionHelpdeskBridge will be inert.")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Fleet Bridge
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class FusionFleetBridge(models.Model):
|
|
"""Extends journal items so each line can optionally reference a fleet
|
|
vehicle, enabling per-vehicle cost tracking and reporting.
|
|
|
|
When the *fleet* module is **not** installed the ``fusion_vehicle_id``
|
|
field is still created (as an orphan Many2one) but it will never
|
|
resolve, and the UI hides it via conditional visibility.
|
|
"""
|
|
|
|
_name = "account.move.line"
|
|
_inherit = "account.move.line"
|
|
|
|
# ---- Fields ----
|
|
fusion_vehicle_id = fields.Many2one(
|
|
comodel_name="fleet.vehicle",
|
|
string="Vehicle",
|
|
index="btree_not_null",
|
|
ondelete="set null",
|
|
copy=True,
|
|
help=(
|
|
"Optionally link this journal item to a fleet vehicle for "
|
|
"per-vehicle expense tracking and cost-center analysis."
|
|
),
|
|
)
|
|
fusion_vehicle_license_plate = fields.Char(
|
|
related="fusion_vehicle_id.license_plate",
|
|
string="License Plate",
|
|
readonly=True,
|
|
store=False,
|
|
)
|
|
|
|
# ---- Helpers ----
|
|
def _fusion_is_fleet_installed(self):
|
|
"""Runtime check: is the *fleet* model actually registered?"""
|
|
return "fleet.vehicle" in self.env
|
|
|
|
@api.onchange("fusion_vehicle_id")
|
|
def _onchange_fusion_vehicle_id(self):
|
|
"""When a vehicle is selected, suggest the vehicle's display name
|
|
as the line label if the label is currently empty."""
|
|
for line in self:
|
|
if line.fusion_vehicle_id and not line.name:
|
|
line.name = _("Expense: %s", line.fusion_vehicle_id.display_name)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# HR Expense Bridge
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class FusionExpenseBridge(models.Model):
|
|
"""Links journal entries to approved HR expense sheets and provides a
|
|
method to generate accounting entries from those sheets.
|
|
|
|
When *hr_expense* is **not** installed the field and method remain on
|
|
the model but are functionally inert.
|
|
"""
|
|
|
|
_name = "account.move"
|
|
_inherit = "account.move"
|
|
|
|
# ---- Fields ----
|
|
fusion_expense_sheet_id = fields.Many2one(
|
|
comodel_name="hr.expense.sheet",
|
|
string="Expense Report",
|
|
index="btree_not_null",
|
|
ondelete="set null",
|
|
copy=False,
|
|
readonly=True,
|
|
help=(
|
|
"The HR expense sheet from which this journal entry was "
|
|
"generated. Populated automatically by the bridge."
|
|
),
|
|
)
|
|
fusion_expense_employee_id = fields.Many2one(
|
|
related="fusion_expense_sheet_id.employee_id",
|
|
string="Expense Employee",
|
|
readonly=True,
|
|
store=False,
|
|
)
|
|
|
|
# ---- Helpers ----
|
|
def _fusion_is_hr_expense_installed(self):
|
|
"""Runtime check: is the *hr.expense.sheet* model registered?"""
|
|
return "hr.expense.sheet" in self.env
|
|
|
|
# ---- Actions ----
|
|
def action_open_expense_sheet(self):
|
|
"""Navigate to the linked expense sheet form."""
|
|
self.ensure_one()
|
|
if not self.fusion_expense_sheet_id:
|
|
raise UserError(_("No expense report is linked to this entry."))
|
|
return {
|
|
"type": "ir.actions.act_window",
|
|
"res_model": "hr.expense.sheet",
|
|
"res_id": self.fusion_expense_sheet_id.id,
|
|
"view_mode": "form",
|
|
"target": "current",
|
|
}
|
|
|
|
# ---- Core method: create journal entry from expense sheet ----
|
|
@api.model
|
|
def create_move_from_expense_sheet(self, expense_sheet_id):
|
|
"""Generate a journal entry from an approved HR expense sheet.
|
|
|
|
:param int expense_sheet_id: id of the ``hr.expense.sheet`` record.
|
|
:returns: the newly created ``account.move`` recordset.
|
|
:raises UserError: if the expense sheet is not in *approve* state,
|
|
or if the hr_expense module is not installed.
|
|
"""
|
|
if not self._fusion_is_hr_expense_installed():
|
|
raise UserError(
|
|
_("The HR Expense module is not installed. "
|
|
"Please install it before creating entries from expense sheets.")
|
|
)
|
|
|
|
sheet = self.env["hr.expense.sheet"].browse(expense_sheet_id)
|
|
if not sheet.exists():
|
|
raise UserError(_("Expense sheet #%d does not exist.", expense_sheet_id))
|
|
|
|
if sheet.state != "approve":
|
|
raise UserError(
|
|
_("Only approved expense reports can be converted to journal "
|
|
"entries. Current status: %s.", sheet.state)
|
|
)
|
|
|
|
# Determine the journal -- prefer the company's expense journal,
|
|
# fall back to the first available miscellaneous journal.
|
|
journal = self.env["account.journal"].search(
|
|
[
|
|
("company_id", "=", sheet.company_id.id),
|
|
("type", "=", "purchase"),
|
|
],
|
|
limit=1,
|
|
)
|
|
if not journal:
|
|
journal = self.env["account.journal"].search(
|
|
[
|
|
("company_id", "=", sheet.company_id.id),
|
|
("type", "=", "general"),
|
|
],
|
|
limit=1,
|
|
)
|
|
if not journal:
|
|
raise UserError(
|
|
_("No suitable purchase or miscellaneous journal found for "
|
|
"company %s.", sheet.company_id.name)
|
|
)
|
|
|
|
# Build move-line values from each expense line.
|
|
move_line_vals = []
|
|
total_amount = 0.0
|
|
|
|
for expense in sheet.expense_line_ids:
|
|
amount = expense.total_amount_company
|
|
total_amount += amount
|
|
|
|
# Debit: expense account
|
|
move_line_vals.append(Command.create({
|
|
"name": expense.name or _("Expense: %s", sheet.name),
|
|
"account_id": expense.account_id.id,
|
|
"debit": amount if amount > 0 else 0.0,
|
|
"credit": -amount if amount < 0 else 0.0,
|
|
"partner_id": sheet.employee_id.work_contact_id.id
|
|
if sheet.employee_id.work_contact_id else False,
|
|
"analytic_distribution": expense.analytic_distribution or False,
|
|
}))
|
|
|
|
# Credit: payable account (employee)
|
|
payable_account = (
|
|
sheet.employee_id.work_contact_id.property_account_payable_id
|
|
if sheet.employee_id.work_contact_id
|
|
else self.env["account.account"].search(
|
|
[
|
|
("company_id", "=", sheet.company_id.id),
|
|
("account_type", "=", "liability_payable"),
|
|
],
|
|
limit=1,
|
|
)
|
|
)
|
|
if not payable_account:
|
|
raise UserError(
|
|
_("No payable account found for employee %s.",
|
|
sheet.employee_id.name)
|
|
)
|
|
|
|
move_line_vals.append(Command.create({
|
|
"name": _("Payable: %s", sheet.name),
|
|
"account_id": payable_account.id,
|
|
"debit": -total_amount if total_amount < 0 else 0.0,
|
|
"credit": total_amount if total_amount > 0 else 0.0,
|
|
"partner_id": sheet.employee_id.work_contact_id.id
|
|
if sheet.employee_id.work_contact_id else False,
|
|
}))
|
|
|
|
move = self.create({
|
|
"journal_id": journal.id,
|
|
"date": fields.Date.context_today(self),
|
|
"ref": _("Expense Report: %s", sheet.name),
|
|
"fusion_expense_sheet_id": sheet.id,
|
|
"move_type": "entry",
|
|
"line_ids": move_line_vals,
|
|
})
|
|
|
|
_logger.info(
|
|
"Fusion Expense Bridge: created journal entry %s (id=%d) "
|
|
"from expense sheet '%s' (id=%d).",
|
|
move.name, move.id, sheet.name, sheet.id,
|
|
)
|
|
|
|
return move
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Helpdesk Bridge
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class FusionHelpdeskBridge(models.Model):
|
|
"""Extends journal entries with helpdesk-ticket linkage and provides
|
|
an action to create a credit note directly from a ticket.
|
|
|
|
When *helpdesk* is **not** installed the fields remain on the model
|
|
but are functionally inert, and the UI hides the button.
|
|
"""
|
|
|
|
_name = "account.move"
|
|
_inherit = "account.move"
|
|
|
|
# ---- Fields ----
|
|
fusion_helpdesk_ticket_id = fields.Many2one(
|
|
comodel_name="helpdesk.ticket",
|
|
string="Helpdesk Ticket",
|
|
index="btree_not_null",
|
|
ondelete="set null",
|
|
copy=False,
|
|
help=(
|
|
"The helpdesk ticket associated with this credit note. "
|
|
"Set automatically when a credit note is created from a ticket."
|
|
),
|
|
)
|
|
fusion_helpdesk_ticket_ref = fields.Char(
|
|
related="fusion_helpdesk_ticket_id.name",
|
|
string="Ticket Reference",
|
|
readonly=True,
|
|
store=False,
|
|
)
|
|
|
|
# ---- Helpers ----
|
|
def _fusion_is_helpdesk_installed(self):
|
|
"""Runtime check: is the *helpdesk.ticket* model registered?"""
|
|
return "helpdesk.ticket" in self.env
|
|
|
|
# ---- Actions ----
|
|
def action_open_helpdesk_ticket(self):
|
|
"""Navigate to the linked helpdesk ticket form."""
|
|
self.ensure_one()
|
|
if not self.fusion_helpdesk_ticket_id:
|
|
raise UserError(_("No helpdesk ticket is linked to this entry."))
|
|
return {
|
|
"type": "ir.actions.act_window",
|
|
"res_model": "helpdesk.ticket",
|
|
"res_id": self.fusion_helpdesk_ticket_id.id,
|
|
"view_mode": "form",
|
|
"target": "current",
|
|
}
|
|
|
|
@api.model
|
|
def action_create_credit_note_from_ticket(self, ticket_id, invoice_id=None):
|
|
"""Create a credit note linked to a helpdesk ticket.
|
|
|
|
If *invoice_id* is provided the credit note reverses that specific
|
|
invoice. Otherwise a standalone credit note is created with the
|
|
ticket's partner and a reference back to the ticket.
|
|
|
|
:param int ticket_id: id of the ``helpdesk.ticket`` record.
|
|
:param int|None invoice_id: optional id of the invoice to reverse.
|
|
:returns: window action pointing to the new credit note form.
|
|
:raises UserError: if the helpdesk module is not installed.
|
|
"""
|
|
if not self._fusion_is_helpdesk_installed():
|
|
raise UserError(
|
|
_("The Helpdesk module is not installed. "
|
|
"Please install it before creating credit notes from tickets.")
|
|
)
|
|
|
|
Ticket = self.env["helpdesk.ticket"]
|
|
ticket = Ticket.browse(ticket_id)
|
|
if not ticket.exists():
|
|
raise UserError(_("Helpdesk ticket #%d does not exist.", ticket_id))
|
|
|
|
partner = ticket.partner_id
|
|
if not partner:
|
|
raise UserError(
|
|
_("Ticket '%s' has no customer set. A customer is required "
|
|
"to create a credit note.", ticket.name)
|
|
)
|
|
|
|
# ---- Path A: reverse an existing invoice ----
|
|
if invoice_id:
|
|
invoice = self.browse(invoice_id)
|
|
if not invoice.exists():
|
|
raise UserError(_("Invoice #%d does not exist.", invoice_id))
|
|
|
|
if invoice.move_type not in ("out_invoice", "out_receipt"):
|
|
raise UserError(
|
|
_("Only customer invoices can be reversed from a "
|
|
"helpdesk ticket.")
|
|
)
|
|
|
|
# Use the standard reversal wizard logic.
|
|
reversal_vals = {
|
|
"journal_id": invoice.journal_id.id,
|
|
"date": fields.Date.context_today(self),
|
|
"reason": _("Credit note from ticket: %s", ticket.name),
|
|
}
|
|
credit_note = invoice._reverse_moves(
|
|
default_values_list=[reversal_vals],
|
|
cancel=False,
|
|
)
|
|
credit_note.write({
|
|
"fusion_helpdesk_ticket_id": ticket.id,
|
|
"ref": _("Ticket: %s", ticket.name),
|
|
})
|
|
|
|
# ---- Path B: create a blank credit note ----
|
|
else:
|
|
journal = self.env["account.journal"].search(
|
|
[
|
|
("company_id", "=", ticket.company_id.id or self.env.company.id),
|
|
("type", "=", "sale"),
|
|
],
|
|
limit=1,
|
|
)
|
|
if not journal:
|
|
raise UserError(
|
|
_("No sales journal found. Please configure one before "
|
|
"creating credit notes.")
|
|
)
|
|
|
|
credit_note = self.create({
|
|
"move_type": "out_refund",
|
|
"journal_id": journal.id,
|
|
"partner_id": partner.id,
|
|
"date": fields.Date.context_today(self),
|
|
"ref": _("Ticket: %s", ticket.name),
|
|
"narration": _(
|
|
"Credit note generated from helpdesk ticket "
|
|
"'%s' (ID %d).", ticket.name, ticket.id,
|
|
),
|
|
"fusion_helpdesk_ticket_id": ticket.id,
|
|
})
|
|
|
|
_logger.info(
|
|
"Fusion Helpdesk Bridge: created credit note %s (id=%d) "
|
|
"from ticket '%s' (id=%d).",
|
|
credit_note.name, credit_note.id, ticket.name, ticket.id,
|
|
)
|
|
|
|
return {
|
|
"type": "ir.actions.act_window",
|
|
"res_model": "account.move",
|
|
"res_id": credit_note.id,
|
|
"view_mode": "form",
|
|
"target": "current",
|
|
}
|