Tower: upload laundry_management 19.0.19.0.4 (via marketplace)

This commit is contained in:
2026-05-01 15:01:13 +00:00
parent 89f02eeda5
commit 850d9bb6c5

View File

@@ -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,
}