import logging from odoo import models, fields, api _logger = logging.getLogger(__name__) class PosOrderLaundryExt(models.Model): """Extend pos.order to: 1. Auto-create laundry.order for sales containing laundry products. 2. Split POS payments into real cash vs deferred (Customer Account). Settlement is NO LONGER a POS order. It is a pure account.payment invoked via res.partner.settle_laundry_dues_rpc — see res_partner.py. The `is_laundry_settlement` field and the old settlement-product path are preserved in schema form for historical compatibility with DB rows created before this refactor, but the sync hook no longer creates or processes settlement POS orders. """ _inherit = 'pos.order' laundry_order_id = fields.Many2one( 'laundry.order', string='Laundry Order', readonly=True, copy=False, index=True, ) is_laundry_settlement = fields.Boolean( string='Laundry Settlement (legacy)', readonly=True, copy=False, default=False, help='Legacy flag from the old settlement-product flow. ' 'New settlements go through account.payment directly and do ' 'not create POS orders.', ) # -- Order type / attributes / delivery (set in POS UI) -- laundry_order_type_id = fields.Many2one( 'laundry.order.type', string='Laundry Order Type', index=True, copy=False, ) laundry_order_attribute_ids = fields.Many2many( 'laundry.order.attribute', 'pos_order_laundry_attribute_rel', 'pos_order_id', 'attribute_id', string='Laundry Attributes', copy=False, ) laundry_is_delivery = fields.Boolean( string='Laundry Delivery', copy=False, ) laundry_delivery_address = fields.Text( string='Laundry Delivery Address', copy=False, ) laundry_delivery_scheduled_at = fields.Datetime( string='Laundry Delivery Scheduled At', copy=False, ) # NOTE: pos.order._load_pos_data_fields is intentionally NOT overridden. # Bisection proved that adding ANY custom field to this loader breaks # POS order construction (`lines is undefined` in _computeAllPrices). # Custom values are sent by serializeForORM (see pos_order_patch.js) # and written directly by core's _process_order via create(**order), # since the columns already exist on this model. def action_open_laundry_order(self): self.ensure_one() if not self.laundry_order_id: return return { 'type': 'ir.actions.act_window', 'res_model': 'laundry.order', 'res_id': self.laundry_order_id.id, 'view_mode': 'form', 'target': 'current', } # ───────────────────────────────────────────────────────────────────── # Sync hook # ───────────────────────────────────────────────────────────────────── @api.model def _extract_pos_order_ids(self, sync_result): """Defensive extraction of pos.order ids from the value returned by `super().sync_from_ui(...)`. Across Odoo 19 patch levels and edge paths this value can be: - a dict {'pos.order': [{'id': N, ...}, ...], ...} - a list of integer ids - a recordset of pos.order (if a downstream module already normalized it) - an empty container of any of the above Returns a list of integer pos.order ids. Logs unknown shapes instead of silently dropping them. """ if not sync_result: return [] # Recordset → use .ids if isinstance(sync_result, models.BaseModel): return list(sync_result.ids) # Dict payload (canonical in mainline Odoo 19) if isinstance(sync_result, dict): payload = sync_result.get('pos.order', []) ids = [] for entry in payload or []: if isinstance(entry, dict) and entry.get('id'): ids.append(entry['id']) elif isinstance(entry, int): ids.append(entry) return ids # Plain list of ids (some patch levels / community forks) if isinstance(sync_result, (list, tuple)): ids = [] for entry in sync_result: if isinstance(entry, int): ids.append(entry) elif isinstance(entry, dict) and entry.get('id'): ids.append(entry['id']) return ids _logger.warning( "[laundry] unknown sync_from_ui return shape: %s %r", type(sync_result).__name__, sync_result, ) return [] @api.model def sync_from_ui(self, orders): # CRITICAL: super() runs the entire POS payment commit. The # laundry hand-off MUST be additive and side-effect-free if it # fails — savepoint per pos.order so a SQL-level error cannot # poison the parent transaction. result = super().sync_from_ui(orders) order_ids = self._extract_pos_order_ids(result) _logger.info( "[laundry] sync_from_ui post-process: %d pos.order id(s) extracted", len(order_ids), ) if not order_ids: return result # `.exists()` filters out any id the caller provided that isn't # actually in the DB anymore (deleted in a concurrent flow). # `sudo()` is bounded to this internal hand-off — POS users # may not hold full create/write rights on laundry.order, but # the hand-off itself is a server-controlled, validated path. for order in self.browse(order_ids).exists(): try: with self.env.cr.savepoint(): order.sudo()._maybe_create_laundry_order() except Exception: _logger.exception( "[laundry] Failed to create/sync laundry.order from " "POS %s (POS sale committed; rolled back laundry-side " "savepoint only)", order.id, ) return result def write(self, vals): res = super().write(vals) # Same isolation as above. The amount-sync to laundry is a # SECONDARY effect of a POS payment write; it must never be the # reason a payment fails. if 'amount_paid' in vals or 'payment_ids' in vals or 'amount_total' in vals: for order in self: if not order.laundry_order_id: continue try: with self.env.cr.savepoint(): order._sync_laundry_amounts() except Exception: _logger.exception( 'Failed to sync amounts to laundry order %s ' 'from POS %s (POS write succeeded; rolled back ' 'laundry-side savepoint only)', order.laundry_order_id.id, order.id, ) return res # ───────────────────────────────────────────────────────────────────── # Payment classification # ───────────────────────────────────────────────────────────────────── def _classify_pos_payments(self): """Return (cash_total, deferred_total) by inspecting pos.payment rows. cash_total = Σ amount where method.split_transactions is False deferred_total = Σ amount where method.split_transactions is True """ self.ensure_one() cash_total = 0.0 deferred_total = 0.0 for pmt in self.payment_ids: method = pmt.payment_method_id if method and method.split_transactions: deferred_total += pmt.amount else: cash_total += pmt.amount return cash_total, deferred_total def _sync_laundry_amounts(self): """Push current financial split to the linked laundry.order. amount_total / amount_paid_cash / amount_deferred are all in LOCKED_HEADER_FIELDS — without the POS-sync context bypass the lock guard would block this write on every payment edit. """ self.ensure_one() if not self.laundry_order_id: return cash, deferred = self._classify_pos_payments() self.laundry_order_id.sudo().with_context( laundry_pos_sync=True ).write({ 'amount_total': self.amount_total, 'amount_paid_cash': cash, 'amount_deferred': deferred, }) # ───────────────────────────────────────────────────────────────────── # Laundry order creation # ───────────────────────────────────────────────────────────────────── def _maybe_create_laundry_order(self): """Create laundry.order if this POS order contains laundry products.""" self.ensure_one() _logger.warning( "[laundry-trace] _maybe_create_laundry_order POS %s " "(ref=%s, lines=%d, existing_link=%s)", self.id, self.pos_reference or '-', len(self.lines), self.laundry_order_id.id or False, ) if self.laundry_order_id: _logger.warning( "[laundry-trace] POS %s already linked to laundry.order %s " "→ syncing amounts only", self.id, self.laundry_order_id.id, ) self._sync_laundry_amounts() return if not self.lines: _logger.warning("[laundry-trace] POS %s has no lines → skip", self.id) return partner = self.partner_id or self.env.company.partner_id laundry_lines = [] skipped = [] for line in self.lines: tmpl = line.product_id.product_tmpl_id if line.product_id else None if not tmpl: skipped.append((line.id, 'no template')) continue if not tmpl.is_laundry_service: skipped.append((line.id, f'not laundry ({tmpl.name})')) continue laundry_lines.append((0, 0, { 'product_id': line.product_id.id, 'description': line.full_product_name or line.product_id.name, 'qty': line.qty, 'price_unit': line.price_unit, 'customer_note': line.customer_note or '', })) _logger.warning( "[laundry-trace] POS %s line scan: %d laundry, %d skipped %s", self.id, len(laundry_lines), len(skipped), skipped[:5], ) if not laundry_lines: _logger.warning( "[laundry-trace] POS %s has no laundry-service lines → skip " "(this is the most common cause of 'no laundry order created')", self.id, ) return LaundryOrder = self.env['laundry.order'] existing = LaundryOrder.search( [('pos_order_id', '=', self.id)], limit=1, ) if existing: self.laundry_order_id = existing.id self._sync_laundry_amounts() return cash, deferred = self._classify_pos_payments() create_vals = { 'pos_order_id': self.id, 'pos_reference': self.pos_reference or '', 'partner_id': partner.id, 'company_id': self.company_id.id, # Phase 3: every laundry.order born from POS is hard-locked # by `source_type`. Header lock + line lock both kick in # immediately. The POS-sync context bypass below lets the # initial create + line writes go through. 'source_type': 'pos', 'amount_total': self.amount_total, 'amount_paid_cash': cash, 'amount_deferred': deferred, 'notes': self.general_customer_note or '', 'line_ids': laundry_lines, } # Propagate order type / attributes / delivery — inference in # laundry.order.create fills priority_level and is_delivery from # these when not explicitly provided. if self.laundry_order_type_id: create_vals['order_type_id'] = self.laundry_order_type_id.id if self.laundry_order_attribute_ids: create_vals['attribute_ids'] = [(6, 0, self.laundry_order_attribute_ids.ids)] if self.laundry_is_delivery: create_vals['is_delivery'] = True if self.laundry_delivery_address: create_vals['delivery_address'] = self.laundry_delivery_address if self.laundry_delivery_scheduled_at: create_vals['delivery_scheduled_at'] = self.laundry_delivery_scheduled_at laundry_order = LaundryOrder.with_context( laundry_pos_sync=True ).create(create_vals) self.laundry_order_id = laundry_order.id _logger.info( 'Created laundry order %s from POS %s: total=%.2f, cash=%.2f, deferred=%.2f', laundry_order.name, self.pos_reference or self.id, self.amount_total, cash, deferred, ) # ───────────────────────────────────────────────────────────────────── # Integrity audit — Step 4 of the stabilization brief # ───────────────────────────────────────────────────────────────────── @api.model def audit_laundry_links(self, heal=False): """Report (and optionally heal) POS orders that should have a linked laundry.order but don't. Returns a dict: { 'checked': int, 'missing': [{pos_order_id, pos_reference, partner_name, amount_total}, ...], 'duplicates': [pos_order_id, ...], # pos.order pointing to # a laundry.order that no # longer exists 'healed': int, # populated only if heal=True } A POS order is "should-link" when it has at least one line whose product_template is flagged is_laundry_service AND is NOT the settlement product. This is exactly the same condition _maybe_create_laundry_order applies, so the audit and the live hand-off agree. Heal mode re-runs `_maybe_create_laundry_order` for missing rows, each in its own savepoint — same isolation guarantee as the live hand-off. Safe to call repeatedly. """ Order = self.env['pos.order'] all_orders = Order.search([]) missing = [] duplicates = [] for o in all_orders: has_laundry_line = any( line.product_id.product_tmpl_id.is_laundry_service and not line.product_id.product_tmpl_id.is_laundry_settlement for line in o.lines if line.product_id and line.product_id.product_tmpl_id ) if not has_laundry_line: continue if o.laundry_order_id and not o.laundry_order_id.exists(): # Stored fk to a deleted laundry.order — orphan link duplicates.append(o.id) continue if not o.laundry_order_id: missing.append({ 'pos_order_id': o.id, 'pos_reference': o.pos_reference or '', 'partner_name': o.partner_id.name if o.partner_id else '', 'amount_total': o.amount_total, }) healed = 0 if heal: for entry in missing: pos_order = Order.browse(entry['pos_order_id']) try: with self.env.cr.savepoint(): pos_order._maybe_create_laundry_order() if pos_order.laundry_order_id: healed += 1 except Exception: _logger.exception( 'audit_laundry_links: heal failed for POS %s', pos_order.id, ) # Clear orphan fk pointers (the linked laundry.order was deleted) for pos_id in duplicates: try: with self.env.cr.savepoint(): Order.browse(pos_id).laundry_order_id = False except Exception: _logger.exception( 'audit_laundry_links: orphan-clear failed for POS %s', pos_id, ) return { 'checked': len(all_orders), 'missing': missing, 'duplicates': duplicates, 'healed': healed, }