284 lines
12 KiB
Python
284 lines
12 KiB
Python
import logging
|
|
from odoo import models, fields, api
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LaundryPaymentWizard(models.TransientModel):
|
|
"""POS-like payment wizard for laundry orders (PART 2).
|
|
|
|
Simplified flow — single screen:
|
|
1. Shows customer, order total, balance due
|
|
2. Staff selects journal (cash/bank) and enters amount
|
|
3. On confirm:
|
|
- Creates invoice if none exists
|
|
- Posts the invoice
|
|
- Creates account.payment
|
|
- Reconciles payment against invoice
|
|
|
|
Permissions:
|
|
- Any laundry user can register payment
|
|
- Only managers can change the payment date (backdate)
|
|
- Only managers can override the amount above order total
|
|
"""
|
|
_name = 'laundry.payment.wizard'
|
|
_description = 'Laundry Quick Payment'
|
|
|
|
# ── Order info (read-only header) ─────────────────────────────────
|
|
order_id = fields.Many2one(
|
|
'sale.order', string='Order',
|
|
required=True, readonly=True,
|
|
domain=[('is_laundry_order', '=', True)],
|
|
)
|
|
order_name = fields.Char(
|
|
related='order_id.name', string='Order No.', readonly=True,
|
|
)
|
|
partner_id = fields.Many2one(
|
|
related='order_id.partner_id', string='Customer', readonly=True,
|
|
)
|
|
mobile = fields.Char(
|
|
related='order_id.mobile', string='Mobile', readonly=True,
|
|
)
|
|
order_total = fields.Monetary(
|
|
related='order_id.amount_total', string='Order Total',
|
|
currency_field='currency_id', readonly=True,
|
|
)
|
|
amount_due = fields.Monetary(
|
|
related='order_id.amount_due', string='Balance Due',
|
|
currency_field='currency_id', readonly=True,
|
|
)
|
|
laundry_payment_status = fields.Selection(
|
|
related='order_id.laundry_payment_status', readonly=True,
|
|
)
|
|
laundry_state = fields.Selection(
|
|
related='order_id.laundry_state', string='Processing Status', readonly=True,
|
|
)
|
|
|
|
# ── Payment fields ────────────────────────────────────────────────
|
|
journal_id = fields.Many2one(
|
|
'account.journal',
|
|
string='Journal',
|
|
required=True,
|
|
domain="[('type', 'in', ['cash', 'bank']), ('company_id', '=', company_id)]",
|
|
)
|
|
payment_method_line_id = fields.Many2one(
|
|
'account.payment.method.line',
|
|
string='Payment Method',
|
|
required=True,
|
|
domain="[('journal_id', '=', journal_id), ('payment_type', '=', 'inbound')]",
|
|
)
|
|
amount = fields.Monetary(
|
|
string='Amount',
|
|
currency_field='currency_id',
|
|
required=True,
|
|
)
|
|
payment_date = fields.Date(
|
|
string='Payment Date',
|
|
required=True,
|
|
default=fields.Date.today,
|
|
)
|
|
memo = fields.Char(
|
|
string='Reference / Memo',
|
|
)
|
|
currency_id = fields.Many2one(
|
|
related='order_id.currency_id', readonly=True,
|
|
)
|
|
company_id = fields.Many2one(
|
|
related='order_id.company_id', readonly=True,
|
|
)
|
|
|
|
# ── Computed info ─────────────────────────────────────────────────
|
|
change_amount = fields.Monetary(
|
|
string='Change',
|
|
compute='_compute_change',
|
|
currency_field='currency_id',
|
|
help='Amount to return to customer if overpaying (cash only).',
|
|
)
|
|
is_overpayment = fields.Boolean(compute='_compute_change')
|
|
|
|
@api.depends('amount', 'amount_due')
|
|
def _compute_change(self):
|
|
for wiz in self:
|
|
if wiz.amount > 0 and wiz.amount_due > 0:
|
|
wiz.change_amount = max(wiz.amount - wiz.amount_due, 0.0)
|
|
wiz.is_overpayment = wiz.amount > wiz.amount_due
|
|
else:
|
|
wiz.change_amount = 0.0
|
|
wiz.is_overpayment = False
|
|
|
|
# ── Defaults ──────────────────────────────────────────────────────
|
|
@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 order_id:
|
|
order = self.env['sale.order'].browse(order_id)
|
|
# Default amount = outstanding balance (or total if no invoice yet)
|
|
res['amount'] = order.amount_due if order.amount_due > 0 else order.amount_total
|
|
res['memo'] = order.name
|
|
# Default journal: 1) configured in Laundry Settings, 2) first cash journal
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
configured_id = ICP.get_param('laundry_management.cash_journal_id', '')
|
|
cash_journal = False
|
|
if configured_id:
|
|
try:
|
|
j = self.env['account.journal'].browse(int(configured_id))
|
|
if j.exists() and j.type in ('cash', 'bank'):
|
|
cash_journal = j
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if not cash_journal:
|
|
cash_journal = self.env['account.journal'].search([
|
|
('type', '=', 'cash'),
|
|
('company_id', '=', order.company_id.id),
|
|
], limit=1)
|
|
if not cash_journal:
|
|
cash_journal = self.env['account.journal'].search([
|
|
('type', '=', 'bank'),
|
|
('company_id', '=', order.company_id.id),
|
|
], limit=1)
|
|
if cash_journal:
|
|
res['journal_id'] = cash_journal.id
|
|
# Default payment method line: first inbound method on the journal
|
|
method_line = self.env['account.payment.method.line'].search([
|
|
('journal_id', '=', cash_journal.id),
|
|
('payment_type', '=', 'inbound'),
|
|
], limit=1)
|
|
if method_line:
|
|
res['payment_method_line_id'] = method_line.id
|
|
return res
|
|
|
|
@api.onchange('journal_id')
|
|
def _onchange_journal_id(self):
|
|
"""Auto-select first inbound payment method line for the chosen journal."""
|
|
if self.journal_id:
|
|
method_line = self.env['account.payment.method.line'].search([
|
|
('journal_id', '=', self.journal_id.id),
|
|
('payment_type', '=', 'inbound'),
|
|
], limit=1)
|
|
self.payment_method_line_id = method_line or False
|
|
else:
|
|
self.payment_method_line_id = False
|
|
|
|
@api.onchange('order_id')
|
|
def _onchange_order_id(self):
|
|
if self.order_id:
|
|
self.amount = (
|
|
self.order_id.amount_due
|
|
if self.order_id.amount_due > 0
|
|
else self.order_id.amount_total
|
|
)
|
|
self.memo = self.order_id.name
|
|
|
|
# ── Confirm payment ───────────────────────────────────────────────
|
|
def action_confirm_payment(self):
|
|
"""Full accounting flow: invoice → post → payment → reconcile."""
|
|
self.ensure_one()
|
|
order = self.order_id
|
|
|
|
if order.state not in ('sale', 'done'):
|
|
raise UserError(
|
|
'The order must be confirmed before registering payment.\n'
|
|
'Please save the order first.'
|
|
)
|
|
|
|
if self.amount <= 0:
|
|
raise UserError('Payment amount must be greater than zero.')
|
|
|
|
# 1. Get or create invoice ────────────────────────────────────
|
|
invoices = order.invoice_ids.filtered(
|
|
lambda inv: inv.move_type == 'out_invoice' and inv.state != 'cancel'
|
|
)
|
|
if not invoices:
|
|
_logger.info('laundry.payment.wizard: creating invoice for order %s', order.name)
|
|
invoices = order._create_invoices()
|
|
if not invoices:
|
|
raise UserError(
|
|
'Failed to create an invoice. '
|
|
'Ensure the order has service lines configured with valid products.'
|
|
)
|
|
|
|
# 2. Post any draft invoices ───────────────────────────────────
|
|
draft_invoices = invoices.filtered(lambda inv: inv.state == 'draft')
|
|
if draft_invoices:
|
|
draft_invoices.action_post()
|
|
|
|
# 3. Get posted invoices with open balance ─────────────────────
|
|
to_pay = invoices.filtered(
|
|
lambda inv: inv.state == 'posted'
|
|
and inv.payment_state not in ('paid', 'in_payment')
|
|
)
|
|
if not to_pay:
|
|
raise UserError(
|
|
'This order is already fully paid.\n\n'
|
|
'Payment Status: ' + (order.laundry_payment_status or 'unknown')
|
|
)
|
|
|
|
# 4. Actual payment amount (cap at total due, not overpay into accounting)
|
|
# Change is returned physically by cashier; we only record the due amount
|
|
pay_amount = min(self.amount, sum(to_pay.mapped('amount_residual')))
|
|
if pay_amount <= 0:
|
|
raise UserError('No outstanding balance to pay on the posted invoice.')
|
|
|
|
# 5. Create account.payment ────────────────────────────────────
|
|
payment_vals = {
|
|
'amount': pay_amount,
|
|
'journal_id': self.journal_id.id,
|
|
'payment_method_line_id': self.payment_method_line_id.id,
|
|
'payment_type': 'inbound',
|
|
'partner_type': 'customer',
|
|
'partner_id': order.partner_id.id,
|
|
'date': self.payment_date,
|
|
'ref': self.memo or order.name,
|
|
'currency_id': order.currency_id.id,
|
|
}
|
|
payment = self.env['account.payment'].create(payment_vals)
|
|
payment.action_post()
|
|
|
|
# 6. Reconcile payment against invoice lines ───────────────────
|
|
# NOTE: account.account uses company_ids (M2M) in Odoo 17+, not company_id.
|
|
# Filter directly on move lines by account_type — no separate account search.
|
|
payment_lines = payment.line_ids.filtered(
|
|
lambda l: l.account_id.account_type == 'asset_receivable'
|
|
and not l.reconciled
|
|
)
|
|
invoice_lines = to_pay.line_ids.filtered(
|
|
lambda l: l.account_id.account_type == 'asset_receivable'
|
|
and not l.reconciled
|
|
)
|
|
if payment_lines and invoice_lines:
|
|
(payment_lines + invoice_lines).reconcile()
|
|
|
|
# 7. Log on order chatter ──────────────────────────────────────
|
|
journal_name = self.journal_id.name
|
|
change_note = ''
|
|
if self.is_overpayment:
|
|
change_note = f' | Change returned: {order.currency_id.symbol} {self.change_amount:.2f}'
|
|
order.message_post(
|
|
body=(
|
|
f'Payment registered: {order.currency_id.symbol} {pay_amount:.2f} '
|
|
f'via {journal_name} on {self.payment_date}.{change_note}'
|
|
),
|
|
)
|
|
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
|
|
def action_view_invoice(self):
|
|
"""Open the related invoice to view/edit if needed."""
|
|
self.ensure_one()
|
|
invoices = self.order_id.invoice_ids.filtered(
|
|
lambda inv: inv.move_type == 'out_invoice' and inv.state != 'cancel'
|
|
)
|
|
if not invoices:
|
|
raise UserError('No invoice exists for this order yet.')
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Invoice',
|
|
'res_model': 'account.move',
|
|
'res_id': invoices[0].id if len(invoices) == 1 else False,
|
|
'view_mode': 'form' if len(invoices) == 1 else 'list,form',
|
|
'domain': [('id', 'in', invoices.ids)] if len(invoices) > 1 else [],
|
|
'target': 'current',
|
|
}
|