import logging from odoo import models, fields, api, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) STATES = [ ('intake', 'Intake'), ('processing', 'Processing'), ('ready', 'Ready for Pickup'), ('delivered', 'Delivered'), ('cancelled', 'Cancelled'), ] STATE_KEYS = [s[0] for s in STATES] FINAL_STATES = {'delivered', 'cancelled'} SOURCE_TYPES = [ ('pos', 'Point of Sale'), ('manual', 'Manual / Backoffice'), ] # Header fields the lock protects. NOT included on purpose: # - state (workflow advance is always allowed via dedicated actions) # - amount_settled (settlement engine writes after lock) # - notes (managerial commentary always allowed) # - manager_unlocked_* (the unlock wizard writes these) # - tracking_enabled (Phase-4 prep, manager configuration) LOCKED_HEADER_FIELDS = frozenset({ 'partner_id', 'amount_total', 'amount_paid_cash', 'amount_deferred', 'order_type_id', 'attribute_ids', 'is_delivery', 'delivery_address', 'delivery_scheduled_at', 'priority_level', 'pos_order_id', 'pos_reference', 'source_type', 'name', 'company_id', }) # Sentinel context flag set by the POS sync hook (and any other automated # server-side path) to allow the create + write that bring a fresh # laundry.order to life. Without this flag, locked orders refuse mutations. POS_SYNC_CTX = 'laundry_pos_sync' class LaundryOrder(models.Model): """Standalone laundry order — created from POS. POS owns payments/sessions/accounting. This model handles operational workflow only: intake -> processing -> ready -> delivered. Financial model (Phase 1 fix): amount_total = mirror of pos.order.amount_total amount_paid_cash = real money collected at origin (cash/card) amount_deferred = Customer Account / pay-later amount at origin amount_settled = money collected later via settlement engine amount_due = amount_deferred - amount_settled `amount_due > 0` means the customer still owes real money. """ _name = 'laundry.order' _description = 'Laundry Order' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'create_date desc, id desc' name = fields.Char( string='Order No.', required=True, copy=False, readonly=True, default='New', tracking=True, ) # -- POS link -- # Optional: manual/backoffice orders have no POS origin. The uniqueness # constraint below still enforces "one laundry order per pos.order" for # POS-sourced rows. pos_order_id = fields.Many2one( 'pos.order', string='POS Order', index=True, readonly=True, copy=False, ondelete='restrict', ) pos_reference = fields.Char( string='POS Reference', readonly=True, copy=False, index=True, ) # -- Customer -- partner_id = fields.Many2one( 'res.partner', string='Customer', required=True, index=True, tracking=True, ) partner_phone = fields.Char( related='partner_id.phone', string='Phone', readonly=True, ) # -- Company / Currency -- company_id = fields.Many2one( 'res.company', string='Company', required=True, default=lambda self: self.env.company, index=True, ) currency_id = fields.Many2one( related='company_id.currency_id', store=True, ) # -- Workflow -- state = fields.Selection( STATES, string='Status', default='intake', required=True, tracking=True, copy=False, index=True, ) # -- Lines -- line_ids = fields.One2many( 'laundry.order.line', 'order_id', string='Order Lines', ) # -- Financial snapshot -- amount_total = fields.Monetary( string='Total', currency_field='currency_id', readonly=True, copy=False, store=True, ) amount_paid_cash = fields.Monetary( string='Paid (Cash/Card)', currency_field='currency_id', readonly=True, copy=False, store=True, default=0.0, help='Amount collected at origin via non-deferred payment methods ' '(cash, card — any pos.payment.method with split_transactions=False).', ) amount_deferred = fields.Monetary( string='Deferred', currency_field='currency_id', readonly=True, copy=False, store=True, default=0.0, help='Amount deferred at origin via Customer Account / pay-later ' '(any pos.payment.method with split_transactions=True).', ) amount_settled = fields.Monetary( string='Settled', currency_field='currency_id', readonly=True, copy=False, store=True, default=0.0, help='Amount collected later via the settlement engine ' '(account.payment + reconciliation).', ) amount_due = fields.Monetary( string='Due', currency_field='currency_id', compute='_compute_amount_due', store=True, help='Remaining balance the customer owes: amount_deferred - amount_settled.', ) # Back-compat alias — views/reports still reference `amount_paid`. # Computed, non-stored; reflects real cash collected at origin. amount_paid = fields.Monetary( string='Paid', currency_field='currency_id', compute='_compute_amount_paid_alias', ) # -- Computed -- item_count = fields.Integer( string='Items', compute='_compute_item_count', store=True, ) # -- Order type / attributes / delivery -- order_type_id = fields.Many2one( 'laundry.order.type', string='Order Type', index=True, tracking=True, ) attribute_ids = fields.Many2many( 'laundry.order.attribute', 'laundry_order_attribute_rel', 'order_id', 'attribute_id', string='Attributes', ) is_delivery = fields.Boolean(string='Delivery', tracking=True, index=True) delivery_address = fields.Text(string='Delivery Address') delivery_scheduled_at = fields.Datetime(string='Scheduled At') priority_level = fields.Selection([ ('normal', 'Normal'), ('urgent', 'Urgent'), ], string='Priority', default='normal', tracking=True, index=True) # -- Notes -- notes = fields.Text(string='Notes') # ── Source / locking (Phase 3) ──────────────────────────────────── # source_type is the truth-bearing identity. is_from_pos is a stored # mirror used in domains, list filters, and rule conditions where a # selection field would be awkward. source_type = fields.Selection( SOURCE_TYPES, string='Source', required=True, default='manual', readonly=True, copy=False, index=True, tracking=True, help='POS-sourced orders are hard-locked: lines, prices and the ' 'customer cannot be edited unless a manager grants a ' 'temporary unlock window. Manual orders are editable until ' 'they reach a final state (delivered / cancelled).', ) is_from_pos = fields.Boolean( string='From POS', compute='_compute_is_from_pos', store=True, index=True, ) # Phase-4 prep — flag only, no logic wired yet. tracking_enabled = fields.Boolean( string='Per-Item Tracking', default=False, copy=False, help='When enabled, each laundry.order.line will be advanced ' 'through its own state machine. Phase 4 wires the ' 'synchronization between order state and item state.', ) # Computed: True when the order refuses mutation of LOCKED_HEADER_FIELDS # and any line write/create/unlink. Not stored — cheap to recompute and # depends on a Datetime that ages out without a write. locked = fields.Boolean( string='Locked', compute='_compute_locked', help='Order is read-only when True. POS-sourced orders are ' 'always locked. Final-state orders (delivered, cancelled) ' 'are always locked. Managers can grant a temporary unlock ' 'window via the "Unlock for Editing" action.', ) manager_unlocked_until = fields.Datetime( string='Unlock Window Expires', copy=False, readonly=True, help='When set in the future, the lock guard is suspended. ' 'Auto-expires; no manual re-lock required.', ) manager_unlocked_by = fields.Many2one( 'res.users', string='Last Unlocked By', copy=False, readonly=True, ) manager_unlock_reason = fields.Char( string='Last Unlock Reason', copy=False, readonly=True, ) # Stamped when the order moves to delivered. Powers the avg-processing # and on-time KPIs on the Operations Dashboard. Outside # LOCKED_HEADER_FIELDS so action_deliver can write it on POS-locked # orders without needing the bypass context. delivered_at = fields.Datetime( string='Delivered At', readonly=True, copy=False, index=True, help='Timestamp set automatically when the order moves to ' 'Delivered. Used by the analytics dashboard to compute ' 'processing time and on-time delivery percentage.', ) # -- Constraints -- _pos_order_uniq = models.Constraint( 'UNIQUE(pos_order_id)', 'A laundry order already exists for this POS order.', ) @api.constrains('source_type', 'pos_order_id') def _check_source_type_consistency(self): for order in self: if order.source_type == 'pos' and not order.pos_order_id: raise UserError(_( 'Order "%s" is marked as POS-sourced but has no ' 'linked POS order.', order.name or order.id, )) if order.source_type == 'manual' and order.pos_order_id: raise UserError(_( 'Order "%s" is marked as manual but has a linked ' 'POS order. Set source_type="pos" to keep them ' 'consistent.', order.name or order.id, )) # -- Computed -- @api.depends('amount_deferred', 'amount_settled') def _compute_amount_due(self): for order in self: order.amount_due = max( (order.amount_deferred or 0.0) - (order.amount_settled or 0.0), 0.0, ) @api.depends('amount_paid_cash') def _compute_amount_paid_alias(self): for order in self: order.amount_paid = order.amount_paid_cash or 0.0 @api.depends('source_type') def _compute_is_from_pos(self): for order in self: order.is_from_pos = order.source_type == 'pos' @api.depends('source_type', 'state', 'manager_unlocked_until') def _compute_locked(self): now = fields.Datetime.now() for order in self: unlock_active = bool( order.manager_unlocked_until and order.manager_unlocked_until > now ) base_locked = ( order.source_type == 'pos' or order.state in FINAL_STATES ) order.locked = base_locked and not unlock_active # ── Lock enforcement helpers ────────────────────────────────────── def _is_pos_sync(self): """True when the call originates from the POS sync hook (or any explicit server path that opts in via the context flag). Both create() and write() honour this so the bridge from pos.order can build / refresh the laundry.order without fighting its own lock guard. This is checked BEFORE anything else in write() so indirect POS writes (stored-compute flushes, cascades) can never raise from the guard. """ return bool(self.env.context.get(POS_SYNC_CTX)) def _check_lock_for_write(self, vals): """Raise UserError when `vals` would mutate a protected header field on a currently-locked order. Workflow advances (state, amount_settled, notes, manager_unlocked_*) are excluded by whitelist (LOCKED_HEADER_FIELDS). Note: the POS-sync bypass is already applied at the top of `write()` — this helper is only invoked for non-bypassed paths. """ protected = LOCKED_HEADER_FIELDS.intersection(vals.keys()) if not protected: return for order in self: if order.locked: raise UserError(_( 'Order "%(name)s" is locked. Editable fields: state ' 'transitions, internal notes, settlement amount.\n' 'To edit %(fields)s, ask a manager to use ' '"Unlock for Editing" first.', name=order.name, fields=', '.join(sorted(protected)), )) def _check_lock_for_unlink(self): """POS-sourced and final-state orders cannot be unlinked. The manager unlock wizard is intentionally NOT honored here — deletion requires a stronger affordance (cancellation + audit trail), not a temporary edit window. """ for order in self: if order.source_type == 'pos': raise UserError(_( 'Order "%(name)s" was created from POS and cannot ' 'be deleted. Cancel the underlying POS order instead.', name=order.name, )) if order.state in FINAL_STATES: raise UserError(_( 'Order "%(name)s" is in a final state (%(state)s) ' 'and cannot be deleted.', name=order.name, state=dict(STATES).get(order.state, order.state), )) @api.depends('line_ids.qty') def _compute_item_count(self): for order in self: order.item_count = int(sum(order.line_ids.mapped('qty'))) # -- ORM -- @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('name', 'New') == 'New': vals['name'] = ( self.env['ir.sequence'].next_by_code('laundry.order') or 'New' ) self._apply_type_attribute_inference(vals) return super().create(vals_list) def write(self, vals): # STEP 1 — POS sync bypass. # Must be the very first thing we do. Any code path that opts # into the context flag (POS create/sync, settlement engine, # stored-compute flushes triggered from inside a bypassed write) # MUST sail through unconditionally. No locking check, no # side-effects, no iteration over self — just delegate to super. # This guarantee is what makes POS payment/settlement flows # immune to this model's lock guard. if self._is_pos_sync(): if _logger.isEnabledFor(logging.DEBUG): _logger.debug( 'laundry.order.write BYPASS ids=%s keys=%s', self.ids, list(vals.keys()), ) return super().write(vals) # STEP 2 — Forensic trace (DEBUG-level; off by default in prod, # enable with `--log-level=debug` or `--log-handler=odoo.addons # .laundry_management.models.laundry_order:DEBUG`). if _logger.isEnabledFor(logging.DEBUG): keys = list(vals.keys()) for order in self: _logger.debug( 'laundry.order.write id=%s source=%s state=%s ' 'locked=%s ctx_keys=%s vals_keys=%s', order.id, order.source_type, order.state, order.locked, sorted(self.env.context.keys()), keys, ) # STEP 3 — Lock guard for non-POS callers. self._check_lock_for_write(vals) return super().write(vals) def unlink(self): # Explicit: even with POS-sync context, refuse to delete a locked # order. Deletion is not a mutation the sync path ever issues. self._check_lock_for_unlink() return super().unlink() @api.model def _apply_type_attribute_inference(self, vals): """Fill in priority_level / is_delivery from the selected order type and attributes when the caller did not explicitly set them. Rules: - type.priority='urgent' OR any attribute with is_priority_related=True → priority_level='urgent' - type.is_delivery=True OR any attribute with is_delivery_related=True → is_delivery=True - Do NOT overwrite explicit incoming delivery_address / delivery_scheduled_at with blank values. """ type_id = vals.get('order_type_id') order_type = ( self.env['laundry.order.type'].browse(type_id) if type_id else None ) attribute_ids = [] raw_attrs = vals.get('attribute_ids') or [] for cmd in raw_attrs: if isinstance(cmd, (list, tuple)) and len(cmd) >= 3: # Odoo x2m commands: (6,0,[ids]), (4,id), etc. if cmd[0] == 6 and isinstance(cmd[2], (list, tuple)): attribute_ids.extend(cmd[2]) elif cmd[0] == 4 and cmd[1]: attribute_ids.append(cmd[1]) elif isinstance(cmd, int): attribute_ids.append(cmd) attributes = ( self.env['laundry.order.attribute'].browse(attribute_ids) if attribute_ids else self.env['laundry.order.attribute'] ) # Priority if 'priority_level' not in vals: urgent = ( (order_type and order_type.priority == 'urgent') or any(a.is_priority_related for a in attributes) ) vals['priority_level'] = 'urgent' if urgent else 'normal' # Delivery if 'is_delivery' not in vals: delivery = ( (order_type and order_type.is_delivery) or any(a.is_delivery_related for a in attributes) ) vals['is_delivery'] = bool(delivery) # -- Workflow actions -- def action_process(self): for order in self: if order.state != 'intake': raise UserError(_( 'Order "%(name)s" is not in Intake state.', name=order.name, )) order.state = 'processing' def action_ready(self): for order in self: if order.state != 'processing': raise UserError(_( 'Order "%(name)s" is not in Processing state.', name=order.name, )) order.state = 'ready' def action_deliver(self): """Guards: must be Ready + fully paid (amount_due == 0). Also stamps `delivered_at` so the dashboard KPIs (avg processing time, on-time delivery %) can be computed from real data instead of the heuristic on `write_date`. """ for order in self: if order.state != 'ready': raise UserError(_( 'Order "%(name)s" must be Ready before delivery.', name=order.name, )) if order.amount_due > 0: raise UserError(_( 'Order "%(name)s" has %(due).2f outstanding. ' 'Collect payment in POS before delivery.', name=order.name, due=order.amount_due, )) order.write({ 'state': 'delivered', 'delivered_at': fields.Datetime.now(), }) def action_cancel(self): """Cancel an order. Allowed for manual orders only — POS-sourced orders must be voided through the POS workflow to keep the sale and the operational record in sync. """ for order in self: if order.source_type == 'pos': raise UserError(_( 'Order "%(name)s" was created from POS and cannot ' 'be cancelled here. Cancel the underlying POS order ' 'instead.', name=order.name, )) if order.state in FINAL_STATES: raise UserError(_( 'Order "%(name)s" is already %(state)s.', name=order.name, state=dict(STATES).get(order.state, order.state), )) order.state = 'cancelled' # -- Smart button -- def action_open_pos_order(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'pos.order', 'res_id': self.pos_order_id.id, 'view_mode': 'form', 'target': 'current', } # ═════════════════════════════════════════════════════════════════ # POS "Laundry Orders" popup — server-side RPCs # --------------------------------------------------------------- # These methods are the ONLY way the POS popup interacts with the # model. Each action delegates to the corresponding workflow method # (`action_process` / `action_ready` / `action_deliver`) which already # enforces state + amount_due guards server-side. Direct writes to # LOCKED_HEADER_FIELDS are NOT exposed here — the Phase 3 lock # remains the sole authority for business-edit protection. # ═════════════════════════════════════════════════════════════════ def _pos_allowed_actions(self): """Return the list of action keys the popup may render for this order. Pure function of state + amount_due. Final states only allow printing. """ self.ensure_one() actions = ['print_work_order'] if self.state in FINAL_STATES: return actions if self.state == 'intake': actions.append('start_processing') elif self.state == 'processing': actions.append('mark_ready') elif self.state == 'ready': if self.amount_due <= 0: actions.append('deliver') else: actions.append('collect_payment') return actions def _pos_payment_state(self): self.ensure_one() if self.amount_due > 0: return 'due' if self.amount_deferred > 0 and self.amount_settled >= self.amount_deferred: return 'settled' if self.amount_deferred > 0: return 'deferred' return 'paid' def _pos_payload(self): """Compact, UI-ready dict. Single source of truth for the popup shape — every RPC returns exactly this structure.""" self.ensure_one() names = self.line_ids.mapped('product_id.name') if not names: names = self.line_ids.mapped('description') state_selection = dict(self._fields['state'].selection) return { 'id': self.id, 'name': self.name, 'state': self.state, 'state_label': state_selection.get(self.state) or self.state, 'pos_reference': self.pos_reference or '', 'is_from_pos': self.is_from_pos, 'create_date': fields.Datetime.to_string(self.create_date) if self.create_date else False, 'item_count': int(self.item_count or 0), 'service_summary': ', '.join(dict.fromkeys(names))[:80], 'amount_total': self.amount_total, 'amount_paid': self.amount_paid_cash, 'amount_deferred': self.amount_deferred, 'amount_settled': self.amount_settled, 'amount_due': self.amount_due, 'payment_state': self._pos_payment_state(), 'is_delivery': self.is_delivery, 'allowed_actions': self._pos_allowed_actions(), } @api.model def pos_search_customer_orders(self, partner_id, search_query=False, limit=20): """Partner-scoped search for the POS popup. • Always filters by partner_id (no global search in this phase). • Optional search_query ilike-matches on name / pos_reference / partner_phone. • Hard-capped at 50 rows regardless of the caller's limit. """ if not partner_id: return [] limit = max(1, min(int(limit or 20), 50)) q = (search_query or '').strip() domain = [('partner_id', '=', partner_id)] if q: domain = [ '&', ('partner_id', '=', partner_id), '|', '|', ('name', 'ilike', q), ('pos_reference', 'ilike', q), ('partner_phone', 'ilike', q), ] orders = self.search( domain, order='create_date desc, id desc', limit=limit, ) return [o._pos_payload() for o in orders] def pos_action_start_processing(self): """POS popup: advance intake → processing. Returns refreshed payload.""" self.ensure_one() self.action_process() return self._pos_payload() def pos_action_mark_ready(self): """POS popup: advance processing → ready. Returns refreshed payload.""" self.ensure_one() self.action_ready() return self._pos_payload() def pos_action_deliver(self): """POS popup: advance ready → delivered. Returns refreshed payload. `action_deliver` raises UserError when amount_due > 0 — the popup surfaces that error; no client-side duplication of the rule. """ self.ensure_one() self.action_deliver() return self._pos_payload() @api.model def pos_get_thermal_data(self, order_id): """Build a self-contained payload for the thermal Work-Order receipt rendered by `laundry_management.LaundryWorkOrderThermal`. Independent from `_pos_payload` — that one is for the popup list (compact); this one carries every line + delivery meta the cashier needs on the printed slip. """ order = self.browse(int(order_id)) if not order.exists(): return False state_label = dict(order._fields['state'].selection).get( order.state, order.state, ) return { 'id': order.id, 'name': order.name, 'state': order.state, 'state_label': state_label, 'payment_state': order._pos_payment_state(), 'pos_reference': order.pos_reference or '', 'partner_name': order.partner_id.name or '', # `mobile` is provided by the optional `phone` add-on; fall # back gracefully when it isn't present in the install. 'partner_phone': ( order.partner_id.phone or getattr(order.partner_id, 'mobile', '') or '' ), 'company_name': order.company_id.name or '', 'create_date': fields.Datetime.to_string(order.create_date), 'lines': [{ 'qty': line.qty, 'description': ( line.description or (line.product_id.name if line.product_id else '') ), 'price_unit': line.price_unit, 'subtotal': line.subtotal, 'tracking_code': line.tracking_code or '', } for line in order.line_ids], 'item_count': int(order.item_count or 0), 'amount_total': order.amount_total, 'amount_paid': order.amount_paid_cash, 'amount_deferred': order.amount_deferred, 'amount_settled': order.amount_settled, 'amount_due': order.amount_due, 'is_delivery': order.is_delivery, 'delivery_address': order.delivery_address or '', 'delivery_scheduled_at': ( fields.Datetime.to_string(order.delivery_scheduled_at) if order.delivery_scheduled_at else '' ), 'currency_symbol': order.currency_id.symbol or '', 'currency_position': order.currency_id.position or 'after', } def action_open_unlock_wizard(self): """Open the manager unlock wizard pre-filled with this order. Access is enforced inside the wizard's action method (group check + reason required), but we also short-circuit here so the button itself is silent for non-managers. """ self.ensure_one() if not self.locked: raise UserError(_( 'Order "%s" is already editable.', self.name, )) return { 'type': 'ir.actions.act_window', 'res_model': 'laundry.order.unlock.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_order_id': self.id, }, }