This commit is contained in:
gsinghpal
2026-03-09 15:21:22 -04:00
parent a3e85a23ef
commit acd3fc455e
243 changed files with 20459 additions and 4197 deletions

View File

@@ -57,3 +57,10 @@ class ResConfigSettings(models.TransientModel):
"kicks in for short-term rentals. Gives the customer time to "
"return without being charged.",
)
rental_short_term_reminder_hours = fields.Integer(
string="Short-Term Reminder (Hours Before Return)",
config_parameter='fusion_rental.short_term_reminder_hours',
default=2,
help="For short-term rentals, send the renewal reminder this many "
"hours before the scheduled return time. Default is 2 hours.",
)

View File

@@ -2,16 +2,28 @@ import base64
import logging
import uuid
from datetime import timedelta
from zoneinfo import ZoneInfo
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
COMPANY_TZ = 'America/Toronto'
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def write(self, vals):
res = super().write(vals)
if 'order_line' in vals:
for order in self:
if order.is_rental_order:
order.invalidate_recordset(['order_line'])
order.order_line._ensure_deposit_lines()
return res
# -----------------------------------------------------------------
# Renewal fields (v1)
# -----------------------------------------------------------------
@@ -233,6 +245,43 @@ class SaleOrder(models.Model):
else:
order.rental_next_renewal_date = False
# -----------------------------------------------------------------
# Timezone helpers -- all customer-facing dates use company TZ
# -----------------------------------------------------------------
def _get_tz(self):
"""Return the ZoneInfo for the company timezone."""
tz_name = (
self.company_id.partner_id.tz
or self.env.user.tz
or COMPANY_TZ
)
try:
return ZoneInfo(tz_name)
except Exception:
return ZoneInfo(COMPANY_TZ)
def _to_local_dt(self, utc_dt):
"""Convert a UTC datetime to the company local timezone."""
if not utc_dt:
return None
return utc_dt.replace(tzinfo=ZoneInfo('UTC')).astimezone(self._get_tz())
def _format_local_dt(self, utc_dt, fmt='%B %d, %Y'):
"""Format a UTC datetime in the company timezone."""
local = self._to_local_dt(utc_dt)
return local.strftime(fmt) if local else ''
def _format_local_dt_with_time(self, utc_dt):
"""Format a UTC datetime with time in the company timezone."""
return self._format_local_dt(utc_dt, '%B %d, %Y at %I:%M %p %Z')
def _local_date_today(self):
"""Return today's date in the company timezone (not UTC)."""
from datetime import datetime, timezone
now_utc = datetime.now(timezone.utc)
return now_utc.astimezone(self._get_tz()).date()
@api.depends('rental_start_date', 'rental_return_date')
def _compute_rental_original_duration(self):
for order in self:
@@ -350,13 +399,37 @@ class SaleOrder(models.Model):
# Return override -- intercept with inspection wizard
# -----------------------------------------------------------------
def _has_completed_delivery(self):
"""Return True if the product has been delivered.
Checks both stock pickings (warehouse flow) and qty_delivered on
rental lines (direct delivery without pickings).
"""
has_picking = bool(self.picking_ids.filtered(
lambda p: p.state == 'done' and p.picking_type_code == 'outgoing'
))
if has_picking:
return True
return any(
line.qty_delivered > 0
for line in self.order_line
if line.is_rental
)
def action_open_return(self):
"""Override to show the inspection wizard before processing returns.
If inspection has already been completed for this order (e.g. via a
technician task), fall through to the standard return flow.
Blocks returns entirely when no delivery has been completed.
"""
self.ensure_one()
if not self._has_completed_delivery():
raise UserError(_(
"Cannot return items: no delivery has been completed for "
"this order. Please process the delivery first."
))
if self.rental_inspection_status:
return super().action_open_return()
@@ -404,16 +477,39 @@ class SaleOrder(models.Model):
return (start + timedelta(days=offset_days)).date()
def _get_reminder_target_date(self):
"""When to send the renewal reminder (percentage of period before renewal)."""
"""When to send the renewal reminder (date component only).
For standard rentals: percentage-based offset before renewal date.
For short-term rentals: the return date (actual send time is
controlled by _short_term_reminder_ready).
"""
self.ensure_one()
if not self.rental_next_renewal_date:
return False
if self._is_short_term_rental():
if self.rental_return_date:
return self._to_local_dt(self.rental_return_date).date()
return self.rental_next_renewal_date
pct = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_rental.renewal_reminder_pct', '10'))
duration = self._get_rental_duration_days()
offset_days = max(round(duration * pct / 100), 1)
if not self.rental_next_renewal_date:
return False
return self.rental_next_renewal_date - timedelta(days=offset_days)
def _short_term_reminder_ready(self):
"""For short-term rentals, True when it's time to send the reminder.
Fires N hours before the scheduled return time so the customer
can bring the item back before renewal kicks in.
"""
self.ensure_one()
if not self.rental_return_date:
return False
hours_before = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_rental.short_term_reminder_hours', '2'))
reminder_time = self.rental_return_date - timedelta(hours=hours_before)
return fields.Datetime.now() >= reminder_time
def _is_short_term_rental(self):
"""True if rental duration is below the short-term threshold."""
self.ensure_one()
@@ -422,14 +518,26 @@ class SaleOrder(models.Model):
return self._get_rental_duration_days() < threshold
def _short_term_grace_expired(self):
"""For short-term rentals, True if the return time + grace has passed."""
"""For short-term rentals, True if the return time + grace has passed.
Compares current time against the scheduled return datetime plus
the configurable grace period (in hours). Both sides use UTC
internally so the comparison is correct regardless of timezone.
"""
self.ensure_one()
grace_hours = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_rental.short_term_grace_hours', '1'))
if not self.rental_return_date:
return False
grace_deadline = self.rental_return_date + timedelta(hours=grace_hours)
return fields.Datetime.now() >= grace_deadline
now = fields.Datetime.now()
_logger.debug(
"Short-term grace check for %s: now=%s, return=%s, "
"grace=%dh, deadline=%s, expired=%s",
self.name, now, self.rental_return_date,
grace_hours, grace_deadline, now >= grace_deadline,
)
return now >= grace_deadline
def _get_rental_only_lines(self):
"""Return order lines that should be invoiced on renewal.
@@ -711,7 +819,7 @@ class SaleOrder(models.Model):
"Your rental %(ref)s renews on %(date)s. "
"To cancel, visit %(url)s or call %(phone)s.",
ref=self.name,
date=self.rental_return_date.strftime('%m/%d/%Y') if self.rental_return_date else '',
date=self._format_local_dt(self.rental_return_date, '%m/%d/%Y'),
url=cancel_url,
phone=company_phone,
)
@@ -724,7 +832,7 @@ class SaleOrder(models.Model):
@api.model
def _cron_rental_renewal_reminders(self):
"""Cron: send renewal reminders based on percentage of rental period."""
today = fields.Date.today()
today = self._local_date_today()
orders = self.search([
('is_rental_order', '=', True),
('rental_auto_renew', '=', True),
@@ -740,6 +848,9 @@ class SaleOrder(models.Model):
target = order._get_reminder_target_date()
if not target or today < target:
continue
if order._is_short_term_rental():
if not order._short_term_reminder_ready():
continue
cancel_token = order._prepare_renewal_cancellation_token()
order._send_renewal_reminder_email(cancel_token)
order._send_renewal_reminder_sms(cancel_token)
@@ -760,7 +871,7 @@ class SaleOrder(models.Model):
@api.model
def _cron_rental_auto_renewals(self):
"""Cron: auto-renew rentals that have reached their return date."""
today = fields.Date.today()
today = self._local_date_today()
staleness_cutoff = today - timedelta(days=self._RENEWAL_STALENESS_DAYS)
orders = self.search([
@@ -1100,8 +1211,20 @@ class SaleOrder(models.Model):
)
return max(hold, 0)
def _has_items_returned(self):
"""Return True if at least one rental line has qty_returned > 0."""
return any(
line.qty_returned > 0
for line in self.order_line
if line.is_rental
)
def _refund_security_deposit(self):
"""Initiate security deposit refund with configurable hold period."""
"""Initiate security deposit refund with configurable hold period.
Validates that the product was actually delivered and returned
before scheduling the refund.
"""
self.ensure_one()
if self.rental_deposit_status != 'collected':
return
@@ -1111,6 +1234,18 @@ class SaleOrder(models.Model):
self.name,
)
return
if not self._has_completed_delivery():
_logger.warning(
"Skipping deposit refund for %s: no delivery completed.",
self.name,
)
return
if not self._has_items_returned():
_logger.warning(
"Skipping deposit refund for %s: no items returned.",
self.name,
)
return
hold_days = self._get_deposit_hold_days()
self.write({
'rental_deposit_status': 'refund_hold',
@@ -1125,8 +1260,24 @@ class SaleOrder(models.Model):
self._send_deposit_refund_initiated_email()
def _process_deposit_refund(self):
"""Process the actual deposit refund via the deposit wizard, send receipt, and auto-close."""
"""Process the actual deposit refund via the deposit wizard, send receipt, and auto-close.
Performs a final safety check to ensure the product was delivered
and returned before issuing the refund.
"""
self.ensure_one()
if not self._has_completed_delivery():
_logger.warning(
"Deposit refund aborted for %s: no delivery completed.",
self.name,
)
return
if not self._has_items_returned():
_logger.warning(
"Deposit refund aborted for %s: no items returned.",
self.name,
)
return
deposit_total = 0.0
if self.rental_deposit_invoice_id:
deposit_total = self.rental_deposit_invoice_id.amount_total
@@ -1513,14 +1664,36 @@ class SaleOrder(models.Model):
@api.model
def _cron_rental_deposit_refunds(self):
"""Cron: process deposit refunds after the 3-day hold period."""
today = fields.Date.today()
"""Cron: process deposit refunds after the hold period.
Re-validates each order before processing to ensure the product
was actually delivered, returned, and inspected.
"""
today = self._local_date_today()
orders = self.search([
('is_rental_order', '=', True),
('rental_deposit_status', '=', 'refund_hold'),
('rental_deposit_refund_date', '<=', today),
])
for order in orders:
if not order.rental_inspection_status:
_logger.error(
"Deposit refund skipped for %s: inspection not completed.",
order.name,
)
continue
if not order._has_completed_delivery():
_logger.error(
"Deposit refund skipped for %s: no delivery completed.",
order.name,
)
continue
if not order._has_items_returned():
_logger.error(
"Deposit refund skipped for %s: no items returned.",
order.name,
)
continue
try:
order._process_deposit_refund()
_logger.info("Processed deposit refund for %s", order.name)
@@ -1571,7 +1744,7 @@ class SaleOrder(models.Model):
@api.model
def _cron_rental_marketing_emails(self):
"""Cron: send purchase marketing email based on percentage of rental period."""
today = fields.Date.today()
today = self._local_date_today()
orders = self.search([
('is_rental_order', '=', True),
('state', '=', 'sale'),

View File

@@ -1,5 +1,9 @@
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
@@ -16,11 +20,10 @@ class SaleOrderLine(models.Model):
help="The rental product line this deposit is associated with.",
)
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
def _ensure_deposit_lines(self):
"""Check rental lines and create missing security deposit lines."""
deposit_vals = []
for line in lines:
for line in self:
if not line.is_rental or line.is_security_deposit:
continue
if not line.order_id.is_rental_order:
@@ -29,9 +32,9 @@ class SaleOrderLine(models.Model):
if deposit_amount <= 0:
continue
existing = line.order_id.order_line.filtered(
lambda l: (
lambda l, src=line: (
l.is_security_deposit
and l.rental_deposit_source_line_id == line
and l.rental_deposit_source_line_id == src
)
)
if existing:
@@ -41,16 +44,36 @@ class SaleOrderLine(models.Model):
'order_id': line.order_id.id,
'product_id': deposit_product.id,
'product_uom_id': deposit_product.uom_id.id,
'name': f"SECURITY DEPOSIT - REFUNDABLE - {line.product_id.display_name}",
'name': (
"SECURITY DEPOSIT - REFUNDABLE UPON RETURN IN "
f"GOOD & CLEAN CONDITION - {line.product_id.display_name}"
),
'product_uom_qty': 1,
'price_unit': deposit_amount,
'is_security_deposit': True,
'rental_deposit_source_line_id': line.id,
})
if deposit_vals:
super().create(deposit_vals)
self.env['sale.order.line'].with_context(
skip_deposit_check=True,
).create(deposit_vals)
return bool(deposit_vals)
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
if not self.env.context.get('skip_deposit_check'):
lines._ensure_deposit_lines()
return lines
def write(self, vals):
res = super().write(vals)
if self.env.context.get('skip_deposit_check'):
return res
if 'is_rental' in vals or 'product_id' in vals:
self._ensure_deposit_lines()
return res
def unlink(self):
deposit_lines = self.env['sale.order.line']
for line in self: