Tower: upload laundry_management 19.0.19.0.4 (via marketplace)

This commit is contained in:
2026-05-01 15:01:14 +00:00
parent 850d9bb6c5
commit 3d69d5b814

View 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,
}