# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging from odoo import http from odoo.http import request from odoo.osv import expression _logger = logging.getLogger(__name__) class WebsiteHelpdeskHours(http.Controller): """Controller for helpdesk hours widget""" @http.route("/helpdesk/hours/available", type="json", auth="user", website=True) def get_available_hours(self): """ Calculate available hours for the authenticated portal user's partner. Returns: dict: { 'total_available': float, # Total hours available 'hours_used': float, # Hours already delivered/used 'prepaid_hours': float, # Hours from prepaid orders (not delivered) 'credit_hours': float, # Hours calculated from available credit 'credit_available': float, # Available credit amount 'highest_price': float, # Highest price unit for hours } """ try: # Get contact information early for use in all return cases company = request.env.company config_param = request.env["ir.config_parameter"].sudo() whatsapp_number = config_param.get_param( "helpdesk_extras.whatsapp_number", "" ) company_email = company.email or "" packages_url = config_param.get_param( "helpdesk_extras.packages_url", "/shop" ) # Check if user is portal if not request.env.user._is_portal(): return { "error": "Access denied: User is not a portal user", "total_available": 0.0, "hours_used": 0.0, "prepaid_hours": 0.0, "credit_hours": 0.0, "credit_available": 0.0, "highest_price": 0.0, "whatsapp_number": whatsapp_number, "email": company_email, "packages_url": packages_url, } partner = request.env.user.partner_id.commercial_partner_id user_partner = request.env.user.partner_id # Get UoM hour reference (use sudo to access uom.uom) try: uom_hour = request.env.ref("uom.product_uom_hour").sudo() except Exception as e: return { "error": f"Error getting UoM hour: {str(e)}", "total_available": 0.0, "hours_used": 0.0, "prepaid_hours": 0.0, "credit_hours": 0.0, "credit_available": 0.0, "highest_price": 0.0, "whatsapp_number": whatsapp_number, "email": company_email, "packages_url": packages_url, } # Get helpdesk teams where this user is a collaborator # Search by both user's partner and commercial partner (in case registered differently) collaborator_domain = [ "|", ("partner_id", "=", user_partner.id), ("partner_id", "=", partner.id), ] collaborator_teams = ( request.env["helpdesk.team.collaborator"] .sudo() .search(collaborator_domain) .mapped("team_id") ) # If user is not a collaborator in any team, return empty results if not collaborator_teams: return { "total_available": 0.0, "hours_used": 0.0, "prepaid_hours": 0.0, "credit_hours": 0.0, "credit_available": 0.0, "highest_price": 0.0, "whatsapp_number": whatsapp_number, "email": company_email, "packages_url": packages_url, } # Get all prepaid sale order lines for the partner # Following Odoo's standard procedure from helpdesk_sale_timesheet SaleOrderLine = request.env["sale.order.line"].sudo() # Use the same domain that Odoo uses in _get_last_sol_of_customer # But extend it to include parent/child commercial partner # And also include orders where the partner is the invoice or shipping address # This is important for contacts that act as billing contacts for a company # Base domain for partner matching partner_domain = expression.OR([ [("order_partner_id", "child_of", partner.id)], [("order_id.partner_invoice_id", "child_of", partner.id)], [("order_id.partner_shipping_id", "child_of", partner.id)], ]) base_domain = [ ("company_id", "=", company.id), # ("order_partner_id", "child_of", partner.id), # Replaced by partner_domain ("state", "in", ["sale", "done"]), ("remaining_hours", ">", 0), # Only lines with remaining hours ] # Combine base domain with partner domain domain = expression.AND([base_domain, partner_domain]) # Check if sale_timesheet module is installed has_sale_timesheet = "sale_timesheet" in request.env.registry._init_modules if has_sale_timesheet: # Use _domain_sale_line_service to filter service products correctly # This is the same method Odoo uses internally in _get_last_sol_of_customer try: service_domain = SaleOrderLine._domain_sale_line_service( check_state=False ) # Combine domains using expression.AND() as Odoo does domain = expression.AND([domain, service_domain]) except Exception: # Fallback if _domain_sale_line_service is not available domain = expression.AND( [ domain, [ ("product_id.type", "=", "service"), ("product_id.service_policy", "=", "ordered_prepaid"), ("remaining_hours_available", "=", True), ], ] ) # Search for prepaid lines following Odoo's standard procedure prepaid_sol_lines = SaleOrderLine.search(domain) # NEW LOGIC: Calculate hours based on invoice payment status # - paid_hours: hours from PAID invoices only (invoice line qty) # - unpaid_invoice_hours: hours from UNPAID invoices # - uninvoiced_hours: hours sold but not yet invoiced paid_hours = 0.0 unpaid_invoice_hours = 0.0 uninvoiced_hours = 0.0 highest_price = 0.0 for line in prepaid_sol_lines: try: # Get quantities for this line qty_sold = line.product_uom_qty or 0.0 qty_invoiced = line.qty_invoiced or 0.0 qty_delivered = line.qty_delivered or 0.0 if qty_sold <= 0: continue # Track highest price unit if line.price_unit > highest_price: highest_price = line.price_unit # Calculate uninvoiced hours (sold but not yet invoiced) qty_uninvoiced = max(0.0, qty_sold - qty_invoiced) uninvoiced_hours += qty_uninvoiced # For invoiced portion, check payment status per invoice invoice_lines = line.invoice_lines.sudo() if not invoice_lines: # No invoices - all goes to uninvoiced (already counted above) continue # Process each invoice line for inv_line in invoice_lines: inv = inv_line.move_id # Only count posted customer invoices if inv.move_type != 'out_invoice' or inv.state != 'posted': continue inv_qty = inv_line.quantity or 0.0 if inv.payment_state == 'paid': # Paid invoice - hours are available paid_hours += inv_qty else: # Not paid (not_paid, partial, in_payment, etc.) unpaid_invoice_hours += inv_qty except Exception as e: _logger.debug( "Error calculating hours for line %s: %s", line.id, str(e), exc_info=True ) # If no lines with price, try to get price from all prepaid lines (historical) if highest_price == 0 and prepaid_sol_lines: for line in prepaid_sol_lines: if line.price_unit > highest_price: highest_price = line.price_unit # Calculate hours used from ALL prepaid lines (including those fully consumed) # This gives a complete picture of hours used by the customer # Use the same extended partner domain base_hours_used_domain = [ ("company_id", "=", company.id), ("state", "in", ["sale", "done"]), ] hours_used_domain = expression.AND([base_hours_used_domain, partner_domain]) if has_sale_timesheet: try: service_domain = SaleOrderLine._domain_sale_line_service( check_state=False ) hours_used_domain = expression.AND( [hours_used_domain, service_domain] ) except Exception: hours_used_domain = expression.AND( [ hours_used_domain, [ ("product_id.type", "=", "service"), ("product_id.service_policy", "=", "ordered_prepaid"), ("remaining_hours_available", "=", True), ], ] ) all_prepaid_lines = SaleOrderLine.search(hours_used_domain) hours_used = 0.0 for line in all_prepaid_lines: # Calculate hours used: qty_delivered converted to hours qty_delivered = line.qty_delivered or 0.0 if qty_delivered > 0: qty_delivered_hours = ( line.product_uom._compute_quantity( qty_delivered, uom_hour, raise_if_failure=False ) or 0.0 ) hours_used += qty_delivered_hours # Calculate credit hours from partner credit limit credit_from_limit = 0.0 credit_available = 0.0 # Check if credit limit is configured partner_sudo = partner.sudo() if company.account_use_credit_limit and partner_sudo.credit_limit > 0: credit_used = partner_sudo.credit or 0.0 credit_available = max(0.0, partner_sudo.credit_limit - credit_used) # Convert credit to hours using highest price if highest_price > 0 and credit_available > 0: credit_from_limit = credit_available / highest_price # NEW: Credit hours = uninvoiced hours + unpaid invoice hours + credit limit hours credit_hours = uninvoiced_hours + unpaid_invoice_hours + credit_from_limit # Available hours = paid invoice hours only (minus used hours) prepaid_hours = max(0.0, paid_hours - hours_used) total_available = prepaid_hours + credit_hours return { "total_available": round(total_available, 2), "hours_used": round(hours_used, 2), "prepaid_hours": round(prepaid_hours, 2), "credit_hours": round(credit_hours, 2), "credit_available": round(credit_available, 2), "highest_price": round(highest_price, 2), "whatsapp_number": whatsapp_number, "email": company_email, "packages_url": packages_url, } except Exception as e: # Log critical errors with full traceback _logger.error( "Error in get_available_hours for partner %s: %s", request.env.user.partner_id.id if request.env.user else "unknown", str(e), exc_info=True ) # Get contact information for error case try: company = request.env.company config_param = request.env["ir.config_parameter"].sudo() whatsapp_number = config_param.get_param( "helpdesk_extras.whatsapp_number", "" ) company_email = company.email or "" packages_url = config_param.get_param( "helpdesk_extras.packages_url", "/shop" ) except: whatsapp_number = "" company_email = "" packages_url = "/shop" return { "error": f"Error al calcular horas disponibles: {str(e)}", "total_available": 0.0, "hours_used": 0.0, "prepaid_hours": 0.0, "credit_hours": 0.0, "credit_available": 0.0, "highest_price": 0.0, "whatsapp_number": whatsapp_number, "email": company_email, "packages_url": packages_url, } @http.route("/helpdesk/form/check_block", type="json", auth="public", website=True) def check_form_block(self, team_id=None): """ Check if the helpdesk ticket form should be blocked. Returns True if form should be blocked (has collaborators and no available hours). Args: team_id: ID of the helpdesk team Returns: dict: { 'should_block': bool, # True if form should be blocked 'has_collaborators': bool, # True if team has collaborators 'has_hours': bool, # True if user has available hours 'message': str, # Message to show if blocked } """ try: # If user is not portal or public, don't block if not request.env.user or not request.env.user._is_portal(): return { "should_block": False, "has_collaborators": False, "has_hours": True, "message": "", } if not team_id: return { "should_block": False, "has_collaborators": False, "has_hours": True, "message": "", } # Get the team team = request.env["helpdesk.team"].sudo().browse(team_id) if not team.exists(): return { "should_block": False, "has_collaborators": False, "has_hours": True, "message": "", } # Check if team has collaborators has_collaborators = bool(team.collaborator_ids) # If no collaborators, don't block if not has_collaborators: return { "should_block": False, "has_collaborators": False, "has_hours": True, "message": "", } # Check if user has available hours hours_data = self.get_available_hours() has_hours = hours_data.get("total_available", 0.0) > 0.0 # Block only if has collaborators AND no hours should_block = has_collaborators and not has_hours # Get contact information for message config_param = request.env["ir.config_parameter"].sudo() whatsapp_number = config_param.get_param( "helpdesk_extras.whatsapp_number", "" ) company_email = request.env.company.email or "" packages_url = config_param.get_param( "helpdesk_extras.packages_url", "/shop" ) message = "" if should_block: message = "No tienes horas disponibles para crear un ticket. Por favor, contacta con nosotros para adquirir más horas." if whatsapp_number or company_email: contact_info = [] if whatsapp_number: contact_info.append(f"WhatsApp: {whatsapp_number}") if company_email: contact_info.append(f"Email: {company_email}") if contact_info: message += " " + " | ".join(contact_info) return { "should_block": should_block, "has_collaborators": has_collaborators, "has_hours": has_hours, "message": message, } except Exception as e: # Log critical errors with full traceback _logger.error( "Error in check_form_block for team_id %s: %s", team_id, str(e), exc_info=True ) # On error, don't block to avoid breaking the form return { "should_block": False, "has_collaborators": False, "has_hours": True, "message": "", }