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:
@@ -1,2 +1,3 @@
|
||||
from . import manual_renewal_wizard
|
||||
from . import deposit_deduction_wizard
|
||||
from . import rental_return_wizard
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
230
fusion_rental/wizard/rental_return_wizard.py
Normal file
230
fusion_rental/wizard/rental_return_wizard.py
Normal 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,
|
||||
)
|
||||
74
fusion_rental/wizard/rental_return_wizard_views.xml
Normal file
74
fusion_rental/wizard/rental_return_wizard_views.xml
Normal 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 & 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 & Inspection"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user