feat: hide authorizer for rental orders, auto-set sale type

Rental orders no longer show the "Authorizer Required?" question or
the Authorizer field. The sale type is automatically set to 'Rentals'
when creating or confirming a rental order. Validation logic also
skips authorizer checks for rental sale type.

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-02-25 23:33:23 -05:00
parent 3c8f83b8e6
commit 14fe9ab716
51 changed files with 4192 additions and 822 deletions

View File

@@ -1,2 +1,3 @@
from . import manual_renewal_wizard
from . import deposit_deduction_wizard
from . import rental_return_wizard

View File

@@ -1,10 +1,14 @@
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class DepositDeductionWizard(models.TransientModel):
class DepositProcessWizard(models.TransientModel):
_name = 'deposit.deduction.wizard'
_description = 'Security Deposit Deduction'
_description = 'Process Security Deposit'
order_id = fields.Many2one(
'sale.order',
@@ -12,53 +16,406 @@ class DepositDeductionWizard(models.TransientModel):
required=True,
readonly=True,
)
partner_id = fields.Many2one(
related='order_id.partner_id',
string="Customer",
)
deposit_total = fields.Float(
string="Deposit Amount",
readonly=True,
)
action_type = fields.Selection(
selection=[
('full_refund', "Full Refund"),
('partial_refund', "Partial Refund (Deduction for Damages)"),
('no_refund', "Full Deduction (Damages Exceed Deposit)"),
('sold', "Customer Purchased Rental Product"),
],
string="Action",
required=True,
default='full_refund',
)
deduction_amount = fields.Float(
string="Deduction Amount",
required=True,
help="Amount to deduct from the security deposit for damages.",
)
reason = fields.Text(
string="Reason for Deduction",
required=True,
string="Reason",
)
remaining_preview = fields.Float(
string="Remaining to Refund",
compute='_compute_remaining_preview',
compute='_compute_previews',
)
overage_preview = fields.Float(
string="Additional Invoice Amount",
compute='_compute_remaining_preview',
help="Amount exceeding the deposit that will be invoiced to the customer.",
compute='_compute_previews',
)
refund_preview = fields.Float(
string="Refund Amount",
compute='_compute_previews',
)
has_card_on_file = fields.Boolean(
string="Card on File",
compute='_compute_has_card',
)
@api.depends('deposit_total', 'deduction_amount')
def _compute_remaining_preview(self):
@api.depends('deposit_total', 'deduction_amount', 'action_type')
def _compute_previews(self):
for wizard in self:
diff = wizard.deposit_total - wizard.deduction_amount
if diff >= 0:
wizard.remaining_preview = diff
if wizard.action_type == 'full_refund':
wizard.refund_preview = wizard.deposit_total
wizard.remaining_preview = wizard.deposit_total
wizard.overage_preview = 0.0
else:
elif wizard.action_type == 'sold':
wizard.refund_preview = wizard.deposit_total
wizard.remaining_preview = wizard.deposit_total
wizard.overage_preview = 0.0
elif wizard.action_type == 'partial_refund':
diff = wizard.deposit_total - wizard.deduction_amount
if diff >= 0:
wizard.remaining_preview = diff
wizard.refund_preview = diff
wizard.overage_preview = 0.0
else:
wizard.remaining_preview = 0.0
wizard.refund_preview = 0.0
wizard.overage_preview = abs(diff)
elif wizard.action_type == 'no_refund':
wizard.remaining_preview = 0.0
wizard.overage_preview = abs(diff)
wizard.refund_preview = 0.0
wizard.overage_preview = max(
wizard.deduction_amount - wizard.deposit_total, 0.0,
)
else:
wizard.refund_preview = 0.0
wizard.remaining_preview = 0.0
wizard.overage_preview = 0.0
def action_confirm_deduction(self):
@api.depends('order_id')
def _compute_has_card(self):
for wizard in self:
wizard.has_card_on_file = bool(
wizard.order_id and wizard.order_id.rental_payment_token_id
)
def action_confirm(self):
"""Dispatch to the appropriate deposit processing path."""
self.ensure_one()
order = self.order_id
if self.action_type == 'full_refund':
return self._process_full_refund(order)
elif self.action_type == 'partial_refund':
return self._process_partial_refund(order)
elif self.action_type == 'no_refund':
return self._process_no_refund(order)
elif self.action_type == 'sold':
return self._process_sold(order)
return {'type': 'ir.actions.act_window_close'}
def _process_full_refund(self, order):
"""Full deposit refund: credit note, Poynt refund, close rental."""
invoice = order.rental_deposit_invoice_id
if not invoice:
raise UserError(_("No deposit invoice found."))
credit_note = self._create_deposit_credit_note(
order, invoice, invoice.amount_total,
_("Security deposit full refund for %s", order.name),
)
if credit_note:
self._process_poynt_refund(order, credit_note)
order.rental_deposit_status = 'refunded'
order._send_deposit_refund_email()
order.message_post(body=_(
"Security deposit fully refunded: %s",
self._format_amount(invoice.amount_total, order),
))
self._close_rental(order)
return {'type': 'ir.actions.act_window_close'}
def _process_partial_refund(self, order):
"""Partial refund with deduction for damages."""
if self.deduction_amount <= 0:
raise UserError(_("Deduction amount must be greater than zero."))
if not self.reason:
raise UserError(_("A reason is required for deductions."))
invoice = order.rental_deposit_invoice_id
if not invoice:
raise UserError(_("No deposit invoice found."))
deposit_total = invoice.amount_total
if self.deduction_amount >= deposit_total:
return self._process_no_refund(order)
refund_amount = deposit_total - self.deduction_amount
credit_note = self._create_deposit_credit_note(
order, invoice, refund_amount,
_("Partial deposit refund for %s (deduction: %s)",
order.name,
self._format_amount(self.deduction_amount, order)),
)
if credit_note:
self._process_poynt_refund(order, credit_note)
order.rental_deposit_status = 'deducted'
order._send_deposit_refund_email()
order = self.order_id
order._deduct_security_deposit(self.deduction_amount)
order.message_post(body=_(
"Security deposit deduction of %s processed.\nReason: %s",
self.env['ir.qweb.field.monetary'].value_to_html(
self.deduction_amount,
{'display_currency': order.currency_id},
),
"Security deposit partial refund: %s refunded, %s deducted.\nReason: %s",
self._format_amount(refund_amount, order),
self._format_amount(self.deduction_amount, order),
self.reason,
))
self._close_rental(order)
return {'type': 'ir.actions.act_window_close'}
def _process_no_refund(self, order):
"""Full deduction: no refund, create overage invoice if needed."""
if not self.reason:
raise UserError(_("A reason is required for deductions."))
invoice = order.rental_deposit_invoice_id
deposit_total = invoice.amount_total if invoice else 0.0
overage = self.deduction_amount - deposit_total if self.deduction_amount > deposit_total else 0.0
order.rental_deposit_status = 'deducted'
if overage > 0:
damage_inv = order._create_damage_invoice(overage)
if damage_inv and order.rental_payment_token_id:
ok = order._collect_token_payment_for_invoice(damage_inv)
if ok:
order._send_invoice_with_receipt(damage_inv, 'damage')
else:
order._notify_staff_manual_payment(damage_inv)
elif damage_inv:
order._send_invoice_with_receipt(damage_inv, 'damage')
order.message_post(body=_(
"Security deposit fully deducted. Deduction: %s.%s\nReason: %s",
self._format_amount(self.deduction_amount, order),
_(" Overage invoice: %s", self._format_amount(overage, order)) if overage > 0 else '',
self.reason,
))
self._close_rental(order)
return {'type': 'ir.actions.act_window_close'}
def _process_sold(self, order):
"""Customer purchased the rental: full deposit refund, mark as sold."""
invoice = order.rental_deposit_invoice_id
if invoice:
credit_note = self._create_deposit_credit_note(
order, invoice, invoice.amount_total,
_("Deposit refund - customer purchased rental product %s", order.name),
)
if credit_note:
self._process_poynt_refund(order, credit_note)
order.rental_deposit_status = 'refunded'
order.rental_purchase_interest = True
order._send_deposit_refund_email()
order.message_post(body=_(
"Customer purchased rental product. Security deposit fully refunded."
))
order.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=fields.Date.today(),
summary=_("Rental product sold - %s", order.name),
note=_(
"Customer %s purchased the rental product from %s. "
"Process the sale order and schedule delivery/pickup.",
order.partner_id.name, order.name,
),
user_id=order.user_id.id or self.env.uid,
)
self._close_rental(order)
return {'type': 'ir.actions.act_window_close'}
def _create_deposit_credit_note(self, order, invoice, amount, ref):
"""Create and post a credit note for the deposit invoice."""
if not invoice or invoice.payment_state not in ('paid', 'in_payment'):
_logger.warning(
"Cannot create credit note: invoice %s state=%s payment=%s",
invoice.name if invoice else 'None',
invoice.state if invoice else 'N/A',
invoice.payment_state if invoice else 'N/A',
)
return None
credit_notes = invoice._reverse_moves(
default_values_list=[{'ref': ref}],
)
if not credit_notes:
return None
credit_note = credit_notes[:1]
if amount != invoice.amount_total:
for line in credit_note.invoice_line_ids:
line.price_unit = amount / max(line.quantity, 1)
credit_note.action_post()
return credit_note
def _process_poynt_refund(self, order, credit_note):
"""Process refund via Poynt referenced refund for the credit note."""
if not credit_note:
return
try:
orig_tx = credit_note._get_original_poynt_transaction()
except Exception:
orig_tx = None
if not orig_tx:
_logger.warning(
"No original Poynt transaction for deposit refund on %s. "
"Credit note created but refund must be processed manually.",
order.name,
)
order._notify_staff_manual_payment(credit_note)
return
provider = orig_tx.provider_id.sudo()
from odoo.addons.fusion_poynt import utils as poynt_utils
parent_txn_id = orig_tx.poynt_transaction_id
try:
txn_data = provider._poynt_make_request(
'GET', f'transactions/{parent_txn_id}',
)
for link in txn_data.get('links', []):
if link.get('rel') == 'CAPTURE' and link.get('href'):
parent_txn_id = link['href']
break
except Exception:
pass
minor_amount = poynt_utils.format_poynt_amount(
abs(credit_note.amount_total), credit_note.currency_id,
)
refund_payload = {
'action': 'REFUND',
'parentId': parent_txn_id,
'fundingSource': {'type': 'CREDIT_DEBIT'},
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
'currency': credit_note.currency_id.name,
},
'context': {
'source': 'WEB',
'sourceApp': 'odoo.fusion_rental',
},
'notes': f'Deposit refund for {order.name} via {credit_note.name}',
}
try:
result = provider._poynt_make_request(
'POST', 'transactions', payload=refund_payload,
)
except Exception as e:
_logger.error(
"Poynt deposit refund failed for %s: %s", order.name, e,
)
order._notify_staff_manual_payment(credit_note)
return
refund_status = result.get('status', '')
refund_txn_id = result.get('id', '')
if refund_status in ('DECLINED', 'FAILED'):
_logger.warning(
"Poynt deposit refund declined for %s: %s",
order.name, refund_status,
)
order._notify_staff_manual_payment(credit_note)
return
PaymentMethod = self.env['payment.method'].sudo().with_context(
active_test=False,
)
payment_method = PaymentMethod.search(
[('code', '=', 'card')], limit=1,
) or PaymentMethod.search(
[('code', 'in', ('visa', 'mastercard'))], limit=1,
)
refund_tx = self.env['payment.transaction'].sudo().create({
'provider_id': provider.id,
'payment_method_id': payment_method.id if payment_method else False,
'amount': -abs(credit_note.amount_total),
'currency_id': credit_note.currency_id.id,
'partner_id': order.partner_id.id,
'operation': 'refund',
'source_transaction_id': orig_tx.id,
'provider_reference': refund_txn_id or '',
'poynt_transaction_id': refund_txn_id or '',
'invoice_ids': [(4, credit_note.id)],
})
if refund_txn_id and refund_status not in ('PENDING',):
payment_data = {
'reference': refund_tx.reference,
'poynt_transaction_id': refund_txn_id,
'poynt_status': refund_status,
}
refund_tx._process('poynt', payment_data)
if (
credit_note.payment_state not in ('paid', 'in_payment')
and refund_tx.payment_id
):
try:
(refund_tx.payment_id.move_id.line_ids + credit_note.line_ids).filtered(
lambda line: line.account_id == refund_tx.payment_id.destination_account_id
and not line.reconciled
).reconcile()
except Exception as e:
_logger.warning(
"Fallback reconciliation failed for %s: %s",
order.name, e,
)
credit_note.sudo().poynt_refunded = True
credit_note.sudo().message_post(body=_(
"Deposit refund processed via Poynt. Amount: %s. "
"Transaction ID: %s.",
self._format_amount(abs(credit_note.amount_total), order),
refund_txn_id or 'N/A',
))
def _close_rental(self, order):
"""Close the rental after deposit processing."""
if not order.rental_closed:
try:
order.action_close_rental()
except Exception as e:
_logger.error(
"Auto-close after deposit processing failed for %s: %s",
order.name, e,
)
def _format_amount(self, amount, order):
return self.env['ir.qweb.field.monetary'].value_to_html(
amount, {'display_currency': order.currency_id},
)

View File

@@ -5,28 +5,70 @@
<field name="name">deposit.deduction.wizard.form</field>
<field name="model">deposit.deduction.wizard</field>
<field name="arch" type="xml">
<form string="Deduct Security Deposit">
<form string="Process Security Deposit">
<group>
<group>
<field name="order_id"/>
<field name="deposit_total" widget="monetary"/>
<field name="order_id" readonly="1"/>
<field name="partner_id" readonly="1"/>
</group>
<group>
<field name="deduction_amount" widget="monetary"/>
<field name="remaining_preview" widget="monetary"/>
<field name="deposit_total" widget="monetary"/>
<field name="has_card_on_file" invisible="1"/>
</group>
</group>
<separator string="Action"/>
<group>
<field name="action_type" widget="radio"/>
</group>
<!-- Deduction fields (partial / no refund) -->
<group invisible="action_type not in ('partial_refund', 'no_refund')">
<group>
<field name="deduction_amount" widget="monetary"
required="action_type in ('partial_refund', 'no_refund')"/>
</group>
<group>
<field name="remaining_preview" widget="monetary"
invisible="action_type != 'partial_refund'"/>
<field name="overage_preview" widget="monetary"
decoration-danger="overage_preview > 0"/>
</group>
</group>
<group>
<field name="reason" placeholder="Describe the damages or reason for deduction..."/>
<group invisible="action_type not in ('partial_refund', 'no_refund')">
<field name="reason"
required="action_type in ('partial_refund', 'no_refund')"
placeholder="Describe the damages or reason for deduction..."/>
</group>
<!-- Refund preview for full refund / sold -->
<group invisible="action_type not in ('full_refund', 'sold')">
<field name="refund_preview" widget="monetary" string="Amount to Refund"/>
</group>
<!-- Sold note -->
<div class="alert alert-info" role="alert"
invisible="action_type != 'sold'">
<i class="fa fa-info-circle me-2"/>
The full security deposit will be refunded to the customer's card.
The rental will be marked as sold and an activity will be created for follow-up.
</div>
<!-- Refund method note -->
<div class="alert alert-warning" role="alert"
invisible="has_card_on_file or action_type in ('no_refund',)">
<i class="fa fa-exclamation-triangle me-2"/>
No card on file. The refund will need to be processed manually.
</div>
<footer>
<button name="action_confirm_deduction"
string="Confirm Deduction"
<button name="action_confirm"
string="Process Deposit"
type="object"
class="btn-primary"
icon="fa-check"/>
icon="fa-check"
confirm="This will process the deposit and close the rental. Continue?"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>

View File

@@ -42,6 +42,40 @@ class ManualRenewalWizard(models.TransientModel):
compute='_compute_amount_preview',
)
payment_token_id = fields.Many2one(
'payment.token',
string="Card on File",
domain="[('partner_id', '=', partner_id)]",
help="Select a stored card to charge automatically. "
"Leave empty to open the manual payment wizard.",
)
use_card_on_file = fields.Boolean(
string="Charge Card on File",
default=False,
compute='_compute_use_card_on_file',
store=True,
readonly=False,
)
previous_start_date = fields.Datetime(
string="Previous Start (rollback)",
readonly=True,
)
previous_return_date = fields.Datetime(
string="Previous Return (rollback)",
readonly=True,
)
renewal_invoice_id = fields.Many2one(
'account.move',
string="Renewal Invoice",
readonly=True,
)
renewal_log_id = fields.Many2one(
'rental.renewal.log',
string="Renewal Log",
readonly=True,
)
@api.depends('order_id', 'new_start_date', 'new_return_date')
def _compute_amount_preview(self):
for wizard in self:
@@ -50,8 +84,13 @@ class ManualRenewalWizard(models.TransientModel):
else:
wizard.amount_preview = 0.0
@api.depends('payment_token_id')
def _compute_use_card_on_file(self):
for wizard in self:
wizard.use_card_on_file = bool(wizard.payment_token_id)
def action_confirm_renewal(self):
"""Confirm the manual renewal: extend dates, invoice, and collect payment."""
"""Confirm the manual renewal: extend dates, create invoice, and collect payment."""
self.ensure_one()
order = self.order_id
@@ -64,6 +103,11 @@ class ManualRenewalWizard(models.TransientModel):
old_start = order.rental_start_date
old_return = order.rental_return_date
self.write({
'previous_start_date': old_start,
'previous_return_date': old_return,
})
order.write({
'rental_start_date': self.new_start_date,
'rental_return_date': self.new_return_date,
@@ -71,8 +115,8 @@ class ManualRenewalWizard(models.TransientModel):
order._recompute_rental_prices()
invoice = order._create_renewal_invoice()
if invoice:
invoice.action_post()
if not invoice:
raise UserError(_("Could not create renewal invoice."))
renewal_log = self.env['rental.renewal.log'].create({
'order_id': order.id,
@@ -81,21 +125,77 @@ class ManualRenewalWizard(models.TransientModel):
'previous_return_date': old_return,
'new_start_date': self.new_start_date,
'new_return_date': self.new_return_date,
'invoice_id': invoice.id if invoice else False,
'invoice_id': invoice.id,
'renewal_type': 'manual',
'state': 'done',
'state': 'draft',
'payment_status': 'pending',
})
self.write({
'renewal_invoice_id': invoice.id,
'renewal_log_id': renewal_log.id,
})
order.write({
'rental_renewal_count': order.rental_renewal_count + 1,
'rental_reminder_sent': False,
})
if self.use_card_on_file and self.payment_token_id:
invoice.action_post()
ok = order._collect_token_payment_for_invoice(invoice)
if ok:
renewal_log.write({
'state': 'done',
'payment_status': 'paid',
})
order._send_invoice_with_receipt(invoice, 'renewal')
order._send_renewal_confirmation_email(renewal_log, True)
return {'type': 'ir.actions.act_window_close'}
else:
renewal_log.write({
'state': 'done',
'payment_status': 'failed',
})
order._send_renewal_confirmation_email(renewal_log, False)
order._notify_staff_manual_payment(invoice)
return {'type': 'ir.actions.act_window_close'}
invoice.action_post()
renewal_log.write({'state': 'done'})
order._send_renewal_confirmation_email(renewal_log, False)
if invoice:
inv = invoice.with_user(self.env.uid)
return inv.action_open_poynt_payment_wizard()
inv = invoice.with_user(self.env.user)
return inv.action_open_poynt_payment_wizard()
def action_cancel_renewal(self):
"""Cancel the renewal: revert dates and void the invoice."""
self.ensure_one()
order = self.order_id
if self.renewal_invoice_id:
inv = self.renewal_invoice_id
if inv.state == 'posted' and inv.payment_state in ('not_paid', 'partial'):
inv.button_draft()
if inv.state == 'draft':
inv.button_cancel()
if self.previous_start_date and self.previous_return_date:
order.write({
'rental_start_date': self.previous_start_date,
'rental_return_date': self.previous_return_date,
})
order._recompute_rental_prices()
if self.renewal_log_id:
self.renewal_log_id.write({
'state': 'failed',
'payment_status': 'failed',
'notes': 'Cancelled by user.',
})
if order.rental_renewal_count > 0:
order.rental_renewal_count -= 1
order.message_post(body=_("Manual renewal cancelled and reverted."))
return {'type': 'ir.actions.act_window_close'}

View File

@@ -1,16 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Manual Renewal Wizard Form -->
<record id="manual_renewal_wizard_view_form" model="ir.ui.view">
<field name="name">manual.renewal.wizard.form</field>
<field name="model">manual.renewal.wizard</field>
<field name="arch" type="xml">
<form string="Renew Rental">
<field name="previous_start_date" invisible="1"/>
<field name="previous_return_date" invisible="1"/>
<field name="renewal_invoice_id" invisible="1"/>
<field name="renewal_log_id" invisible="1"/>
<group>
<field name="order_id" readonly="1"/>
<field name="partner_id" readonly="1"/>
</group>
<separator string="Current Rental Period"/>
<group>
<group>
@@ -20,6 +25,7 @@
<field name="current_return_date" readonly="1"/>
</group>
</group>
<separator string="New Rental Period"/>
<group>
<group>
@@ -29,15 +35,36 @@
<field name="new_return_date"/>
</group>
</group>
<group>
<field name="amount_preview" widget="monetary"/>
</group>
<separator string="Payment"/>
<group>
<group>
<field name="payment_token_id"/>
</group>
<group>
<field name="use_card_on_file" widget="boolean_toggle"/>
</group>
</group>
<footer>
<button name="action_confirm_renewal"
type="object"
string="Confirm Renewal &amp; Collect Payment"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
class="btn-primary"
invisible="not use_card_on_file"/>
<button name="action_confirm_renewal"
type="object"
string="Confirm Renewal &amp; Open Payment"
class="btn-primary"
invisible="use_card_on_file"/>
<button name="action_cancel_renewal"
type="object"
string="Cancel"
class="btn-secondary"/>
</footer>
</form>
</field>

View File

@@ -0,0 +1,230 @@
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import float_compare
_logger = logging.getLogger(__name__)
class RentalReturnWizard(models.TransientModel):
_name = 'rental.return.wizard'
_description = 'Rental Return & Inspection'
order_id = fields.Many2one(
'sale.order',
string="Rental Order",
required=True,
readonly=True,
)
partner_id = fields.Many2one(related='order_id.partner_id')
currency_id = fields.Many2one(related='order_id.currency_id')
line_ids = fields.One2many(
'rental.return.wizard.line',
'wizard_id',
string="Items to Return",
readonly=True,
)
inspection_condition = fields.Selection(
[
('good', 'Good - No Issues'),
('fair', 'Fair - Minor Wear'),
('damaged', 'Damaged'),
],
string="Product Condition",
required=True,
)
inspection_notes = fields.Text(
string="Inspection Notes",
help="Describe the condition of the returned items.",
)
inspection_photo_ids = fields.Many2many(
'ir.attachment',
'rental_return_wizard_photo_rel',
'wizard_id',
'attachment_id',
string="Inspection Photos",
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
order_id = res.get('order_id') or self.env.context.get('default_order_id')
if not order_id:
return res
order = self.env['sale.order'].browse(order_id)
precision = self.env['decimal.precision'].precision_get('Product Unit')
lines_vals = []
for line in order.order_line.filtered(
lambda r: r.is_rental
and r.product_type != 'combo'
and float_compare(
r.qty_delivered, r.qty_returned,
precision_digits=precision,
) > 0
):
lines_vals.append((0, 0, {
'sale_line_id': line.id,
'product_id': line.product_id.id,
'description': line.name,
'qty_delivered': line.qty_delivered,
'qty_returned': line.qty_returned,
'qty_to_return': line.qty_delivered - line.qty_returned,
}))
res['line_ids'] = lines_vals
return res
def action_confirm(self):
"""Validate inspection, apply results, and process the return."""
self.ensure_one()
order = self.order_id
if not self.inspection_photo_ids:
raise UserError(_(
"Inspection photos are required. Please attach at least one "
"photo showing the condition of the returned items."
))
if self.inspection_condition in ('fair', 'damaged') and not self.inspection_notes:
raise UserError(_(
"Please provide inspection notes describing the issue "
"when condition is '%s'.",
dict(self._fields['inspection_condition'].selection).get(
self.inspection_condition, self.inspection_condition,
),
))
self._apply_inspection(order)
self._process_return(order)
return {'type': 'ir.actions.act_window_close'}
def _apply_inspection(self, order):
"""Write inspection results to the sale order."""
if self.inspection_condition == 'good':
status = 'passed'
else:
status = 'flagged'
order.write({
'rental_inspection_status': status,
'rental_inspection_notes': self.inspection_notes or '',
'rental_inspection_photo_ids': [(6, 0, self.inspection_photo_ids.ids)],
})
if status == 'passed':
order.message_post(body=_(
"Return inspection completed: condition is good. "
"Security deposit refund process initiated."
))
if order.rental_deposit_status == 'collected':
order._refund_security_deposit()
else:
condition_label = dict(
self._fields['inspection_condition'].selection,
).get(self.inspection_condition, self.inspection_condition)
order.message_post(body=_(
"Return inspection completed: condition is '%s'. "
"Flagged for review.\nNotes: %s",
condition_label,
self.inspection_notes or '-',
))
order.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=fields.Date.today(),
summary=_("Return inspection flagged - %s", order.name),
note=_(
"Returned items from %s inspected as '%s'. "
"Review the inspection photos and process the deposit.\n"
"Notes: %s",
order.partner_id.name,
condition_label,
self.inspection_notes or '-',
),
user_id=order.user_id.id or self.env.uid,
)
order._send_damage_notification_email()
def _process_return(self, order):
"""Mark items as returned via stock picking or rental wizard."""
picking = order.picking_ids.filtered(
lambda p: p.state == 'assigned'
and p.picking_type_code == 'incoming'
)
if picking:
picking = picking[:1]
for move in picking.move_ids.filtered(
lambda m: m.state not in ('done', 'cancel')
):
if move.product_uom.is_zero(move.quantity):
move.quantity = move.product_uom_qty
try:
picking.with_context(
skip_sanity_check=False,
cancel_backorder=True,
).button_validate()
except Exception as e:
_logger.error(
"Auto-validate return picking %s for %s failed: %s",
picking.name, order.name, e,
)
raise UserError(_(
"Could not automatically process the return picking. "
"Please validate the return picking manually.\n\n"
"Inspection has been saved successfully.\n\n"
"Error: %s", str(e),
)) from e
return
precision = self.env['decimal.precision'].precision_get(
'Product Unit',
)
for line in self.line_ids:
sol = line.sale_line_id
if float_compare(
sol.qty_delivered, sol.qty_returned,
precision_digits=precision,
) > 0:
sol.qty_returned = sol.qty_delivered
class RentalReturnWizardLine(models.TransientModel):
_name = 'rental.return.wizard.line'
_description = 'Rental Return Wizard Line'
wizard_id = fields.Many2one(
'rental.return.wizard',
required=True,
ondelete='cascade',
)
sale_line_id = fields.Many2one(
'sale.order.line',
string="Order Line",
readonly=True,
)
product_id = fields.Many2one(
'product.product',
string="Product",
readonly=True,
)
description = fields.Char(readonly=True)
qty_delivered = fields.Float(
string="Qty Delivered",
readonly=True,
)
qty_returned = fields.Float(
string="Already Returned",
readonly=True,
)
qty_to_return = fields.Float(
string="Qty to Return",
readonly=True,
)

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="rental_return_wizard_form" model="ir.ui.view">
<field name="name">rental.return.wizard.form</field>
<field name="model">rental.return.wizard</field>
<field name="arch" type="xml">
<form string="Return &amp; Inspection">
<group>
<group>
<field name="order_id"/>
<field name="partner_id"/>
</group>
</group>
<separator string="Items Being Returned"/>
<field name="line_ids" nolabel="1" readonly="1">
<list>
<field name="product_id"/>
<field name="description"/>
<field name="qty_delivered"/>
<field name="qty_returned"/>
<field name="qty_to_return"/>
</list>
</field>
<separator string="Inspection"/>
<group>
<group>
<field name="inspection_condition" widget="radio"/>
</group>
<group>
<field name="inspection_notes"
placeholder="Describe any issues, damage, missing parts..."
invisible="inspection_condition == 'good'"/>
</group>
</group>
<group string="Inspection Photos (Required)">
<field name="inspection_photo_ids"
widget="inspection_photos"
nolabel="1"
class="o_inspection_photos"
options="{'accepted_file_extensions': 'image/*'}"/>
<div class="text-muted small">
Click the attach button to add multiple photos.
Click any photo thumbnail to preview full size.
</div>
</group>
<div class="alert alert-info" role="alert"
invisible="inspection_condition != 'good'">
Items are in good condition. The security deposit refund process
will be initiated automatically after confirmation.
</div>
<div class="alert alert-warning" role="alert"
invisible="inspection_condition not in ('fair', 'damaged')">
Items have been flagged. A staff activity will be created
for review and the customer will be notified.
Please provide detailed notes and photos.
</div>
<footer>
<button name="action_confirm"
type="object"
class="btn-primary"
string="Confirm Return &amp; Inspection"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>