From 89f02eeda5fa66d061d30c78673b75677afa6fa2 Mon Sep 17 00:00:00 2001 From: git_admin Date: Fri, 1 May 2026 15:01:12 +0000 Subject: [PATCH] Tower: upload laundry_management 19.0.19.0.4 (via marketplace) --- .../laundry_management/models/res_partner.py | 513 ++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 addons/laundry_management/models/res_partner.py diff --git a/addons/laundry_management/models/res_partner.py b/addons/laundry_management/models/res_partner.py new file mode 100644 index 0000000..4ca74c5 --- /dev/null +++ b/addons/laundry_management/models/res_partner.py @@ -0,0 +1,513 @@ +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, + )