412 lines
17 KiB
Python
412 lines
17 KiB
Python
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,
|
|
}
|