diff --git a/addons/laundry_management/models/laundry_order_line.py b/addons/laundry_management/models/laundry_order_line.py new file mode 100644 index 0000000..5495911 --- /dev/null +++ b/addons/laundry_management/models/laundry_order_line.py @@ -0,0 +1,288 @@ +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, + }