Tower: upload laundry_management 19.0.19.0.4 (via marketplace)
This commit is contained in:
411
addons/laundry_management/models/pos_order.py
Normal file
411
addons/laundry_management/models/pos_order.py
Normal file
@@ -0,0 +1,411 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PosOrderLaundryExt(models.Model):
|
||||
"""Extend pos.order to:
|
||||
1. Auto-create laundry.order for sales containing laundry products.
|
||||
2. Split POS payments into real cash vs deferred (Customer Account).
|
||||
|
||||
Settlement is NO LONGER a POS order. It is a pure account.payment
|
||||
invoked via res.partner.settle_laundry_dues_rpc — see res_partner.py.
|
||||
|
||||
The `is_laundry_settlement` field and the old settlement-product path
|
||||
are preserved in schema form for historical compatibility with DB rows
|
||||
created before this refactor, but the sync hook no longer creates or
|
||||
processes settlement POS orders.
|
||||
"""
|
||||
_inherit = 'pos.order'
|
||||
|
||||
laundry_order_id = fields.Many2one(
|
||||
'laundry.order', string='Laundry Order',
|
||||
readonly=True, copy=False, index=True,
|
||||
)
|
||||
is_laundry_settlement = fields.Boolean(
|
||||
string='Laundry Settlement (legacy)',
|
||||
readonly=True, copy=False, default=False,
|
||||
help='Legacy flag from the old settlement-product flow. '
|
||||
'New settlements go through account.payment directly and do '
|
||||
'not create POS orders.',
|
||||
)
|
||||
|
||||
# -- Order type / attributes / delivery (set in POS UI) --
|
||||
laundry_order_type_id = fields.Many2one(
|
||||
'laundry.order.type', string='Laundry Order Type',
|
||||
index=True, copy=False,
|
||||
)
|
||||
laundry_order_attribute_ids = fields.Many2many(
|
||||
'laundry.order.attribute',
|
||||
'pos_order_laundry_attribute_rel',
|
||||
'pos_order_id', 'attribute_id',
|
||||
string='Laundry Attributes',
|
||||
copy=False,
|
||||
)
|
||||
laundry_is_delivery = fields.Boolean(
|
||||
string='Laundry Delivery', copy=False,
|
||||
)
|
||||
laundry_delivery_address = fields.Text(
|
||||
string='Laundry Delivery Address', copy=False,
|
||||
)
|
||||
laundry_delivery_scheduled_at = fields.Datetime(
|
||||
string='Laundry Delivery Scheduled At', copy=False,
|
||||
)
|
||||
|
||||
# NOTE: pos.order._load_pos_data_fields is intentionally NOT overridden.
|
||||
# Bisection proved that adding ANY custom field to this loader breaks
|
||||
# POS order construction (`lines is undefined` in _computeAllPrices).
|
||||
# Custom values are sent by serializeForORM (see pos_order_patch.js)
|
||||
# and written directly by core's _process_order via create(**order),
|
||||
# since the columns already exist on this model.
|
||||
|
||||
def action_open_laundry_order(self):
|
||||
self.ensure_one()
|
||||
if not self.laundry_order_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'laundry.order',
|
||||
'res_id': self.laundry_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Sync hook
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@api.model
|
||||
def _extract_pos_order_ids(self, sync_result):
|
||||
"""Defensive extraction of pos.order ids from the value returned
|
||||
by `super().sync_from_ui(...)`. Across Odoo 19 patch levels and
|
||||
edge paths this value can be:
|
||||
|
||||
- a dict {'pos.order': [{'id': N, ...}, ...], ...}
|
||||
- a list of integer ids
|
||||
- a recordset of pos.order (if a downstream module already
|
||||
normalized it)
|
||||
- an empty container of any of the above
|
||||
|
||||
Returns a list of integer pos.order ids. Logs unknown shapes
|
||||
instead of silently dropping them.
|
||||
"""
|
||||
if not sync_result:
|
||||
return []
|
||||
# Recordset → use .ids
|
||||
if isinstance(sync_result, models.BaseModel):
|
||||
return list(sync_result.ids)
|
||||
# Dict payload (canonical in mainline Odoo 19)
|
||||
if isinstance(sync_result, dict):
|
||||
payload = sync_result.get('pos.order', [])
|
||||
ids = []
|
||||
for entry in payload or []:
|
||||
if isinstance(entry, dict) and entry.get('id'):
|
||||
ids.append(entry['id'])
|
||||
elif isinstance(entry, int):
|
||||
ids.append(entry)
|
||||
return ids
|
||||
# Plain list of ids (some patch levels / community forks)
|
||||
if isinstance(sync_result, (list, tuple)):
|
||||
ids = []
|
||||
for entry in sync_result:
|
||||
if isinstance(entry, int):
|
||||
ids.append(entry)
|
||||
elif isinstance(entry, dict) and entry.get('id'):
|
||||
ids.append(entry['id'])
|
||||
return ids
|
||||
_logger.warning(
|
||||
"[laundry] unknown sync_from_ui return shape: %s %r",
|
||||
type(sync_result).__name__, sync_result,
|
||||
)
|
||||
return []
|
||||
|
||||
@api.model
|
||||
def sync_from_ui(self, orders):
|
||||
# CRITICAL: super() runs the entire POS payment commit. The
|
||||
# laundry hand-off MUST be additive and side-effect-free if it
|
||||
# fails — savepoint per pos.order so a SQL-level error cannot
|
||||
# poison the parent transaction.
|
||||
result = super().sync_from_ui(orders)
|
||||
|
||||
order_ids = self._extract_pos_order_ids(result)
|
||||
_logger.info(
|
||||
"[laundry] sync_from_ui post-process: %d pos.order id(s) extracted",
|
||||
len(order_ids),
|
||||
)
|
||||
|
||||
if not order_ids:
|
||||
return result
|
||||
|
||||
# `.exists()` filters out any id the caller provided that isn't
|
||||
# actually in the DB anymore (deleted in a concurrent flow).
|
||||
# `sudo()` is bounded to this internal hand-off — POS users
|
||||
# may not hold full create/write rights on laundry.order, but
|
||||
# the hand-off itself is a server-controlled, validated path.
|
||||
for order in self.browse(order_ids).exists():
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
order.sudo()._maybe_create_laundry_order()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"[laundry] Failed to create/sync laundry.order from "
|
||||
"POS %s (POS sale committed; rolled back laundry-side "
|
||||
"savepoint only)", order.id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
# Same isolation as above. The amount-sync to laundry is a
|
||||
# SECONDARY effect of a POS payment write; it must never be the
|
||||
# reason a payment fails.
|
||||
if 'amount_paid' in vals or 'payment_ids' in vals or 'amount_total' in vals:
|
||||
for order in self:
|
||||
if not order.laundry_order_id:
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
order._sync_laundry_amounts()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'Failed to sync amounts to laundry order %s '
|
||||
'from POS %s (POS write succeeded; rolled back '
|
||||
'laundry-side savepoint only)',
|
||||
order.laundry_order_id.id, order.id,
|
||||
)
|
||||
return res
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Payment classification
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
def _classify_pos_payments(self):
|
||||
"""Return (cash_total, deferred_total) by inspecting pos.payment rows.
|
||||
|
||||
cash_total = Σ amount where method.split_transactions is False
|
||||
deferred_total = Σ amount where method.split_transactions is True
|
||||
"""
|
||||
self.ensure_one()
|
||||
cash_total = 0.0
|
||||
deferred_total = 0.0
|
||||
for pmt in self.payment_ids:
|
||||
method = pmt.payment_method_id
|
||||
if method and method.split_transactions:
|
||||
deferred_total += pmt.amount
|
||||
else:
|
||||
cash_total += pmt.amount
|
||||
return cash_total, deferred_total
|
||||
|
||||
def _sync_laundry_amounts(self):
|
||||
"""Push current financial split to the linked laundry.order.
|
||||
|
||||
amount_total / amount_paid_cash / amount_deferred are all in
|
||||
LOCKED_HEADER_FIELDS — without the POS-sync context bypass the
|
||||
lock guard would block this write on every payment edit.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.laundry_order_id:
|
||||
return
|
||||
cash, deferred = self._classify_pos_payments()
|
||||
self.laundry_order_id.sudo().with_context(
|
||||
laundry_pos_sync=True
|
||||
).write({
|
||||
'amount_total': self.amount_total,
|
||||
'amount_paid_cash': cash,
|
||||
'amount_deferred': deferred,
|
||||
})
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Laundry order creation
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
def _maybe_create_laundry_order(self):
|
||||
"""Create laundry.order if this POS order contains laundry products."""
|
||||
self.ensure_one()
|
||||
_logger.warning(
|
||||
"[laundry-trace] _maybe_create_laundry_order POS %s "
|
||||
"(ref=%s, lines=%d, existing_link=%s)",
|
||||
self.id, self.pos_reference or '-',
|
||||
len(self.lines), self.laundry_order_id.id or False,
|
||||
)
|
||||
|
||||
if self.laundry_order_id:
|
||||
_logger.warning(
|
||||
"[laundry-trace] POS %s already linked to laundry.order %s "
|
||||
"→ syncing amounts only", self.id, self.laundry_order_id.id,
|
||||
)
|
||||
self._sync_laundry_amounts()
|
||||
return
|
||||
|
||||
if not self.lines:
|
||||
_logger.warning("[laundry-trace] POS %s has no lines → skip", self.id)
|
||||
return
|
||||
|
||||
partner = self.partner_id or self.env.company.partner_id
|
||||
|
||||
laundry_lines = []
|
||||
skipped = []
|
||||
for line in self.lines:
|
||||
tmpl = line.product_id.product_tmpl_id if line.product_id else None
|
||||
if not tmpl:
|
||||
skipped.append((line.id, 'no template'))
|
||||
continue
|
||||
if not tmpl.is_laundry_service:
|
||||
skipped.append((line.id, f'not laundry ({tmpl.name})'))
|
||||
continue
|
||||
laundry_lines.append((0, 0, {
|
||||
'product_id': line.product_id.id,
|
||||
'description': line.full_product_name or line.product_id.name,
|
||||
'qty': line.qty,
|
||||
'price_unit': line.price_unit,
|
||||
'customer_note': line.customer_note or '',
|
||||
}))
|
||||
|
||||
_logger.warning(
|
||||
"[laundry-trace] POS %s line scan: %d laundry, %d skipped %s",
|
||||
self.id, len(laundry_lines), len(skipped), skipped[:5],
|
||||
)
|
||||
|
||||
if not laundry_lines:
|
||||
_logger.warning(
|
||||
"[laundry-trace] POS %s has no laundry-service lines → skip "
|
||||
"(this is the most common cause of 'no laundry order created')",
|
||||
self.id,
|
||||
)
|
||||
return
|
||||
|
||||
LaundryOrder = self.env['laundry.order']
|
||||
existing = LaundryOrder.search(
|
||||
[('pos_order_id', '=', self.id)], limit=1,
|
||||
)
|
||||
if existing:
|
||||
self.laundry_order_id = existing.id
|
||||
self._sync_laundry_amounts()
|
||||
return
|
||||
|
||||
cash, deferred = self._classify_pos_payments()
|
||||
create_vals = {
|
||||
'pos_order_id': self.id,
|
||||
'pos_reference': self.pos_reference or '',
|
||||
'partner_id': partner.id,
|
||||
'company_id': self.company_id.id,
|
||||
# Phase 3: every laundry.order born from POS is hard-locked
|
||||
# by `source_type`. Header lock + line lock both kick in
|
||||
# immediately. The POS-sync context bypass below lets the
|
||||
# initial create + line writes go through.
|
||||
'source_type': 'pos',
|
||||
'amount_total': self.amount_total,
|
||||
'amount_paid_cash': cash,
|
||||
'amount_deferred': deferred,
|
||||
'notes': self.general_customer_note or '',
|
||||
'line_ids': laundry_lines,
|
||||
}
|
||||
# Propagate order type / attributes / delivery — inference in
|
||||
# laundry.order.create fills priority_level and is_delivery from
|
||||
# these when not explicitly provided.
|
||||
if self.laundry_order_type_id:
|
||||
create_vals['order_type_id'] = self.laundry_order_type_id.id
|
||||
if self.laundry_order_attribute_ids:
|
||||
create_vals['attribute_ids'] = [(6, 0, self.laundry_order_attribute_ids.ids)]
|
||||
if self.laundry_is_delivery:
|
||||
create_vals['is_delivery'] = True
|
||||
if self.laundry_delivery_address:
|
||||
create_vals['delivery_address'] = self.laundry_delivery_address
|
||||
if self.laundry_delivery_scheduled_at:
|
||||
create_vals['delivery_scheduled_at'] = self.laundry_delivery_scheduled_at
|
||||
|
||||
laundry_order = LaundryOrder.with_context(
|
||||
laundry_pos_sync=True
|
||||
).create(create_vals)
|
||||
self.laundry_order_id = laundry_order.id
|
||||
|
||||
_logger.info(
|
||||
'Created laundry order %s from POS %s: total=%.2f, cash=%.2f, deferred=%.2f',
|
||||
laundry_order.name, self.pos_reference or self.id,
|
||||
self.amount_total, cash, deferred,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Integrity audit — Step 4 of the stabilization brief
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@api.model
|
||||
def audit_laundry_links(self, heal=False):
|
||||
"""Report (and optionally heal) POS orders that should have a
|
||||
linked laundry.order but don't.
|
||||
|
||||
Returns a dict:
|
||||
{
|
||||
'checked': int,
|
||||
'missing': [{pos_order_id, pos_reference, partner_name,
|
||||
amount_total}, ...],
|
||||
'duplicates': [pos_order_id, ...], # pos.order pointing to
|
||||
# a laundry.order that no
|
||||
# longer exists
|
||||
'healed': int, # populated only if heal=True
|
||||
}
|
||||
|
||||
A POS order is "should-link" when it has at least one line whose
|
||||
product_template is flagged is_laundry_service AND is NOT the
|
||||
settlement product. This is exactly the same condition
|
||||
_maybe_create_laundry_order applies, so the audit and the live
|
||||
hand-off agree.
|
||||
|
||||
Heal mode re-runs `_maybe_create_laundry_order` for missing
|
||||
rows, each in its own savepoint — same isolation guarantee as
|
||||
the live hand-off. Safe to call repeatedly.
|
||||
"""
|
||||
Order = self.env['pos.order']
|
||||
all_orders = Order.search([])
|
||||
missing = []
|
||||
duplicates = []
|
||||
for o in all_orders:
|
||||
has_laundry_line = any(
|
||||
line.product_id.product_tmpl_id.is_laundry_service
|
||||
and not line.product_id.product_tmpl_id.is_laundry_settlement
|
||||
for line in o.lines
|
||||
if line.product_id and line.product_id.product_tmpl_id
|
||||
)
|
||||
if not has_laundry_line:
|
||||
continue
|
||||
if o.laundry_order_id and not o.laundry_order_id.exists():
|
||||
# Stored fk to a deleted laundry.order — orphan link
|
||||
duplicates.append(o.id)
|
||||
continue
|
||||
if not o.laundry_order_id:
|
||||
missing.append({
|
||||
'pos_order_id': o.id,
|
||||
'pos_reference': o.pos_reference or '',
|
||||
'partner_name': o.partner_id.name if o.partner_id else '',
|
||||
'amount_total': o.amount_total,
|
||||
})
|
||||
|
||||
healed = 0
|
||||
if heal:
|
||||
for entry in missing:
|
||||
pos_order = Order.browse(entry['pos_order_id'])
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
pos_order._maybe_create_laundry_order()
|
||||
if pos_order.laundry_order_id:
|
||||
healed += 1
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'audit_laundry_links: heal failed for POS %s',
|
||||
pos_order.id,
|
||||
)
|
||||
# Clear orphan fk pointers (the linked laundry.order was deleted)
|
||||
for pos_id in duplicates:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
Order.browse(pos_id).laundry_order_id = False
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'audit_laundry_links: orphan-clear failed for POS %s',
|
||||
pos_id,
|
||||
)
|
||||
|
||||
return {
|
||||
'checked': len(all_orders),
|
||||
'missing': missing,
|
||||
'duplicates': duplicates,
|
||||
'healed': healed,
|
||||
}
|
||||
Reference in New Issue
Block a user