import logging from odoo import models, fields, api, _ from odoo.exceptions import UserError from .laundry_order import POS_SYNC_CTX _logger = logging.getLogger(__name__) LINE_STATES = [ ('received', 'Received'), ('processing', 'Processing'), ('ready', 'Ready'), ('delivered', 'Delivered'), ] # Line fields the lock protects. NOT included on purpose: # - state (per-item workflow advance is always allowed) # - customer_note (operator commentary) # - tracking_code (auto-assigned; cannot change after create either way) LOCKED_LINE_FIELDS = frozenset({ 'product_id', 'description', 'qty', 'price_unit', }) class LaundryOrderLine(models.Model): """Line item on a laundry order — maps from pos.order.line. Each line carries a unique scannable tracking_code (barcode) and its own per-item workflow state. The order-level state on laundry.order remains the source of truth for financial gates; the per-line state is an operational overlay that supports items moving through the workflow at different speeds. """ _name = 'laundry.order.line' _description = 'Laundry Order Line' _order = 'order_id, id' order_id = fields.Many2one( 'laundry.order', string='Order', required=True, ondelete='cascade', index=True, ) # Mirror order-level partner/state so list + kanban views can filter/group # without costly cross-model joins. order_partner_id = fields.Many2one( related='order_id.partner_id', store=True, index=True, readonly=True, ) order_state = fields.Selection( related='order_id.state', store=True, index=True, readonly=True, ) product_id = fields.Many2one( 'product.product', string='Product', ) description = fields.Char( string='Description', ) qty = fields.Float( string='Quantity', default=1.0, digits=(10, 2), ) price_unit = fields.Float( string='Unit Price', digits=(10, 2), readonly=True, ) customer_note = fields.Char( string='Customer Note', ) subtotal = fields.Float( string='Subtotal', compute='_compute_subtotal', store=True, digits=(10, 2), ) # -- Per-item tracking -- tracking_code = fields.Char( string='Tracking Code', copy=False, readonly=True, index=True, help='Unique scannable barcode for this item.', ) state = fields.Selection( LINE_STATES, string='Item Status', default='received', required=True, copy=False, index=True, ) _tracking_code_uniq = models.Constraint( 'UNIQUE(tracking_code)', 'Tracking code must be unique across all laundry items.', ) @api.depends('qty', 'price_unit') def _compute_subtotal(self): for line in self: line.subtotal = line.qty * line.price_unit # Sequence code for the auto-generated tracking_code (barcode). _TRACKING_SEQ_CODE = 'laundry.order.line.tracking' @api.model def _next_tracking_code(self): """Allocate a tracking_code that is GUARANTEED unique across the existing laundry_order_line table. Why this exists ─────────────── Postgres sequences are NON-transactional: a `nextval()` advances the sequence even when the surrounding ORM transaction rolls back. Repeated POS validates that fail (lock, missing partner, anything) eat sequence values without consuming them in real rows. Conversely, a partial reseed / data import that inserts rows with manual tracking_codes leaves the sequence BEHIND the table's MAX. Either way, `next_by_code()` can return a code that already exists → UniqueViolation → POS sale silently misses its laundry-order link (the savepoint in pos_order.py catches the SQL error to protect the POS commit). How this fixes it ───────────────── On collision, repair the sequence to (max_tracking_num + 1) using a direct SQL nextval-skip, then ask the sequence again. Capped at a few attempts so a real bug (e.g. malformed schema) still surfaces instead of looping forever. """ seq = self.env['ir.sequence'] for attempt in range(5): code = seq.next_by_code(self._TRACKING_SEQ_CODE) or False if not code: return False # Cheap collision check; the underlying UNIQUE constraint is # the real safety net — this just avoids paying the round-trip. existing = self.sudo().search_count([('tracking_code', '=', code)]) if not existing: return code # Collision — repair sequence past the current MAX, then retry. self._repair_tracking_sequence() _logger.warning( "laundry.order.line tracking sequence collided on %s " "(attempt %d); repaired and retrying.", code, attempt + 1, ) # If we still can't get a unique code after 5 tries, surface the # problem instead of writing an empty code. raise UserError(_( 'Could not allocate a unique tracking code after 5 attempts. ' 'Check the laundry_management sequence configuration.' )) @api.model def _repair_tracking_sequence(self): """Advance the tracking-code sequence past the actual MAX in the table. Idempotent — safe to call repeatedly. SQL-level so it works even when the ORM env context is unusual (sudo, sync hook). """ self.env.cr.execute(""" SELECT COALESCE(MAX( CAST(NULLIF(REGEXP_REPLACE(tracking_code, '[^0-9]', '', 'g'), '') AS INTEGER) ), 0) FROM laundry_order_line WHERE tracking_code IS NOT NULL; """) max_existing = self.env.cr.fetchone()[0] or 0 # `ir.sequence` writes update number_next; we use the API for # safety (handles ranges, prefixes, padding). seq = self.env['ir.sequence'].sudo().search( [('code', '=', self._TRACKING_SEQ_CODE)], limit=1, ) if seq: seq.write({'number_next': max_existing + 1}) @api.model_create_multi def create(self, vals_list): # POS-sync bypass FIRST — POS creates the order and its lines in a # single create_vals payload; both must sail through the guard. pos_sync = bool(self.env.context.get(POS_SYNC_CTX)) if not pos_sync: order_ids = {v.get('order_id') for v in vals_list if v.get('order_id')} if order_ids: orders = self.env['laundry.order'].browse(list(order_ids)) for order in orders: if order.locked: raise UserError(_( 'Cannot add a line to locked order "%s".', order.name, )) for vals in vals_list: if not vals.get('tracking_code'): vals['tracking_code'] = self._next_tracking_code() if _logger.isEnabledFor(logging.DEBUG): _logger.debug( 'laundry.order.line.create pos_sync=%s count=%d', pos_sync, len(vals_list), ) return super().create(vals_list) def write(self, vals): if self.env.context.get(POS_SYNC_CTX): if _logger.isEnabledFor(logging.DEBUG): _logger.debug( 'laundry.order.line.write BYPASS ids=%s keys=%s', self.ids, list(vals.keys()), ) return super().write(vals) protected = LOCKED_LINE_FIELDS.intersection(vals.keys()) if protected: for line in self: if line.order_id.locked: raise UserError(_( 'Line on locked order "%(order)s" cannot edit ' '%(fields)s. Ask a manager to use "Unlock for ' 'Editing" first.', order=line.order_id.name, fields=', '.join(sorted(protected)), )) return super().write(vals) def unlink(self): # No bypass: deletion is never issued by the POS sync path. for line in self: if line.order_id.locked: raise UserError(_( 'Cannot delete a line from locked order "%s".', line.order_id.name, )) return super().unlink() # -- Per-line workflow actions (1-click) -- def action_line_process(self): for line in self: if line.state != 'received': raise UserError(_( 'Item %(code)s is not in Received state.', code=line.tracking_code or line.id, )) line.state = 'processing' def action_line_ready(self): for line in self: if line.state != 'processing': raise UserError(_( 'Item %(code)s is not in Processing state.', code=line.tracking_code or line.id, )) line.state = 'ready' def action_line_deliver(self): for line in self: if line.state != 'ready': raise UserError(_( 'Item %(code)s must be Ready before delivery.', code=line.tracking_code or line.id, )) line.state = 'delivered' @api.model def action_scan_advance(self, tracking_code): """Advance an item one stage by its scanned tracking code. Intended for barcode scanner workflow: scanner types the code, this method finds the line and bumps its state to the next stage. Returns the new state or raises UserError if terminal / unknown. """ if not tracking_code: raise UserError(_('Scan a tracking code.')) line = self.search([('tracking_code', '=', tracking_code.strip())], limit=1) if not line: raise UserError(_('No item with tracking code %s.', tracking_code)) transitions = { 'received': line.action_line_process, 'processing': line.action_line_ready, 'ready': line.action_line_deliver, } action = transitions.get(line.state) if not action: raise UserError(_( 'Item %(code)s is already %(state)s.', code=line.tracking_code, state=line.state, )) action() return { 'id': line.id, 'tracking_code': line.tracking_code, 'state': line.state, 'order_name': line.order_id.name, 'partner_name': line.order_partner_id.name, 'product_name': line.product_id.display_name, }