import logging from odoo import models, fields, api, _ from odoo.exceptions import UserError, ValidationError _logger = logging.getLogger(__name__) class ResPartnerLaundry(models.Model): _inherit = 'res.partner' laundry_unpaid_count = fields.Integer( string='Unpaid Laundry Orders', compute='_compute_laundry_unpaid_count', ) # Customer defaults — seed values only; cashier can override in POS. default_laundry_order_type_id = fields.Many2one( 'laundry.order.type', string='Default Laundry Order Type', ) default_laundry_attribute_ids = fields.Many2many( 'laundry.order.attribute', 'res_partner_laundry_default_attr_rel', 'partner_id', 'attribute_id', string='Default Laundry Attributes', ) def _compute_laundry_unpaid_count(self): if not self.ids: self.laundry_unpaid_count = 0 return self.env.cr.execute(""" SELECT partner_id, COUNT(*) FROM laundry_order WHERE partner_id IN %s AND amount_due > 0 AND state != 'delivered' GROUP BY partner_id """, [tuple(self.ids)]) counts = dict(self.env.cr.fetchall()) for partner in self: partner.laundry_unpaid_count = counts.get(partner.id, 0) @api.model def _load_pos_data_fields(self, config): fields = super()._load_pos_data_fields(config) fields.append('laundry_unpaid_count') fields.append('default_laundry_order_type_id') fields.append('default_laundry_attribute_ids') return fields @api.model def laundry_find_by_phone(self, phone): """Search for existing partner by phone. Returns dict or False.""" phone_clean = phone.strip().replace(' ', '') if not phone_clean: return False domain = [('phone', 'ilike', phone_clean)] if 'mobile' in self._fields: domain = ['|', ('phone', 'ilike', phone_clean), ('mobile', 'ilike', phone_clean)] partner = self.search(domain, limit=1) if partner: phone_val = partner.phone or '' if 'mobile' in self._fields and partner.mobile: phone_val = phone_val or partner.mobile return {'id': partner.id, 'name': partner.name, 'phone': phone_val} return False @api.model def laundry_quick_create(self, vals): """Create partner from POS quick-create popup. Returns partner id. UX contract: - phone is required (the JS popup enforces this; we also guard). - name is optional. If empty, the partner name defaults to the phone number — keeps lists scannable when a cashier doesn't have time to type a name. - mobile (when the field exists in this Odoo install) is mirrored from phone so duplicate-search by phone OR mobile keeps working. """ phone = (vals.get('phone') or '').strip() if not phone: raise UserError(_('Phone is required to create a customer.')) name = (vals.get('name') or '').strip() or phone street = (vals.get('street') or '').strip() or False create_vals = {'name': name, 'phone': phone, 'street': street} # `mobile` is provided by the optional phone add-on. Mirror only # when the field exists, so this stays portable across Odoo # distributions without bringing in extra deps. if 'mobile' in self._fields: create_vals['mobile'] = phone partner = self.create(create_vals) return partner.id @api.model def get_laundry_dues(self, partner_id): """Return outstanding laundry dues for a partner (read-only). `amount_due` is now `amount_deferred - amount_settled` — the real money still owed — not `total - paid`. """ orders = self.env['laundry.order'].search([ ('partner_id', '=', partner_id), ('amount_due', '>', 0), ('state', '!=', 'delivered'), ], order='create_date asc, id asc') order_details = [] total_due = 0.0 for o in orders: total_due += o.amount_due services = o.line_ids.mapped('product_id.name') service_summary = ', '.join(dict.fromkeys(services))[:80] order_details.append({ 'id': o.id, 'name': o.name, 'amount_due': o.amount_due, 'amount_total': o.amount_total, 'amount_paid_cash': o.amount_paid_cash, 'amount_deferred': o.amount_deferred, 'amount_settled': o.amount_settled, 'state': o.state, 'intake_date': fields.Datetime.to_string(o.create_date) if o.create_date else False, 'item_count': o.item_count, 'service_summary': service_summary or '', }) return { 'total_due': total_due, 'order_count': len(orders), 'orders': order_details, } # ───────────────────────────────────────────────────────────────────── # Settlement engine # CASH → account.bank.statement.line (Dr Cash / Cr Receivable) # + immediate reconcile vs open AR debits (FIFO) # → POS expected cash auto-includes it via statement_line_ids # NON-CASH → account.payment (Dr Bank / Cr Receivable) + reconcile # → drawer untouched, no duplication risk # ───────────────────────────────────────────────────────────────────── @api.model def settle_laundry_dues_rpc(self, partner_id, payment_lines, pos_session_id=None): """Collect a laundry receivable via one or more journal entries. Per-line routing by journal type — see module docstring above. Args: partner_id: res.partner id. payment_lines: list of dicts, each with: - pos_payment_method_id: pos.payment.method id - amount: float > 0 pos_session_id: optional pos.session id. For CASH it tags the statement line so POS includes the amount in expected cash. For NON-CASH it's stamped on account.payment for audit. Returns: dict with: settled_total, remaining_due, payments[], settled_orders[]. """ partner = self.browse(partner_id) if not partner.exists(): raise UserError(_('Customer not found.')) if not payment_lines or not isinstance(payment_lines, list): raise UserError(_('At least one payment line is required.')) normalized = [] total = 0.0 for idx, line in enumerate(payment_lines, start=1): if not isinstance(line, dict): raise UserError(_('Payment line %s is malformed.', idx)) method_id = line.get('pos_payment_method_id') if not method_id: raise UserError(_('Payment line %s has no payment method.', idx)) try: amt = float(line.get('amount') or 0.0) except (TypeError, ValueError): raise UserError(_('Payment line %s has an invalid amount.', idx)) if amt <= 0: raise UserError(_('Payment line %s must have a positive amount.', idx)) journal = self._resolve_settlement_journal(method_id) normalized.append({ 'method_id': int(method_id), 'amount': amt, 'journal': journal, }) total += amt session = None if pos_session_id: session = self.env['pos.session'].browse(int(pos_session_id)) if not session.exists(): session = None receipt_entries = [] for line in normalized: journal = line['journal'] method = self.env['pos.payment.method'].browse(line['method_id']) if journal.type == 'cash': stmt_line = self._create_cash_settlement_statement_line( partner, journal, line['amount'], session, ) self._reconcile_statement_line_to_receivable(stmt_line, partner) receipt_entries.append({ 'id': stmt_line.id, 'name': stmt_line.move_id.name or stmt_line.payment_ref, 'state': stmt_line.move_id.state, 'amount': stmt_line.amount, 'journal_name': journal.name, 'journal_type': 'cash', 'method_name': method.name or journal.name, }) else: pmt = self._create_settlement_account_payment( partner, journal, line['amount'], line['method_id'], session, ) receipt_entries.append({ 'id': pmt.id, 'name': pmt.name, 'state': pmt.state, 'amount': pmt.amount, 'journal_name': pmt.journal_id.name, 'journal_type': pmt.journal_id.type, 'method_name': pmt.settlement_pos_pm_id.name or pmt.journal_id.name, }) settled_orders, remaining_due = self._distribute_settlement_fifo(partner, total) _logger.info( 'Settlement RPC: partner=%s total=%.2f lines=%d remaining=%.2f', partner.name, total, len(receipt_entries), remaining_due, ) return { 'settled_total': total, 'remaining_due': remaining_due, 'payments': receipt_entries, 'settled_orders': settled_orders, } @api.model def _create_cash_settlement_statement_line(self, partner, journal, amount, session): """Create a POS-tagged cash-in statement line that posts directly Dr Cash / Cr Receivable. `counterpart_account_id` is consumed by account.bank.statement.line.create to override the journal's default suspense account — so the credit leg lands on the partner's receivable account, ready for reconcile. The statement line auto-posts its move (Odoo handles action_post). Tagging `pos_session_id` makes the amount flow into POS's native expected-cash formula (statement_line_ids sum) → no closing diff. """ receivable = partner.property_account_receivable_id if not receivable: raise UserError(_( 'Customer "%s" has no receivable account configured.', partner.name, )) vals = { 'journal_id': journal.id, 'amount': amount, 'partner_id': partner.id, 'payment_ref': _('Customer Settlement (POS) — %s', partner.name), 'counterpart_account_id': receivable.id, 'date': fields.Date.context_today(self), } if session: vals['pos_session_id'] = session.id try: return self.env['account.bank.statement.line'].sudo().create(vals) except Exception as e: _logger.exception( 'Failed to create cash settlement statement line for partner %s', partner.id, ) raise UserError(_('Failed to record cash settlement: %s', e)) @api.model def _reconcile_statement_line_to_receivable(self, stmt_line, partner): """Reconcile the statement line's receivable credit against the partner's open receivable debits (FIFO). Both sides live on the partner's receivable account, so a direct AML.reconcile() fully settles the customer due in one step. """ receivable = partner.property_account_receivable_id if not receivable: return counterpart_credits = stmt_line.move_id.line_ids.filtered( lambda l: l.account_id == receivable and l.credit > 0 and not l.reconciled ) if not counterpart_credits: return AML = self.env['account.move.line'] open_debits = AML.search([ ('partner_id', '=', partner.id), ('account_id', '=', receivable.id), ('reconciled', '=', False), ('parent_state', '=', 'posted'), ('debit', '>', 0), ], order='date asc, id asc') if not open_debits: return try: (counterpart_credits | open_debits).reconcile() except Exception: _logger.exception( 'Reconciliation failed for cash settlement statement line %s', stmt_line.id, ) @api.model def _create_settlement_account_payment( self, partner, journal, amount, method_id, session, ): """Non-cash settlement: standard account.payment + reconcile.""" Payment = self.env['account.payment'] pmt_vals = { 'payment_type': 'inbound', 'partner_type': 'customer', 'partner_id': partner.id, 'amount': amount, 'journal_id': journal.id, 'currency_id': (journal.currency_id or self.env.company.currency_id).id, 'date': fields.Date.context_today(self), 'memo': _('Laundry dues settlement — %s', partner.name), 'settlement_pos_pm_id': method_id, } if session: pmt_vals['pos_session_id'] = session.id pmt = Payment.sudo().create(pmt_vals) try: pmt.action_post() except Exception as e: _logger.exception( 'Failed to post settlement payment for partner %s', partner.id, ) raise UserError(_('Failed to post payment: %s', e)) self._reconcile_settlement_payment(pmt, partner) return pmt @api.model def _resolve_settlement_journal(self, pos_payment_method_id): """Pick the account.journal for a settlement payment. 1. If a pos.payment.method id is given, enforce split_transactions=False and return its journal. 2. Else return the company's default cash journal, then bank journal. Raises UserError if no valid journal can be found. """ if pos_payment_method_id: method = self.env['pos.payment.method'].browse(int(pos_payment_method_id)) if not method.exists(): raise UserError(_('Selected payment method not found.')) if method.split_transactions: raise ValidationError(_( 'Customer Account / pay-later methods cannot be used to ' 'settle laundry dues — they would create new receivable ' 'instead of reducing it.' )) if not method.journal_id: raise UserError(_( 'Payment method "%s" has no journal configured.', method.name, )) return method.journal_id Journal = self.env['account.journal'] j = Journal.search([ ('company_id', '=', self.env.company.id), ('type', '=', 'cash'), ], limit=1) if not j: j = Journal.search([ ('company_id', '=', self.env.company.id), ('type', '=', 'bank'), ], limit=1) if not j: raise UserError(_('No cash or bank journal available for settlement.')) return j @api.model def _reconcile_settlement_payment(self, payment, partner): """Reconcile the payment's partner-receivable line against open receivable AMLs from prior customer-account POS sales. Journals without an outstanding-receipts account post directly to the partner receivable (Dr Bank / Cr Receivable, state=paid). Journals configured with an outstanding-receipts account post to it first (Dr Bank / Cr Outstanding Receipts, state=in_process) — no partner receivable line yet, so there's nothing to reconcile here until the bank statement clears. This method safely no-ops in that case. """ receivable_account = partner.property_account_receivable_id if not receivable_account: _logger.warning('Partner %s has no receivable account', partner.id) return AML = self.env['account.move.line'] payment_lines = payment.move_id.line_ids.filtered( lambda l: l.account_id == receivable_account and not l.reconciled ) if not payment_lines: return # state=in_process — reconciliation happens at bank clearing open_debits = AML.search([ ('partner_id', '=', partner.id), ('account_id', '=', receivable_account.id), ('reconciled', '=', False), ('parent_state', '=', 'posted'), ('debit', '>', 0), ], order='date asc, id asc') if not open_debits: return try: (payment_lines | open_debits).reconcile() except Exception: _logger.exception( 'Reconciliation failed for settlement payment %s', payment.name, ) @api.model def _distribute_settlement_fifo(self, partner, amount): """Spread an already-collected amount across the partner's open laundry orders, oldest first. Returns (settled_orders, remaining_due). """ LaundryOrder = self.env['laundry.order'] open_orders = LaundryOrder.search([ ('partner_id', '=', partner.id), ('amount_due', '>', 0), ('state', '!=', 'delivered'), ], order='create_date asc, id asc') remaining = amount settled_orders = [] for lo in open_orders: if remaining <= 0: break apply_amount = min(remaining, lo.amount_due) # Defense-in-depth: `amount_settled` is not in # LOCKED_HEADER_FIELDS so this write already sails through, # but we pass laundry_pos_sync=True to guarantee the bypass # even if the locked-field whitelist changes later. lo.sudo().with_context(laundry_pos_sync=True).write({ 'amount_settled': (lo.amount_settled or 0.0) + apply_amount, }) remaining -= apply_amount settled_orders.append({ 'id': lo.id, 'name': lo.name, 'applied': apply_amount, 'remaining_on_order': lo.amount_due, }) total_due = sum( LaundryOrder.search([ ('partner_id', '=', partner.id), ('amount_due', '>', 0), ('state', '!=', 'delivered'), ]).mapped('amount_due') ) return settled_orders, total_due @api.model def get_session_settlements(self, pos_session_id): """Return laundry settlement payments stamped on a POS session. Used by the closing-screen extension to show a read-only summary of settlement collections made during this session. Does NOT inject into POS cash-control totals. """ Payment = self.env['account.payment'] payments = Payment.sudo().search([ ('pos_session_id', '=', int(pos_session_id)), ('payment_type', '=', 'inbound'), ('partner_type', '=', 'customer'), ], order='date asc, id asc') by_journal = {} for p in payments: jname = p.journal_id.name jtype = p.journal_id.type # 'cash' or 'bank' key = jname if key not in by_journal: by_journal[key] = {'name': jname, 'type': jtype, 'total': 0.0} by_journal[key]['total'] += p.amount return { 'total': sum(p.amount for p in payments), 'count': len(payments), 'by_journal': list(by_journal.values()), 'payments': [{ 'id': p.id, 'name': p.name, 'partner_name': p.partner_id.name, 'amount': p.amount, 'journal_name': p.journal_id.name, 'date': fields.Date.to_string(p.date), } for p in payments], } @api.model def get_laundry_orders_for_pos(self, partner_id, limit=20): """Legacy shim — delegates to the canonical method on laundry.order. Kept so older POS bundles that still call this RPC keep working. New code should call `laundry.order.pos_search_customer_orders` directly; it supports a `search_query` parameter and returns a richer payload (payment_state, allowed_actions, …). """ return self.env['laundry.order'].pos_search_customer_orders( partner_id=partner_id, limit=limit, )