| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479 |
- # -*- 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)
- 'prepaid_hours_used': float, # Hours used from prepaid
- 'prepaid_hours_total': float, # Total prepaid hours (available + used)
- 'credit_hours': float, # Hours calculated from available credit
- 'credit_hours_used': float, # Hours used from credit
- 'credit_hours_total': float, # Total credit hours (available + used)
- '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,
- "prepaid_hours_used": 0.0,
- "prepaid_hours_total": 0.0,
- "credit_hours": 0.0,
- "credit_hours_used": 0.0,
- "credit_hours_total": 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.sudo().commercial_partner_id
- user_partner = request.env.user.partner_id.sudo()
- # 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,
- "prepaid_hours_used": 0.0,
- "prepaid_hours_total": 0.0,
- "credit_hours": 0.0,
- "credit_hours_used": 0.0,
- "credit_hours_total": 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,
- "prepaid_hours_used": 0.0,
- "prepaid_hours_total": 0.0,
- "credit_hours": 0.0,
- "credit_hours_used": 0.0,
- "credit_hours_total": 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)
-
- # Calculate used hours separately for prepaid and credit
- # Hours are consumed from prepaid first, then from credit
- prepaid_hours_used = min(hours_used, paid_hours)
- credit_hours_used = max(0.0, hours_used - paid_hours)
-
- # Calculate totals (available + used)
- prepaid_hours_total = prepaid_hours + prepaid_hours_used # Should equal paid_hours
- credit_hours_total = credit_hours + credit_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),
- "prepaid_hours_used": round(prepaid_hours_used, 2),
- "prepaid_hours_total": round(prepaid_hours_total, 2),
- "credit_hours": round(credit_hours, 2),
- "credit_hours_used": round(credit_hours_used, 2),
- "credit_hours_total": round(credit_hours_total, 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,
- "prepaid_hours_used": 0.0,
- "prepaid_hours_total": 0.0,
- "credit_hours": 0.0,
- "credit_hours_used": 0.0,
- "credit_hours_total": 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": "",
- }
|