| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- # -*- 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
- # 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
- collaborator_teams = (
- request.env["helpdesk.team.collaborator"]
- .sudo()
- .search([("partner_id", "=", partner.id)])
- .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)
- # Filter lines from orders that have received payment
- # Only consider hours from orders with paid invoices
- helpdesk_team_model = request.env["helpdesk.team"]
- # Filter lines from orders that have received payment
- # Use explicit loop to handle exceptions properly
- paid_prepaid_lines = request.env["sale.order.line"].sudo()
- for line in prepaid_sol_lines:
- try:
- if helpdesk_team_model._is_order_paid(line.order_id):
- paid_prepaid_lines |= line
- except Exception as e:
- # Log exception only in debug mode
- _logger.debug(
- "Error checking payment for line %s, order %s: %s",
- line.id,
- line.order_id.id,
- str(e),
- exc_info=True
- )
- # Calculate prepaid hours using Odoo's remaining_hours field
- # This is the correct way as it handles UOM conversion automatically
- prepaid_hours = 0.0
- highest_price = 0.0
- for line in paid_prepaid_lines:
- # Use remaining_hours directly (already in hours, handles UOM conversion)
- # This is the field Odoo uses and calculates correctly
- remaining = line.remaining_hours or 0.0
- prepaid_hours += max(0.0, remaining)
- # Track highest price unit
- if line.price_unit > highest_price:
- highest_price = line.price_unit
- # If no paid lines with price, try to get price from all prepaid lines (historical)
- # This is needed to calculate credit_hours even if there are no paid lines currently
- 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),
- # ("order_partner_id", "child_of", partner.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)
- # Filter lines from orders that have received payment
- # Only consider hours used from orders with paid invoices
- # Use explicit loop to handle exceptions properly
- paid_all_prepaid_lines = request.env["sale.order.line"].sudo()
- for line in all_prepaid_lines:
- try:
- if helpdesk_team_model._is_order_paid(line.order_id):
- paid_all_prepaid_lines |= line
- except Exception as e:
- # Log exception only in debug mode
- _logger.debug(
- "Error checking payment for line %s, order %s: %s",
- line.id,
- line.order_id.id,
- str(e),
- exc_info=True
- )
- hours_used = 0.0
- for line in paid_all_prepaid_lines:
- # Calculate hours used: qty_delivered converted to hours
- # Use the same UOM conversion that Odoo uses
- 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
- credit_hours = 0.0
- credit_available = 0.0
- # Check if credit limit is configured
- # Use sudo to access credit fields which may have restricted access
- 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_hours = credit_available / highest_price
- elif highest_price == 0 and credit_available > 0:
- # If no hours sold yet, we can't calculate credit hours
- # But we still show the credit available
- credit_hours = 0.0
- 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": "",
- }
|