""" 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", }