Tower: upload laundry_management 19.0.19.0.4 (via marketplace)
This commit is contained in:
765
addons/laundry_management/models/laundry_order.py
Normal file
765
addons/laundry_management/models/laundry_order.py
Normal file
@@ -0,0 +1,765 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
STATES = [
|
||||
('intake', 'Intake'),
|
||||
('processing', 'Processing'),
|
||||
('ready', 'Ready for Pickup'),
|
||||
('delivered', 'Delivered'),
|
||||
('cancelled', 'Cancelled'),
|
||||
]
|
||||
STATE_KEYS = [s[0] for s in STATES]
|
||||
FINAL_STATES = {'delivered', 'cancelled'}
|
||||
|
||||
SOURCE_TYPES = [
|
||||
('pos', 'Point of Sale'),
|
||||
('manual', 'Manual / Backoffice'),
|
||||
]
|
||||
|
||||
# Header fields the lock protects. NOT included on purpose:
|
||||
# - state (workflow advance is always allowed via dedicated actions)
|
||||
# - amount_settled (settlement engine writes after lock)
|
||||
# - notes (managerial commentary always allowed)
|
||||
# - manager_unlocked_* (the unlock wizard writes these)
|
||||
# - tracking_enabled (Phase-4 prep, manager configuration)
|
||||
LOCKED_HEADER_FIELDS = frozenset({
|
||||
'partner_id',
|
||||
'amount_total', 'amount_paid_cash', 'amount_deferred',
|
||||
'order_type_id', 'attribute_ids',
|
||||
'is_delivery', 'delivery_address', 'delivery_scheduled_at',
|
||||
'priority_level',
|
||||
'pos_order_id', 'pos_reference',
|
||||
'source_type', 'name', 'company_id',
|
||||
})
|
||||
|
||||
# Sentinel context flag set by the POS sync hook (and any other automated
|
||||
# server-side path) to allow the create + write that bring a fresh
|
||||
# laundry.order to life. Without this flag, locked orders refuse mutations.
|
||||
POS_SYNC_CTX = 'laundry_pos_sync'
|
||||
|
||||
|
||||
class LaundryOrder(models.Model):
|
||||
"""Standalone laundry order — created from POS.
|
||||
|
||||
POS owns payments/sessions/accounting. This model handles operational
|
||||
workflow only: intake -> processing -> ready -> delivered.
|
||||
|
||||
Financial model (Phase 1 fix):
|
||||
amount_total = mirror of pos.order.amount_total
|
||||
amount_paid_cash = real money collected at origin (cash/card)
|
||||
amount_deferred = Customer Account / pay-later amount at origin
|
||||
amount_settled = money collected later via settlement engine
|
||||
amount_due = amount_deferred - amount_settled
|
||||
|
||||
`amount_due > 0` means the customer still owes real money.
|
||||
"""
|
||||
_name = 'laundry.order'
|
||||
_description = 'Laundry Order'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Order No.',
|
||||
required=True, copy=False, readonly=True,
|
||||
default='New', tracking=True,
|
||||
)
|
||||
|
||||
# -- POS link --
|
||||
# Optional: manual/backoffice orders have no POS origin. The uniqueness
|
||||
# constraint below still enforces "one laundry order per pos.order" for
|
||||
# POS-sourced rows.
|
||||
pos_order_id = fields.Many2one(
|
||||
'pos.order', string='POS Order',
|
||||
index=True, readonly=True, copy=False,
|
||||
ondelete='restrict',
|
||||
)
|
||||
pos_reference = fields.Char(
|
||||
string='POS Reference',
|
||||
readonly=True, copy=False, index=True,
|
||||
)
|
||||
|
||||
# -- Customer --
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
required=True, index=True, tracking=True,
|
||||
)
|
||||
partner_phone = fields.Char(
|
||||
related='partner_id.phone', string='Phone', readonly=True,
|
||||
)
|
||||
|
||||
# -- Company / Currency --
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
required=True, default=lambda self: self.env.company,
|
||||
index=True,
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
related='company_id.currency_id', store=True,
|
||||
)
|
||||
|
||||
# -- Workflow --
|
||||
state = fields.Selection(
|
||||
STATES,
|
||||
string='Status',
|
||||
default='intake',
|
||||
required=True,
|
||||
tracking=True,
|
||||
copy=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# -- Lines --
|
||||
line_ids = fields.One2many(
|
||||
'laundry.order.line', 'order_id', string='Order Lines',
|
||||
)
|
||||
|
||||
# -- Financial snapshot --
|
||||
amount_total = fields.Monetary(
|
||||
string='Total',
|
||||
currency_field='currency_id',
|
||||
readonly=True, copy=False, store=True,
|
||||
)
|
||||
|
||||
amount_paid_cash = fields.Monetary(
|
||||
string='Paid (Cash/Card)',
|
||||
currency_field='currency_id',
|
||||
readonly=True, copy=False, store=True,
|
||||
default=0.0,
|
||||
help='Amount collected at origin via non-deferred payment methods '
|
||||
'(cash, card — any pos.payment.method with split_transactions=False).',
|
||||
)
|
||||
|
||||
amount_deferred = fields.Monetary(
|
||||
string='Deferred',
|
||||
currency_field='currency_id',
|
||||
readonly=True, copy=False, store=True,
|
||||
default=0.0,
|
||||
help='Amount deferred at origin via Customer Account / pay-later '
|
||||
'(any pos.payment.method with split_transactions=True).',
|
||||
)
|
||||
|
||||
amount_settled = fields.Monetary(
|
||||
string='Settled',
|
||||
currency_field='currency_id',
|
||||
readonly=True, copy=False, store=True,
|
||||
default=0.0,
|
||||
help='Amount collected later via the settlement engine '
|
||||
'(account.payment + reconciliation).',
|
||||
)
|
||||
|
||||
amount_due = fields.Monetary(
|
||||
string='Due',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_amount_due',
|
||||
store=True,
|
||||
help='Remaining balance the customer owes: amount_deferred - amount_settled.',
|
||||
)
|
||||
|
||||
# Back-compat alias — views/reports still reference `amount_paid`.
|
||||
# Computed, non-stored; reflects real cash collected at origin.
|
||||
amount_paid = fields.Monetary(
|
||||
string='Paid',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_amount_paid_alias',
|
||||
)
|
||||
|
||||
# -- Computed --
|
||||
item_count = fields.Integer(
|
||||
string='Items',
|
||||
compute='_compute_item_count',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# -- Order type / attributes / delivery --
|
||||
order_type_id = fields.Many2one(
|
||||
'laundry.order.type', string='Order Type',
|
||||
index=True, tracking=True,
|
||||
)
|
||||
attribute_ids = fields.Many2many(
|
||||
'laundry.order.attribute',
|
||||
'laundry_order_attribute_rel',
|
||||
'order_id', 'attribute_id',
|
||||
string='Attributes',
|
||||
)
|
||||
is_delivery = fields.Boolean(string='Delivery', tracking=True, index=True)
|
||||
delivery_address = fields.Text(string='Delivery Address')
|
||||
delivery_scheduled_at = fields.Datetime(string='Scheduled At')
|
||||
priority_level = fields.Selection([
|
||||
('normal', 'Normal'),
|
||||
('urgent', 'Urgent'),
|
||||
], string='Priority', default='normal', tracking=True, index=True)
|
||||
|
||||
# -- Notes --
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
# ── Source / locking (Phase 3) ────────────────────────────────────
|
||||
# source_type is the truth-bearing identity. is_from_pos is a stored
|
||||
# mirror used in domains, list filters, and rule conditions where a
|
||||
# selection field would be awkward.
|
||||
source_type = fields.Selection(
|
||||
SOURCE_TYPES,
|
||||
string='Source',
|
||||
required=True,
|
||||
default='manual',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
index=True,
|
||||
tracking=True,
|
||||
help='POS-sourced orders are hard-locked: lines, prices and the '
|
||||
'customer cannot be edited unless a manager grants a '
|
||||
'temporary unlock window. Manual orders are editable until '
|
||||
'they reach a final state (delivered / cancelled).',
|
||||
)
|
||||
|
||||
is_from_pos = fields.Boolean(
|
||||
string='From POS',
|
||||
compute='_compute_is_from_pos',
|
||||
store=True, index=True,
|
||||
)
|
||||
|
||||
# Phase-4 prep — flag only, no logic wired yet.
|
||||
tracking_enabled = fields.Boolean(
|
||||
string='Per-Item Tracking',
|
||||
default=False,
|
||||
copy=False,
|
||||
help='When enabled, each laundry.order.line will be advanced '
|
||||
'through its own state machine. Phase 4 wires the '
|
||||
'synchronization between order state and item state.',
|
||||
)
|
||||
|
||||
# Computed: True when the order refuses mutation of LOCKED_HEADER_FIELDS
|
||||
# and any line write/create/unlink. Not stored — cheap to recompute and
|
||||
# depends on a Datetime that ages out without a write.
|
||||
locked = fields.Boolean(
|
||||
string='Locked',
|
||||
compute='_compute_locked',
|
||||
help='Order is read-only when True. POS-sourced orders are '
|
||||
'always locked. Final-state orders (delivered, cancelled) '
|
||||
'are always locked. Managers can grant a temporary unlock '
|
||||
'window via the "Unlock for Editing" action.',
|
||||
)
|
||||
|
||||
manager_unlocked_until = fields.Datetime(
|
||||
string='Unlock Window Expires',
|
||||
copy=False, readonly=True,
|
||||
help='When set in the future, the lock guard is suspended. '
|
||||
'Auto-expires; no manual re-lock required.',
|
||||
)
|
||||
|
||||
manager_unlocked_by = fields.Many2one(
|
||||
'res.users', string='Last Unlocked By',
|
||||
copy=False, readonly=True,
|
||||
)
|
||||
|
||||
manager_unlock_reason = fields.Char(
|
||||
string='Last Unlock Reason',
|
||||
copy=False, readonly=True,
|
||||
)
|
||||
|
||||
# Stamped when the order moves to delivered. Powers the avg-processing
|
||||
# and on-time KPIs on the Operations Dashboard. Outside
|
||||
# LOCKED_HEADER_FIELDS so action_deliver can write it on POS-locked
|
||||
# orders without needing the bypass context.
|
||||
delivered_at = fields.Datetime(
|
||||
string='Delivered At',
|
||||
readonly=True, copy=False, index=True,
|
||||
help='Timestamp set automatically when the order moves to '
|
||||
'Delivered. Used by the analytics dashboard to compute '
|
||||
'processing time and on-time delivery percentage.',
|
||||
)
|
||||
|
||||
# -- Constraints --
|
||||
_pos_order_uniq = models.Constraint(
|
||||
'UNIQUE(pos_order_id)',
|
||||
'A laundry order already exists for this POS order.',
|
||||
)
|
||||
|
||||
@api.constrains('source_type', 'pos_order_id')
|
||||
def _check_source_type_consistency(self):
|
||||
for order in self:
|
||||
if order.source_type == 'pos' and not order.pos_order_id:
|
||||
raise UserError(_(
|
||||
'Order "%s" is marked as POS-sourced but has no '
|
||||
'linked POS order.', order.name or order.id,
|
||||
))
|
||||
if order.source_type == 'manual' and order.pos_order_id:
|
||||
raise UserError(_(
|
||||
'Order "%s" is marked as manual but has a linked '
|
||||
'POS order. Set source_type="pos" to keep them '
|
||||
'consistent.', order.name or order.id,
|
||||
))
|
||||
|
||||
# -- Computed --
|
||||
@api.depends('amount_deferred', 'amount_settled')
|
||||
def _compute_amount_due(self):
|
||||
for order in self:
|
||||
order.amount_due = max(
|
||||
(order.amount_deferred or 0.0) - (order.amount_settled or 0.0),
|
||||
0.0,
|
||||
)
|
||||
|
||||
@api.depends('amount_paid_cash')
|
||||
def _compute_amount_paid_alias(self):
|
||||
for order in self:
|
||||
order.amount_paid = order.amount_paid_cash or 0.0
|
||||
|
||||
@api.depends('source_type')
|
||||
def _compute_is_from_pos(self):
|
||||
for order in self:
|
||||
order.is_from_pos = order.source_type == 'pos'
|
||||
|
||||
@api.depends('source_type', 'state', 'manager_unlocked_until')
|
||||
def _compute_locked(self):
|
||||
now = fields.Datetime.now()
|
||||
for order in self:
|
||||
unlock_active = bool(
|
||||
order.manager_unlocked_until
|
||||
and order.manager_unlocked_until > now
|
||||
)
|
||||
base_locked = (
|
||||
order.source_type == 'pos'
|
||||
or order.state in FINAL_STATES
|
||||
)
|
||||
order.locked = base_locked and not unlock_active
|
||||
|
||||
# ── Lock enforcement helpers ──────────────────────────────────────
|
||||
def _is_pos_sync(self):
|
||||
"""True when the call originates from the POS sync hook (or any
|
||||
explicit server path that opts in via the context flag).
|
||||
|
||||
Both create() and write() honour this so the bridge from
|
||||
pos.order can build / refresh the laundry.order without fighting
|
||||
its own lock guard. This is checked BEFORE anything else in
|
||||
write() so indirect POS writes (stored-compute flushes, cascades)
|
||||
can never raise from the guard.
|
||||
"""
|
||||
return bool(self.env.context.get(POS_SYNC_CTX))
|
||||
|
||||
def _check_lock_for_write(self, vals):
|
||||
"""Raise UserError when `vals` would mutate a protected header
|
||||
field on a currently-locked order. Workflow advances (state,
|
||||
amount_settled, notes, manager_unlocked_*) are excluded by
|
||||
whitelist (LOCKED_HEADER_FIELDS).
|
||||
|
||||
Note: the POS-sync bypass is already applied at the top of
|
||||
`write()` — this helper is only invoked for non-bypassed paths.
|
||||
"""
|
||||
protected = LOCKED_HEADER_FIELDS.intersection(vals.keys())
|
||||
if not protected:
|
||||
return
|
||||
for order in self:
|
||||
if order.locked:
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" is locked. Editable fields: state '
|
||||
'transitions, internal notes, settlement amount.\n'
|
||||
'To edit %(fields)s, ask a manager to use '
|
||||
'"Unlock for Editing" first.',
|
||||
name=order.name,
|
||||
fields=', '.join(sorted(protected)),
|
||||
))
|
||||
|
||||
def _check_lock_for_unlink(self):
|
||||
"""POS-sourced and final-state orders cannot be unlinked. The
|
||||
manager unlock wizard is intentionally NOT honored here — deletion
|
||||
requires a stronger affordance (cancellation + audit trail), not
|
||||
a temporary edit window.
|
||||
"""
|
||||
for order in self:
|
||||
if order.source_type == 'pos':
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" was created from POS and cannot '
|
||||
'be deleted. Cancel the underlying POS order instead.',
|
||||
name=order.name,
|
||||
))
|
||||
if order.state in FINAL_STATES:
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" is in a final state (%(state)s) '
|
||||
'and cannot be deleted.',
|
||||
name=order.name,
|
||||
state=dict(STATES).get(order.state, order.state),
|
||||
))
|
||||
|
||||
@api.depends('line_ids.qty')
|
||||
def _compute_item_count(self):
|
||||
for order in self:
|
||||
order.item_count = int(sum(order.line_ids.mapped('qty')))
|
||||
|
||||
# -- ORM --
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', 'New') == 'New':
|
||||
vals['name'] = (
|
||||
self.env['ir.sequence'].next_by_code('laundry.order')
|
||||
or 'New'
|
||||
)
|
||||
self._apply_type_attribute_inference(vals)
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
# STEP 1 — POS sync bypass.
|
||||
# Must be the very first thing we do. Any code path that opts
|
||||
# into the context flag (POS create/sync, settlement engine,
|
||||
# stored-compute flushes triggered from inside a bypassed write)
|
||||
# MUST sail through unconditionally. No locking check, no
|
||||
# side-effects, no iteration over self — just delegate to super.
|
||||
# This guarantee is what makes POS payment/settlement flows
|
||||
# immune to this model's lock guard.
|
||||
if self._is_pos_sync():
|
||||
if _logger.isEnabledFor(logging.DEBUG):
|
||||
_logger.debug(
|
||||
'laundry.order.write BYPASS ids=%s keys=%s',
|
||||
self.ids, list(vals.keys()),
|
||||
)
|
||||
return super().write(vals)
|
||||
|
||||
# STEP 2 — Forensic trace (DEBUG-level; off by default in prod,
|
||||
# enable with `--log-level=debug` or `--log-handler=odoo.addons
|
||||
# .laundry_management.models.laundry_order:DEBUG`).
|
||||
if _logger.isEnabledFor(logging.DEBUG):
|
||||
keys = list(vals.keys())
|
||||
for order in self:
|
||||
_logger.debug(
|
||||
'laundry.order.write id=%s source=%s state=%s '
|
||||
'locked=%s ctx_keys=%s vals_keys=%s',
|
||||
order.id, order.source_type, order.state, order.locked,
|
||||
sorted(self.env.context.keys()), keys,
|
||||
)
|
||||
|
||||
# STEP 3 — Lock guard for non-POS callers.
|
||||
self._check_lock_for_write(vals)
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
# Explicit: even with POS-sync context, refuse to delete a locked
|
||||
# order. Deletion is not a mutation the sync path ever issues.
|
||||
self._check_lock_for_unlink()
|
||||
return super().unlink()
|
||||
|
||||
@api.model
|
||||
def _apply_type_attribute_inference(self, vals):
|
||||
"""Fill in priority_level / is_delivery from the selected order
|
||||
type and attributes when the caller did not explicitly set them.
|
||||
|
||||
Rules:
|
||||
- type.priority='urgent' OR any attribute with
|
||||
is_priority_related=True → priority_level='urgent'
|
||||
- type.is_delivery=True OR any attribute with
|
||||
is_delivery_related=True → is_delivery=True
|
||||
- Do NOT overwrite explicit incoming delivery_address /
|
||||
delivery_scheduled_at with blank values.
|
||||
"""
|
||||
type_id = vals.get('order_type_id')
|
||||
order_type = (
|
||||
self.env['laundry.order.type'].browse(type_id) if type_id else None
|
||||
)
|
||||
|
||||
attribute_ids = []
|
||||
raw_attrs = vals.get('attribute_ids') or []
|
||||
for cmd in raw_attrs:
|
||||
if isinstance(cmd, (list, tuple)) and len(cmd) >= 3:
|
||||
# Odoo x2m commands: (6,0,[ids]), (4,id), etc.
|
||||
if cmd[0] == 6 and isinstance(cmd[2], (list, tuple)):
|
||||
attribute_ids.extend(cmd[2])
|
||||
elif cmd[0] == 4 and cmd[1]:
|
||||
attribute_ids.append(cmd[1])
|
||||
elif isinstance(cmd, int):
|
||||
attribute_ids.append(cmd)
|
||||
attributes = (
|
||||
self.env['laundry.order.attribute'].browse(attribute_ids)
|
||||
if attribute_ids else self.env['laundry.order.attribute']
|
||||
)
|
||||
|
||||
# Priority
|
||||
if 'priority_level' not in vals:
|
||||
urgent = (
|
||||
(order_type and order_type.priority == 'urgent')
|
||||
or any(a.is_priority_related for a in attributes)
|
||||
)
|
||||
vals['priority_level'] = 'urgent' if urgent else 'normal'
|
||||
|
||||
# Delivery
|
||||
if 'is_delivery' not in vals:
|
||||
delivery = (
|
||||
(order_type and order_type.is_delivery)
|
||||
or any(a.is_delivery_related for a in attributes)
|
||||
)
|
||||
vals['is_delivery'] = bool(delivery)
|
||||
|
||||
# -- Workflow actions --
|
||||
def action_process(self):
|
||||
for order in self:
|
||||
if order.state != 'intake':
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" is not in Intake state.',
|
||||
name=order.name,
|
||||
))
|
||||
order.state = 'processing'
|
||||
|
||||
def action_ready(self):
|
||||
for order in self:
|
||||
if order.state != 'processing':
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" is not in Processing state.',
|
||||
name=order.name,
|
||||
))
|
||||
order.state = 'ready'
|
||||
|
||||
def action_deliver(self):
|
||||
"""Guards: must be Ready + fully paid (amount_due == 0).
|
||||
|
||||
Also stamps `delivered_at` so the dashboard KPIs (avg processing
|
||||
time, on-time delivery %) can be computed from real data instead
|
||||
of the heuristic on `write_date`.
|
||||
"""
|
||||
for order in self:
|
||||
if order.state != 'ready':
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" must be Ready before delivery.',
|
||||
name=order.name,
|
||||
))
|
||||
if order.amount_due > 0:
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" has %(due).2f outstanding. '
|
||||
'Collect payment in POS before delivery.',
|
||||
name=order.name,
|
||||
due=order.amount_due,
|
||||
))
|
||||
order.write({
|
||||
'state': 'delivered',
|
||||
'delivered_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def action_cancel(self):
|
||||
"""Cancel an order. Allowed for manual orders only — POS-sourced
|
||||
orders must be voided through the POS workflow to keep the sale
|
||||
and the operational record in sync.
|
||||
"""
|
||||
for order in self:
|
||||
if order.source_type == 'pos':
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" was created from POS and cannot '
|
||||
'be cancelled here. Cancel the underlying POS order '
|
||||
'instead.',
|
||||
name=order.name,
|
||||
))
|
||||
if order.state in FINAL_STATES:
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" is already %(state)s.',
|
||||
name=order.name,
|
||||
state=dict(STATES).get(order.state, order.state),
|
||||
))
|
||||
order.state = 'cancelled'
|
||||
|
||||
# -- Smart button --
|
||||
def action_open_pos_order(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'pos.order',
|
||||
'res_id': self.pos_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════
|
||||
# POS "Laundry Orders" popup — server-side RPCs
|
||||
# ---------------------------------------------------------------
|
||||
# These methods are the ONLY way the POS popup interacts with the
|
||||
# model. Each action delegates to the corresponding workflow method
|
||||
# (`action_process` / `action_ready` / `action_deliver`) which already
|
||||
# enforces state + amount_due guards server-side. Direct writes to
|
||||
# LOCKED_HEADER_FIELDS are NOT exposed here — the Phase 3 lock
|
||||
# remains the sole authority for business-edit protection.
|
||||
# ═════════════════════════════════════════════════════════════════
|
||||
|
||||
def _pos_allowed_actions(self):
|
||||
"""Return the list of action keys the popup may render for this
|
||||
order. Pure function of state + amount_due. Final states only
|
||||
allow printing.
|
||||
"""
|
||||
self.ensure_one()
|
||||
actions = ['print_work_order']
|
||||
if self.state in FINAL_STATES:
|
||||
return actions
|
||||
if self.state == 'intake':
|
||||
actions.append('start_processing')
|
||||
elif self.state == 'processing':
|
||||
actions.append('mark_ready')
|
||||
elif self.state == 'ready':
|
||||
if self.amount_due <= 0:
|
||||
actions.append('deliver')
|
||||
else:
|
||||
actions.append('collect_payment')
|
||||
return actions
|
||||
|
||||
def _pos_payment_state(self):
|
||||
self.ensure_one()
|
||||
if self.amount_due > 0:
|
||||
return 'due'
|
||||
if self.amount_deferred > 0 and self.amount_settled >= self.amount_deferred:
|
||||
return 'settled'
|
||||
if self.amount_deferred > 0:
|
||||
return 'deferred'
|
||||
return 'paid'
|
||||
|
||||
def _pos_payload(self):
|
||||
"""Compact, UI-ready dict. Single source of truth for the popup
|
||||
shape — every RPC returns exactly this structure."""
|
||||
self.ensure_one()
|
||||
names = self.line_ids.mapped('product_id.name')
|
||||
if not names:
|
||||
names = self.line_ids.mapped('description')
|
||||
state_selection = dict(self._fields['state'].selection)
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'state': self.state,
|
||||
'state_label': state_selection.get(self.state) or self.state,
|
||||
'pos_reference': self.pos_reference or '',
|
||||
'is_from_pos': self.is_from_pos,
|
||||
'create_date': fields.Datetime.to_string(self.create_date) if self.create_date else False,
|
||||
'item_count': int(self.item_count or 0),
|
||||
'service_summary': ', '.join(dict.fromkeys(names))[:80],
|
||||
'amount_total': self.amount_total,
|
||||
'amount_paid': self.amount_paid_cash,
|
||||
'amount_deferred': self.amount_deferred,
|
||||
'amount_settled': self.amount_settled,
|
||||
'amount_due': self.amount_due,
|
||||
'payment_state': self._pos_payment_state(),
|
||||
'is_delivery': self.is_delivery,
|
||||
'allowed_actions': self._pos_allowed_actions(),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def pos_search_customer_orders(self, partner_id, search_query=False, limit=20):
|
||||
"""Partner-scoped search for the POS popup.
|
||||
|
||||
• Always filters by partner_id (no global search in this phase).
|
||||
• Optional search_query ilike-matches on name / pos_reference /
|
||||
partner_phone.
|
||||
• Hard-capped at 50 rows regardless of the caller's limit.
|
||||
"""
|
||||
if not partner_id:
|
||||
return []
|
||||
limit = max(1, min(int(limit or 20), 50))
|
||||
q = (search_query or '').strip()
|
||||
domain = [('partner_id', '=', partner_id)]
|
||||
if q:
|
||||
domain = [
|
||||
'&',
|
||||
('partner_id', '=', partner_id),
|
||||
'|', '|',
|
||||
('name', 'ilike', q),
|
||||
('pos_reference', 'ilike', q),
|
||||
('partner_phone', 'ilike', q),
|
||||
]
|
||||
orders = self.search(
|
||||
domain, order='create_date desc, id desc', limit=limit,
|
||||
)
|
||||
return [o._pos_payload() for o in orders]
|
||||
|
||||
def pos_action_start_processing(self):
|
||||
"""POS popup: advance intake → processing. Returns refreshed payload."""
|
||||
self.ensure_one()
|
||||
self.action_process()
|
||||
return self._pos_payload()
|
||||
|
||||
def pos_action_mark_ready(self):
|
||||
"""POS popup: advance processing → ready. Returns refreshed payload."""
|
||||
self.ensure_one()
|
||||
self.action_ready()
|
||||
return self._pos_payload()
|
||||
|
||||
def pos_action_deliver(self):
|
||||
"""POS popup: advance ready → delivered. Returns refreshed payload.
|
||||
|
||||
`action_deliver` raises UserError when amount_due > 0 — the popup
|
||||
surfaces that error; no client-side duplication of the rule.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self.action_deliver()
|
||||
return self._pos_payload()
|
||||
|
||||
@api.model
|
||||
def pos_get_thermal_data(self, order_id):
|
||||
"""Build a self-contained payload for the thermal Work-Order
|
||||
receipt rendered by `laundry_management.LaundryWorkOrderThermal`.
|
||||
|
||||
Independent from `_pos_payload` — that one is for the popup list
|
||||
(compact); this one carries every line + delivery meta the
|
||||
cashier needs on the printed slip.
|
||||
"""
|
||||
order = self.browse(int(order_id))
|
||||
if not order.exists():
|
||||
return False
|
||||
state_label = dict(order._fields['state'].selection).get(
|
||||
order.state, order.state,
|
||||
)
|
||||
return {
|
||||
'id': order.id,
|
||||
'name': order.name,
|
||||
'state': order.state,
|
||||
'state_label': state_label,
|
||||
'payment_state': order._pos_payment_state(),
|
||||
'pos_reference': order.pos_reference or '',
|
||||
'partner_name': order.partner_id.name or '',
|
||||
# `mobile` is provided by the optional `phone` add-on; fall
|
||||
# back gracefully when it isn't present in the install.
|
||||
'partner_phone': (
|
||||
order.partner_id.phone
|
||||
or getattr(order.partner_id, 'mobile', '')
|
||||
or ''
|
||||
),
|
||||
'company_name': order.company_id.name or '',
|
||||
'create_date': fields.Datetime.to_string(order.create_date),
|
||||
'lines': [{
|
||||
'qty': line.qty,
|
||||
'description': (
|
||||
line.description
|
||||
or (line.product_id.name if line.product_id else '')
|
||||
),
|
||||
'price_unit': line.price_unit,
|
||||
'subtotal': line.subtotal,
|
||||
'tracking_code': line.tracking_code or '',
|
||||
} for line in order.line_ids],
|
||||
'item_count': int(order.item_count or 0),
|
||||
'amount_total': order.amount_total,
|
||||
'amount_paid': order.amount_paid_cash,
|
||||
'amount_deferred': order.amount_deferred,
|
||||
'amount_settled': order.amount_settled,
|
||||
'amount_due': order.amount_due,
|
||||
'is_delivery': order.is_delivery,
|
||||
'delivery_address': order.delivery_address or '',
|
||||
'delivery_scheduled_at': (
|
||||
fields.Datetime.to_string(order.delivery_scheduled_at)
|
||||
if order.delivery_scheduled_at else ''
|
||||
),
|
||||
'currency_symbol': order.currency_id.symbol or '',
|
||||
'currency_position': order.currency_id.position or 'after',
|
||||
}
|
||||
|
||||
def action_open_unlock_wizard(self):
|
||||
"""Open the manager unlock wizard pre-filled with this order.
|
||||
Access is enforced inside the wizard's action method (group
|
||||
check + reason required), but we also short-circuit here so
|
||||
the button itself is silent for non-managers.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.locked:
|
||||
raise UserError(_(
|
||||
'Order "%s" is already editable.', self.name,
|
||||
))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'laundry.order.unlock.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_order_id': self.id,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user