Tower: upload laundry_management 19.0.19.0.4 (via marketplace)
This commit is contained in:
288
addons/laundry_management/models/laundry_order_line.py
Normal file
288
addons/laundry_management/models/laundry_order_line.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user