changes
This commit is contained in:
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user