From acf12b508b452ab7055455880950831ded6ea96a Mon Sep 17 00:00:00 2001 From: git_admin Date: Fri, 1 May 2026 15:00:55 +0000 Subject: [PATCH] Tower: upload laundry_management 19.0.19.0.4 (via marketplace) --- .../wizard/laundry_payment_wizard.py | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 addons/laundry_management/wizard/laundry_payment_wizard.py diff --git a/addons/laundry_management/wizard/laundry_payment_wizard.py b/addons/laundry_management/wizard/laundry_payment_wizard.py new file mode 100644 index 0000000..e7acea0 --- /dev/null +++ b/addons/laundry_management/wizard/laundry_payment_wizard.py @@ -0,0 +1,283 @@ +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', + }