website_helpdesk_hours.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import logging
  4. from odoo import http
  5. from odoo.http import request
  6. from odoo.osv import expression
  7. _logger = logging.getLogger(__name__)
  8. class WebsiteHelpdeskHours(http.Controller):
  9. """Controller for helpdesk hours widget"""
  10. @http.route("/helpdesk/hours/available", type="json", auth="user", website=True)
  11. def get_available_hours(self):
  12. """
  13. Calculate available hours for the authenticated portal user's partner.
  14. Returns:
  15. dict: {
  16. 'total_available': float, # Total hours available
  17. 'hours_used': float, # Hours already delivered/used
  18. 'prepaid_hours': float, # Hours from prepaid orders (not delivered)
  19. 'credit_hours': float, # Hours calculated from available credit
  20. 'credit_available': float, # Available credit amount
  21. 'highest_price': float, # Highest price unit for hours
  22. }
  23. """
  24. try:
  25. # Get contact information early for use in all return cases
  26. company = request.env.company
  27. config_param = request.env["ir.config_parameter"].sudo()
  28. whatsapp_number = config_param.get_param(
  29. "helpdesk_extras.whatsapp_number", ""
  30. )
  31. company_email = company.email or ""
  32. packages_url = config_param.get_param(
  33. "helpdesk_extras.packages_url", "/shop"
  34. )
  35. # Check if user is portal
  36. if not request.env.user._is_portal():
  37. return {
  38. "error": "Access denied: User is not a portal user",
  39. "total_available": 0.0,
  40. "hours_used": 0.0,
  41. "prepaid_hours": 0.0,
  42. "credit_hours": 0.0,
  43. "credit_available": 0.0,
  44. "highest_price": 0.0,
  45. "whatsapp_number": whatsapp_number,
  46. "email": company_email,
  47. "packages_url": packages_url,
  48. }
  49. partner = request.env.user.partner_id.commercial_partner_id
  50. # Get UoM hour reference (use sudo to access uom.uom)
  51. try:
  52. uom_hour = request.env.ref("uom.product_uom_hour").sudo()
  53. except Exception as e:
  54. return {
  55. "error": f"Error getting UoM hour: {str(e)}",
  56. "total_available": 0.0,
  57. "hours_used": 0.0,
  58. "prepaid_hours": 0.0,
  59. "credit_hours": 0.0,
  60. "credit_available": 0.0,
  61. "highest_price": 0.0,
  62. "whatsapp_number": whatsapp_number,
  63. "email": company_email,
  64. "packages_url": packages_url,
  65. }
  66. # Get helpdesk teams where this user is a collaborator
  67. collaborator_teams = (
  68. request.env["helpdesk.team.collaborator"]
  69. .sudo()
  70. .search([("partner_id", "=", partner.id)])
  71. .mapped("team_id")
  72. )
  73. # If user is not a collaborator in any team, return empty results
  74. if not collaborator_teams:
  75. return {
  76. "total_available": 0.0,
  77. "hours_used": 0.0,
  78. "prepaid_hours": 0.0,
  79. "credit_hours": 0.0,
  80. "credit_available": 0.0,
  81. "highest_price": 0.0,
  82. "whatsapp_number": whatsapp_number,
  83. "email": company_email,
  84. "packages_url": packages_url,
  85. }
  86. # Get all prepaid sale order lines for the partner
  87. # Following Odoo's standard procedure from helpdesk_sale_timesheet
  88. SaleOrderLine = request.env["sale.order.line"].sudo()
  89. # Use the same domain that Odoo uses in _get_last_sol_of_customer
  90. # But extend it to include parent/child commercial partner
  91. # And also include orders where the partner is the invoice or shipping address
  92. # This is important for contacts that act as billing contacts for a company
  93. # Base domain for partner matching
  94. partner_domain = expression.OR([
  95. [("order_partner_id", "child_of", partner.id)],
  96. [("order_id.partner_invoice_id", "child_of", partner.id)],
  97. [("order_id.partner_shipping_id", "child_of", partner.id)],
  98. ])
  99. base_domain = [
  100. ("company_id", "=", company.id),
  101. # ("order_partner_id", "child_of", partner.id), # Replaced by partner_domain
  102. ("state", "in", ["sale", "done"]),
  103. ("remaining_hours", ">", 0), # Only lines with remaining hours
  104. ]
  105. # Combine base domain with partner domain
  106. domain = expression.AND([base_domain, partner_domain])
  107. # Check if sale_timesheet module is installed
  108. has_sale_timesheet = "sale_timesheet" in request.env.registry._init_modules
  109. if has_sale_timesheet:
  110. # Use _domain_sale_line_service to filter service products correctly
  111. # This is the same method Odoo uses internally in _get_last_sol_of_customer
  112. try:
  113. service_domain = SaleOrderLine._domain_sale_line_service(
  114. check_state=False
  115. )
  116. # Combine domains using expression.AND() as Odoo does
  117. domain = expression.AND([domain, service_domain])
  118. except Exception:
  119. # Fallback if _domain_sale_line_service is not available
  120. domain = expression.AND(
  121. [
  122. domain,
  123. [
  124. ("product_id.type", "=", "service"),
  125. ("product_id.service_policy", "=", "ordered_prepaid"),
  126. ("remaining_hours_available", "=", True),
  127. ],
  128. ]
  129. )
  130. # Search for prepaid lines following Odoo's standard procedure
  131. prepaid_sol_lines = SaleOrderLine.search(domain)
  132. # Filter lines from orders that have received payment
  133. # Only consider hours from orders with paid invoices
  134. helpdesk_team_model = request.env["helpdesk.team"]
  135. # Filter lines from orders that have received payment
  136. # Use explicit loop to handle exceptions properly
  137. paid_prepaid_lines = request.env["sale.order.line"].sudo()
  138. for line in prepaid_sol_lines:
  139. try:
  140. if helpdesk_team_model._is_order_paid(line.order_id):
  141. paid_prepaid_lines |= line
  142. except Exception as e:
  143. # Log exception only in debug mode
  144. _logger.debug(
  145. "Error checking payment for line %s, order %s: %s",
  146. line.id,
  147. line.order_id.id,
  148. str(e),
  149. exc_info=True
  150. )
  151. # Calculate prepaid hours using Odoo's remaining_hours field
  152. # This is the correct way as it handles UOM conversion automatically
  153. prepaid_hours = 0.0
  154. highest_price = 0.0
  155. for line in paid_prepaid_lines:
  156. # Use remaining_hours directly (already in hours, handles UOM conversion)
  157. # This is the field Odoo uses and calculates correctly
  158. remaining = line.remaining_hours or 0.0
  159. prepaid_hours += max(0.0, remaining)
  160. # Track highest price unit
  161. if line.price_unit > highest_price:
  162. highest_price = line.price_unit
  163. # If no paid lines with price, try to get price from all prepaid lines (historical)
  164. # This is needed to calculate credit_hours even if there are no paid lines currently
  165. if highest_price == 0 and prepaid_sol_lines:
  166. for line in prepaid_sol_lines:
  167. if line.price_unit > highest_price:
  168. highest_price = line.price_unit
  169. # Calculate hours used from ALL prepaid lines (including those fully consumed)
  170. # This gives a complete picture of hours used by the customer
  171. # Use the same extended partner domain
  172. base_hours_used_domain = [
  173. ("company_id", "=", company.id),
  174. # ("order_partner_id", "child_of", partner.id),
  175. ("state", "in", ["sale", "done"]),
  176. ]
  177. hours_used_domain = expression.AND([base_hours_used_domain, partner_domain])
  178. if has_sale_timesheet:
  179. try:
  180. service_domain = SaleOrderLine._domain_sale_line_service(
  181. check_state=False
  182. )
  183. hours_used_domain = expression.AND(
  184. [hours_used_domain, service_domain]
  185. )
  186. except Exception:
  187. hours_used_domain = expression.AND(
  188. [
  189. hours_used_domain,
  190. [
  191. ("product_id.type", "=", "service"),
  192. ("product_id.service_policy", "=", "ordered_prepaid"),
  193. ("remaining_hours_available", "=", True),
  194. ],
  195. ]
  196. )
  197. all_prepaid_lines = SaleOrderLine.search(hours_used_domain)
  198. # Filter lines from orders that have received payment
  199. # Only consider hours used from orders with paid invoices
  200. # Use explicit loop to handle exceptions properly
  201. paid_all_prepaid_lines = request.env["sale.order.line"].sudo()
  202. for line in all_prepaid_lines:
  203. try:
  204. if helpdesk_team_model._is_order_paid(line.order_id):
  205. paid_all_prepaid_lines |= line
  206. except Exception as e:
  207. # Log exception only in debug mode
  208. _logger.debug(
  209. "Error checking payment for line %s, order %s: %s",
  210. line.id,
  211. line.order_id.id,
  212. str(e),
  213. exc_info=True
  214. )
  215. hours_used = 0.0
  216. for line in paid_all_prepaid_lines:
  217. # Calculate hours used: qty_delivered converted to hours
  218. # Use the same UOM conversion that Odoo uses
  219. qty_delivered = line.qty_delivered or 0.0
  220. if qty_delivered > 0:
  221. qty_delivered_hours = (
  222. line.product_uom._compute_quantity(
  223. qty_delivered, uom_hour, raise_if_failure=False
  224. )
  225. or 0.0
  226. )
  227. hours_used += qty_delivered_hours
  228. # Calculate credit hours
  229. credit_hours = 0.0
  230. credit_available = 0.0
  231. # Check if credit limit is configured
  232. # Use sudo to access credit fields which may have restricted access
  233. partner_sudo = partner.sudo()
  234. if company.account_use_credit_limit and partner_sudo.credit_limit > 0:
  235. credit_used = partner_sudo.credit or 0.0
  236. credit_available = max(0.0, partner_sudo.credit_limit - credit_used)
  237. # Convert credit to hours using highest price
  238. if highest_price > 0 and credit_available > 0:
  239. credit_hours = credit_available / highest_price
  240. elif highest_price == 0 and credit_available > 0:
  241. # If no hours sold yet, we can't calculate credit hours
  242. # But we still show the credit available
  243. credit_hours = 0.0
  244. total_available = prepaid_hours + credit_hours
  245. return {
  246. "total_available": round(total_available, 2),
  247. "hours_used": round(hours_used, 2),
  248. "prepaid_hours": round(prepaid_hours, 2),
  249. "credit_hours": round(credit_hours, 2),
  250. "credit_available": round(credit_available, 2),
  251. "highest_price": round(highest_price, 2),
  252. "whatsapp_number": whatsapp_number,
  253. "email": company_email,
  254. "packages_url": packages_url,
  255. }
  256. except Exception as e:
  257. # Log critical errors with full traceback
  258. _logger.error(
  259. "Error in get_available_hours for partner %s: %s",
  260. request.env.user.partner_id.id if request.env.user else "unknown",
  261. str(e),
  262. exc_info=True
  263. )
  264. # Get contact information for error case
  265. try:
  266. company = request.env.company
  267. config_param = request.env["ir.config_parameter"].sudo()
  268. whatsapp_number = config_param.get_param(
  269. "helpdesk_extras.whatsapp_number", ""
  270. )
  271. company_email = company.email or ""
  272. packages_url = config_param.get_param(
  273. "helpdesk_extras.packages_url", "/shop"
  274. )
  275. except:
  276. whatsapp_number = ""
  277. company_email = ""
  278. packages_url = "/shop"
  279. return {
  280. "error": f"Error al calcular horas disponibles: {str(e)}",
  281. "total_available": 0.0,
  282. "hours_used": 0.0,
  283. "prepaid_hours": 0.0,
  284. "credit_hours": 0.0,
  285. "credit_available": 0.0,
  286. "highest_price": 0.0,
  287. "whatsapp_number": whatsapp_number,
  288. "email": company_email,
  289. "packages_url": packages_url,
  290. }
  291. @http.route("/helpdesk/form/check_block", type="json", auth="public", website=True)
  292. def check_form_block(self, team_id=None):
  293. """
  294. Check if the helpdesk ticket form should be blocked.
  295. Returns True if form should be blocked (has collaborators and no available hours).
  296. Args:
  297. team_id: ID of the helpdesk team
  298. Returns:
  299. dict: {
  300. 'should_block': bool, # True if form should be blocked
  301. 'has_collaborators': bool, # True if team has collaborators
  302. 'has_hours': bool, # True if user has available hours
  303. 'message': str, # Message to show if blocked
  304. }
  305. """
  306. try:
  307. # If user is not portal or public, don't block
  308. if not request.env.user or not request.env.user._is_portal():
  309. return {
  310. "should_block": False,
  311. "has_collaborators": False,
  312. "has_hours": True,
  313. "message": "",
  314. }
  315. if not team_id:
  316. return {
  317. "should_block": False,
  318. "has_collaborators": False,
  319. "has_hours": True,
  320. "message": "",
  321. }
  322. # Get the team
  323. team = request.env["helpdesk.team"].sudo().browse(team_id)
  324. if not team.exists():
  325. return {
  326. "should_block": False,
  327. "has_collaborators": False,
  328. "has_hours": True,
  329. "message": "",
  330. }
  331. # Check if team has collaborators
  332. has_collaborators = bool(team.collaborator_ids)
  333. # If no collaborators, don't block
  334. if not has_collaborators:
  335. return {
  336. "should_block": False,
  337. "has_collaborators": False,
  338. "has_hours": True,
  339. "message": "",
  340. }
  341. # Check if user has available hours
  342. hours_data = self.get_available_hours()
  343. has_hours = hours_data.get("total_available", 0.0) > 0.0
  344. # Block only if has collaborators AND no hours
  345. should_block = has_collaborators and not has_hours
  346. # Get contact information for message
  347. config_param = request.env["ir.config_parameter"].sudo()
  348. whatsapp_number = config_param.get_param(
  349. "helpdesk_extras.whatsapp_number", ""
  350. )
  351. company_email = request.env.company.email or ""
  352. packages_url = config_param.get_param(
  353. "helpdesk_extras.packages_url", "/shop"
  354. )
  355. message = ""
  356. if should_block:
  357. message = "No tienes horas disponibles para crear un ticket. Por favor, contacta con nosotros para adquirir más horas."
  358. if whatsapp_number or company_email:
  359. contact_info = []
  360. if whatsapp_number:
  361. contact_info.append(f"WhatsApp: {whatsapp_number}")
  362. if company_email:
  363. contact_info.append(f"Email: {company_email}")
  364. if contact_info:
  365. message += " " + " | ".join(contact_info)
  366. return {
  367. "should_block": should_block,
  368. "has_collaborators": has_collaborators,
  369. "has_hours": has_hours,
  370. "message": message,
  371. }
  372. except Exception as e:
  373. # Log critical errors with full traceback
  374. _logger.error(
  375. "Error in check_form_block for team_id %s: %s",
  376. team_id,
  377. str(e),
  378. exc_info=True
  379. )
  380. # On error, don't block to avoid breaking the form
  381. return {
  382. "should_block": False,
  383. "has_collaborators": False,
  384. "has_hours": True,
  385. "message": "",
  386. }