website_helpdesk_hours.py 19 KB

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