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