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