website_helpdesk_hours.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  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. 'prepaid_hours_used': float, # Hours used from prepaid
  20. 'prepaid_hours_total': float, # Total prepaid hours (available + used)
  21. 'credit_hours': float, # Hours calculated from available credit
  22. 'credit_hours_used': float, # Hours used from credit
  23. 'credit_hours_total': float, # Total credit hours (available + used)
  24. 'credit_available': float, # Available credit amount
  25. 'highest_price': float, # Highest price unit for hours
  26. }
  27. """
  28. try:
  29. # Get contact information early for use in all return cases
  30. company = request.env.company
  31. config_param = request.env["ir.config_parameter"].sudo()
  32. whatsapp_number = config_param.get_param(
  33. "helpdesk_extras.whatsapp_number", ""
  34. )
  35. company_email = company.email or ""
  36. packages_url = config_param.get_param(
  37. "helpdesk_extras.packages_url", "/shop"
  38. )
  39. # Check if user is portal
  40. if not request.env.user._is_portal():
  41. return {
  42. "error": "Access denied: User is not a portal user",
  43. "total_available": 0.0,
  44. "hours_used": 0.0,
  45. "prepaid_hours": 0.0,
  46. "prepaid_hours_used": 0.0,
  47. "prepaid_hours_total": 0.0,
  48. "credit_hours": 0.0,
  49. "credit_hours_used": 0.0,
  50. "credit_hours_total": 0.0,
  51. "credit_available": 0.0,
  52. "highest_price": 0.0,
  53. "whatsapp_number": whatsapp_number,
  54. "email": company_email,
  55. "packages_url": packages_url,
  56. }
  57. partner = request.env.user.partner_id.commercial_partner_id
  58. user_partner = request.env.user.partner_id
  59. # Get UoM hour reference (use sudo to access uom.uom)
  60. try:
  61. uom_hour = request.env.ref("uom.product_uom_hour").sudo()
  62. except Exception as e:
  63. return {
  64. "error": f"Error getting UoM hour: {str(e)}",
  65. "total_available": 0.0,
  66. "hours_used": 0.0,
  67. "prepaid_hours": 0.0,
  68. "prepaid_hours_used": 0.0,
  69. "prepaid_hours_total": 0.0,
  70. "credit_hours": 0.0,
  71. "credit_hours_used": 0.0,
  72. "credit_hours_total": 0.0,
  73. "credit_available": 0.0,
  74. "highest_price": 0.0,
  75. "whatsapp_number": whatsapp_number,
  76. "email": company_email,
  77. "packages_url": packages_url,
  78. }
  79. # Get helpdesk teams where this user is a collaborator
  80. # Search by both user's partner and commercial partner (in case registered differently)
  81. collaborator_domain = [
  82. "|",
  83. ("partner_id", "=", user_partner.id),
  84. ("partner_id", "=", partner.id),
  85. ]
  86. collaborator_teams = (
  87. request.env["helpdesk.team.collaborator"]
  88. .sudo()
  89. .search(collaborator_domain)
  90. .mapped("team_id")
  91. )
  92. # If user is not a collaborator in any team, return empty results
  93. if not collaborator_teams:
  94. return {
  95. "total_available": 0.0,
  96. "hours_used": 0.0,
  97. "prepaid_hours": 0.0,
  98. "prepaid_hours_used": 0.0,
  99. "prepaid_hours_total": 0.0,
  100. "credit_hours": 0.0,
  101. "credit_hours_used": 0.0,
  102. "credit_hours_total": 0.0,
  103. "credit_available": 0.0,
  104. "highest_price": 0.0,
  105. "whatsapp_number": whatsapp_number,
  106. "email": company_email,
  107. "packages_url": packages_url,
  108. }
  109. # Get all prepaid sale order lines for the partner
  110. # Following Odoo's standard procedure from helpdesk_sale_timesheet
  111. SaleOrderLine = request.env["sale.order.line"].sudo()
  112. # Use the same domain that Odoo uses in _get_last_sol_of_customer
  113. # But extend it to include parent/child commercial partner
  114. # And also include orders where the partner is the invoice or shipping address
  115. # This is important for contacts that act as billing contacts for a company
  116. # Base domain for partner matching
  117. partner_domain = expression.OR([
  118. [("order_partner_id", "child_of", partner.id)],
  119. [("order_id.partner_invoice_id", "child_of", partner.id)],
  120. [("order_id.partner_shipping_id", "child_of", partner.id)],
  121. ])
  122. base_domain = [
  123. ("company_id", "=", company.id),
  124. # ("order_partner_id", "child_of", partner.id), # Replaced by partner_domain
  125. ("state", "in", ["sale", "done"]),
  126. ("remaining_hours", ">", 0), # Only lines with remaining hours
  127. ]
  128. # Combine base domain with partner domain
  129. domain = expression.AND([base_domain, partner_domain])
  130. # Check if sale_timesheet module is installed
  131. has_sale_timesheet = "sale_timesheet" in request.env.registry._init_modules
  132. if has_sale_timesheet:
  133. # Use _domain_sale_line_service to filter service products correctly
  134. # This is the same method Odoo uses internally in _get_last_sol_of_customer
  135. try:
  136. service_domain = SaleOrderLine._domain_sale_line_service(
  137. check_state=False
  138. )
  139. # Combine domains using expression.AND() as Odoo does
  140. domain = expression.AND([domain, service_domain])
  141. except Exception:
  142. # Fallback if _domain_sale_line_service is not available
  143. domain = expression.AND(
  144. [
  145. domain,
  146. [
  147. ("product_id.type", "=", "service"),
  148. ("product_id.service_policy", "=", "ordered_prepaid"),
  149. ("remaining_hours_available", "=", True),
  150. ],
  151. ]
  152. )
  153. # Search for prepaid lines following Odoo's standard procedure
  154. prepaid_sol_lines = SaleOrderLine.search(domain)
  155. # NEW LOGIC: Calculate hours based on invoice payment status
  156. # - paid_hours: hours from PAID invoices only (invoice line qty)
  157. # - unpaid_invoice_hours: hours from UNPAID invoices
  158. # - uninvoiced_hours: hours sold but not yet invoiced
  159. paid_hours = 0.0
  160. unpaid_invoice_hours = 0.0
  161. uninvoiced_hours = 0.0
  162. highest_price = 0.0
  163. for line in prepaid_sol_lines:
  164. try:
  165. # Get quantities for this line
  166. qty_sold = line.product_uom_qty or 0.0
  167. qty_invoiced = line.qty_invoiced or 0.0
  168. qty_delivered = line.qty_delivered or 0.0
  169. if qty_sold <= 0:
  170. continue
  171. # Track highest price unit
  172. if line.price_unit > highest_price:
  173. highest_price = line.price_unit
  174. # Calculate uninvoiced hours (sold but not yet invoiced)
  175. qty_uninvoiced = max(0.0, qty_sold - qty_invoiced)
  176. uninvoiced_hours += qty_uninvoiced
  177. # For invoiced portion, check payment status per invoice
  178. invoice_lines = line.invoice_lines.sudo()
  179. if not invoice_lines:
  180. # No invoices - all goes to uninvoiced (already counted above)
  181. continue
  182. # Process each invoice line
  183. for inv_line in invoice_lines:
  184. inv = inv_line.move_id
  185. # Only count posted customer invoices
  186. if inv.move_type != 'out_invoice' or inv.state != 'posted':
  187. continue
  188. inv_qty = inv_line.quantity or 0.0
  189. if inv.payment_state == 'paid':
  190. # Paid invoice - hours are available
  191. paid_hours += inv_qty
  192. else:
  193. # Not paid (not_paid, partial, in_payment, etc.)
  194. unpaid_invoice_hours += inv_qty
  195. except Exception as e:
  196. _logger.debug(
  197. "Error calculating hours for line %s: %s",
  198. line.id,
  199. str(e),
  200. exc_info=True
  201. )
  202. # If no lines with price, try to get price from all prepaid lines (historical)
  203. if highest_price == 0 and prepaid_sol_lines:
  204. for line in prepaid_sol_lines:
  205. if line.price_unit > highest_price:
  206. highest_price = line.price_unit
  207. # Calculate hours used from ALL prepaid lines (including those fully consumed)
  208. # This gives a complete picture of hours used by the customer
  209. # Use the same extended partner domain
  210. base_hours_used_domain = [
  211. ("company_id", "=", company.id),
  212. ("state", "in", ["sale", "done"]),
  213. ]
  214. hours_used_domain = expression.AND([base_hours_used_domain, partner_domain])
  215. if has_sale_timesheet:
  216. try:
  217. service_domain = SaleOrderLine._domain_sale_line_service(
  218. check_state=False
  219. )
  220. hours_used_domain = expression.AND(
  221. [hours_used_domain, service_domain]
  222. )
  223. except Exception:
  224. hours_used_domain = expression.AND(
  225. [
  226. hours_used_domain,
  227. [
  228. ("product_id.type", "=", "service"),
  229. ("product_id.service_policy", "=", "ordered_prepaid"),
  230. ("remaining_hours_available", "=", True),
  231. ],
  232. ]
  233. )
  234. all_prepaid_lines = SaleOrderLine.search(hours_used_domain)
  235. hours_used = 0.0
  236. for line in all_prepaid_lines:
  237. # Calculate hours used: qty_delivered converted to hours
  238. qty_delivered = line.qty_delivered or 0.0
  239. if qty_delivered > 0:
  240. qty_delivered_hours = (
  241. line.product_uom._compute_quantity(
  242. qty_delivered, uom_hour, raise_if_failure=False
  243. )
  244. or 0.0
  245. )
  246. hours_used += qty_delivered_hours
  247. # Calculate credit hours from partner credit limit
  248. credit_from_limit = 0.0
  249. credit_available = 0.0
  250. # Check if credit limit is configured
  251. partner_sudo = partner.sudo()
  252. if company.account_use_credit_limit and partner_sudo.credit_limit > 0:
  253. credit_used = partner_sudo.credit or 0.0
  254. credit_available = max(0.0, partner_sudo.credit_limit - credit_used)
  255. # Convert credit to hours using highest price
  256. if highest_price > 0 and credit_available > 0:
  257. credit_from_limit = credit_available / highest_price
  258. # NEW: Credit hours = uninvoiced hours + unpaid invoice hours + credit limit hours
  259. credit_hours = uninvoiced_hours + unpaid_invoice_hours + credit_from_limit
  260. # Available hours = paid invoice hours only (minus used hours)
  261. prepaid_hours = max(0.0, paid_hours - hours_used)
  262. # Calculate used hours separately for prepaid and credit
  263. # Hours are consumed from prepaid first, then from credit
  264. prepaid_hours_used = min(hours_used, paid_hours)
  265. credit_hours_used = max(0.0, hours_used - paid_hours)
  266. # Calculate totals (available + used)
  267. prepaid_hours_total = prepaid_hours + prepaid_hours_used # Should equal paid_hours
  268. credit_hours_total = credit_hours + credit_hours_used
  269. total_available = prepaid_hours + credit_hours
  270. return {
  271. "total_available": round(total_available, 2),
  272. "hours_used": round(hours_used, 2),
  273. "prepaid_hours": round(prepaid_hours, 2),
  274. "prepaid_hours_used": round(prepaid_hours_used, 2),
  275. "prepaid_hours_total": round(prepaid_hours_total, 2),
  276. "credit_hours": round(credit_hours, 2),
  277. "credit_hours_used": round(credit_hours_used, 2),
  278. "credit_hours_total": round(credit_hours_total, 2),
  279. "credit_available": round(credit_available, 2),
  280. "highest_price": round(highest_price, 2),
  281. "whatsapp_number": whatsapp_number,
  282. "email": company_email,
  283. "packages_url": packages_url,
  284. }
  285. except Exception as e:
  286. # Log critical errors with full traceback
  287. _logger.error(
  288. "Error in get_available_hours for partner %s: %s",
  289. request.env.user.partner_id.id if request.env.user else "unknown",
  290. str(e),
  291. exc_info=True
  292. )
  293. # Get contact information for error case
  294. try:
  295. company = request.env.company
  296. config_param = request.env["ir.config_parameter"].sudo()
  297. whatsapp_number = config_param.get_param(
  298. "helpdesk_extras.whatsapp_number", ""
  299. )
  300. company_email = company.email or ""
  301. packages_url = config_param.get_param(
  302. "helpdesk_extras.packages_url", "/shop"
  303. )
  304. except:
  305. whatsapp_number = ""
  306. company_email = ""
  307. packages_url = "/shop"
  308. return {
  309. "error": f"Error al calcular horas disponibles: {str(e)}",
  310. "total_available": 0.0,
  311. "hours_used": 0.0,
  312. "prepaid_hours": 0.0,
  313. "prepaid_hours_used": 0.0,
  314. "prepaid_hours_total": 0.0,
  315. "credit_hours": 0.0,
  316. "credit_hours_used": 0.0,
  317. "credit_hours_total": 0.0,
  318. "credit_available": 0.0,
  319. "highest_price": 0.0,
  320. "whatsapp_number": whatsapp_number,
  321. "email": company_email,
  322. "packages_url": packages_url,
  323. }
  324. @http.route("/helpdesk/form/check_block", type="json", auth="public", website=True)
  325. def check_form_block(self, team_id=None):
  326. """
  327. Check if the helpdesk ticket form should be blocked.
  328. Returns True if form should be blocked (has collaborators and no available hours).
  329. Args:
  330. team_id: ID of the helpdesk team
  331. Returns:
  332. dict: {
  333. 'should_block': bool, # True if form should be blocked
  334. 'has_collaborators': bool, # True if team has collaborators
  335. 'has_hours': bool, # True if user has available hours
  336. 'message': str, # Message to show if blocked
  337. }
  338. """
  339. try:
  340. # If user is not portal or public, don't block
  341. if not request.env.user or not request.env.user._is_portal():
  342. return {
  343. "should_block": False,
  344. "has_collaborators": False,
  345. "has_hours": True,
  346. "message": "",
  347. }
  348. if not team_id:
  349. return {
  350. "should_block": False,
  351. "has_collaborators": False,
  352. "has_hours": True,
  353. "message": "",
  354. }
  355. # Get the team
  356. team = request.env["helpdesk.team"].sudo().browse(team_id)
  357. if not team.exists():
  358. return {
  359. "should_block": False,
  360. "has_collaborators": False,
  361. "has_hours": True,
  362. "message": "",
  363. }
  364. # Check if team has collaborators
  365. has_collaborators = bool(team.collaborator_ids)
  366. # If no collaborators, don't block
  367. if not has_collaborators:
  368. return {
  369. "should_block": False,
  370. "has_collaborators": False,
  371. "has_hours": True,
  372. "message": "",
  373. }
  374. # Check if user has available hours
  375. hours_data = self.get_available_hours()
  376. has_hours = hours_data.get("total_available", 0.0) > 0.0
  377. # Block only if has collaborators AND no hours
  378. should_block = has_collaborators and not has_hours
  379. # Get contact information for message
  380. config_param = request.env["ir.config_parameter"].sudo()
  381. whatsapp_number = config_param.get_param(
  382. "helpdesk_extras.whatsapp_number", ""
  383. )
  384. company_email = request.env.company.email or ""
  385. packages_url = config_param.get_param(
  386. "helpdesk_extras.packages_url", "/shop"
  387. )
  388. message = ""
  389. if should_block:
  390. message = "No tienes horas disponibles para crear un ticket. Por favor, contacta con nosotros para adquirir más horas."
  391. if whatsapp_number or company_email:
  392. contact_info = []
  393. if whatsapp_number:
  394. contact_info.append(f"WhatsApp: {whatsapp_number}")
  395. if company_email:
  396. contact_info.append(f"Email: {company_email}")
  397. if contact_info:
  398. message += " " + " | ".join(contact_info)
  399. return {
  400. "should_block": should_block,
  401. "has_collaborators": has_collaborators,
  402. "has_hours": has_hours,
  403. "message": message,
  404. }
  405. except Exception as e:
  406. # Log critical errors with full traceback
  407. _logger.error(
  408. "Error in check_form_block for team_id %s: %s",
  409. team_id,
  410. str(e),
  411. exc_info=True
  412. )
  413. # On error, don't block to avoid breaking the form
  414. return {
  415. "should_block": False,
  416. "has_collaborators": False,
  417. "has_hours": True,
  418. "message": "",
  419. }