Tower: upload laundry_management 19.0.19.0.4 (via marketplace)

This commit is contained in:
2026-05-01 15:01:12 +00:00
parent cba2ad4052
commit 89f02eeda5

View File

@@ -0,0 +1,513 @@
import logging
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
class ResPartnerLaundry(models.Model):
_inherit = 'res.partner'
laundry_unpaid_count = fields.Integer(
string='Unpaid Laundry Orders',
compute='_compute_laundry_unpaid_count',
)
# Customer defaults — seed values only; cashier can override in POS.
default_laundry_order_type_id = fields.Many2one(
'laundry.order.type', string='Default Laundry Order Type',
)
default_laundry_attribute_ids = fields.Many2many(
'laundry.order.attribute',
'res_partner_laundry_default_attr_rel',
'partner_id', 'attribute_id',
string='Default Laundry Attributes',
)
def _compute_laundry_unpaid_count(self):
if not self.ids:
self.laundry_unpaid_count = 0
return
self.env.cr.execute("""
SELECT partner_id, COUNT(*)
FROM laundry_order
WHERE partner_id IN %s
AND amount_due > 0
AND state != 'delivered'
GROUP BY partner_id
""", [tuple(self.ids)])
counts = dict(self.env.cr.fetchall())
for partner in self:
partner.laundry_unpaid_count = counts.get(partner.id, 0)
@api.model
def _load_pos_data_fields(self, config):
fields = super()._load_pos_data_fields(config)
fields.append('laundry_unpaid_count')
fields.append('default_laundry_order_type_id')
fields.append('default_laundry_attribute_ids')
return fields
@api.model
def laundry_find_by_phone(self, phone):
"""Search for existing partner by phone. Returns dict or False."""
phone_clean = phone.strip().replace(' ', '')
if not phone_clean:
return False
domain = [('phone', 'ilike', phone_clean)]
if 'mobile' in self._fields:
domain = ['|', ('phone', 'ilike', phone_clean), ('mobile', 'ilike', phone_clean)]
partner = self.search(domain, limit=1)
if partner:
phone_val = partner.phone or ''
if 'mobile' in self._fields and partner.mobile:
phone_val = phone_val or partner.mobile
return {'id': partner.id, 'name': partner.name, 'phone': phone_val}
return False
@api.model
def laundry_quick_create(self, vals):
"""Create partner from POS quick-create popup. Returns partner id.
UX contract:
- phone is required (the JS popup enforces this; we also guard).
- name is optional. If empty, the partner name defaults to the
phone number — keeps lists scannable when a cashier doesn't
have time to type a name.
- mobile (when the field exists in this Odoo install) is mirrored
from phone so duplicate-search by phone OR mobile keeps working.
"""
phone = (vals.get('phone') or '').strip()
if not phone:
raise UserError(_('Phone is required to create a customer.'))
name = (vals.get('name') or '').strip() or phone
street = (vals.get('street') or '').strip() or False
create_vals = {'name': name, 'phone': phone, 'street': street}
# `mobile` is provided by the optional phone add-on. Mirror only
# when the field exists, so this stays portable across Odoo
# distributions without bringing in extra deps.
if 'mobile' in self._fields:
create_vals['mobile'] = phone
partner = self.create(create_vals)
return partner.id
@api.model
def get_laundry_dues(self, partner_id):
"""Return outstanding laundry dues for a partner (read-only).
`amount_due` is now `amount_deferred - amount_settled` — the real
money still owed — not `total - paid`.
"""
orders = self.env['laundry.order'].search([
('partner_id', '=', partner_id),
('amount_due', '>', 0),
('state', '!=', 'delivered'),
], order='create_date asc, id asc')
order_details = []
total_due = 0.0
for o in orders:
total_due += o.amount_due
services = o.line_ids.mapped('product_id.name')
service_summary = ', '.join(dict.fromkeys(services))[:80]
order_details.append({
'id': o.id,
'name': o.name,
'amount_due': o.amount_due,
'amount_total': o.amount_total,
'amount_paid_cash': o.amount_paid_cash,
'amount_deferred': o.amount_deferred,
'amount_settled': o.amount_settled,
'state': o.state,
'intake_date': fields.Datetime.to_string(o.create_date) if o.create_date else False,
'item_count': o.item_count,
'service_summary': service_summary or '',
})
return {
'total_due': total_due,
'order_count': len(orders),
'orders': order_details,
}
# ─────────────────────────────────────────────────────────────────────
# Settlement engine
# CASH → account.bank.statement.line (Dr Cash / Cr Receivable)
# + immediate reconcile vs open AR debits (FIFO)
# → POS expected cash auto-includes it via statement_line_ids
# NON-CASH → account.payment (Dr Bank / Cr Receivable) + reconcile
# → drawer untouched, no duplication risk
# ─────────────────────────────────────────────────────────────────────
@api.model
def settle_laundry_dues_rpc(self, partner_id, payment_lines, pos_session_id=None):
"""Collect a laundry receivable via one or more journal entries.
Per-line routing by journal type — see module docstring above.
Args:
partner_id: res.partner id.
payment_lines: list of dicts, each with:
- pos_payment_method_id: pos.payment.method id
- amount: float > 0
pos_session_id: optional pos.session id. For CASH it tags the
statement line so POS includes the amount in expected cash.
For NON-CASH it's stamped on account.payment for audit.
Returns:
dict with: settled_total, remaining_due, payments[], settled_orders[].
"""
partner = self.browse(partner_id)
if not partner.exists():
raise UserError(_('Customer not found.'))
if not payment_lines or not isinstance(payment_lines, list):
raise UserError(_('At least one payment line is required.'))
normalized = []
total = 0.0
for idx, line in enumerate(payment_lines, start=1):
if not isinstance(line, dict):
raise UserError(_('Payment line %s is malformed.', idx))
method_id = line.get('pos_payment_method_id')
if not method_id:
raise UserError(_('Payment line %s has no payment method.', idx))
try:
amt = float(line.get('amount') or 0.0)
except (TypeError, ValueError):
raise UserError(_('Payment line %s has an invalid amount.', idx))
if amt <= 0:
raise UserError(_('Payment line %s must have a positive amount.', idx))
journal = self._resolve_settlement_journal(method_id)
normalized.append({
'method_id': int(method_id),
'amount': amt,
'journal': journal,
})
total += amt
session = None
if pos_session_id:
session = self.env['pos.session'].browse(int(pos_session_id))
if not session.exists():
session = None
receipt_entries = []
for line in normalized:
journal = line['journal']
method = self.env['pos.payment.method'].browse(line['method_id'])
if journal.type == 'cash':
stmt_line = self._create_cash_settlement_statement_line(
partner, journal, line['amount'], session,
)
self._reconcile_statement_line_to_receivable(stmt_line, partner)
receipt_entries.append({
'id': stmt_line.id,
'name': stmt_line.move_id.name or stmt_line.payment_ref,
'state': stmt_line.move_id.state,
'amount': stmt_line.amount,
'journal_name': journal.name,
'journal_type': 'cash',
'method_name': method.name or journal.name,
})
else:
pmt = self._create_settlement_account_payment(
partner, journal, line['amount'], line['method_id'], session,
)
receipt_entries.append({
'id': pmt.id,
'name': pmt.name,
'state': pmt.state,
'amount': pmt.amount,
'journal_name': pmt.journal_id.name,
'journal_type': pmt.journal_id.type,
'method_name': pmt.settlement_pos_pm_id.name or pmt.journal_id.name,
})
settled_orders, remaining_due = self._distribute_settlement_fifo(partner, total)
_logger.info(
'Settlement RPC: partner=%s total=%.2f lines=%d remaining=%.2f',
partner.name, total, len(receipt_entries), remaining_due,
)
return {
'settled_total': total,
'remaining_due': remaining_due,
'payments': receipt_entries,
'settled_orders': settled_orders,
}
@api.model
def _create_cash_settlement_statement_line(self, partner, journal, amount, session):
"""Create a POS-tagged cash-in statement line that posts directly
Dr Cash / Cr Receivable.
`counterpart_account_id` is consumed by account.bank.statement.line.create
to override the journal's default suspense account — so the credit
leg lands on the partner's receivable account, ready for reconcile.
The statement line auto-posts its move (Odoo handles action_post).
Tagging `pos_session_id` makes the amount flow into POS's native
expected-cash formula (statement_line_ids sum) → no closing diff.
"""
receivable = partner.property_account_receivable_id
if not receivable:
raise UserError(_(
'Customer "%s" has no receivable account configured.',
partner.name,
))
vals = {
'journal_id': journal.id,
'amount': amount,
'partner_id': partner.id,
'payment_ref': _('Customer Settlement (POS) — %s', partner.name),
'counterpart_account_id': receivable.id,
'date': fields.Date.context_today(self),
}
if session:
vals['pos_session_id'] = session.id
try:
return self.env['account.bank.statement.line'].sudo().create(vals)
except Exception as e:
_logger.exception(
'Failed to create cash settlement statement line for partner %s',
partner.id,
)
raise UserError(_('Failed to record cash settlement: %s', e))
@api.model
def _reconcile_statement_line_to_receivable(self, stmt_line, partner):
"""Reconcile the statement line's receivable credit against the
partner's open receivable debits (FIFO).
Both sides live on the partner's receivable account, so a direct
AML.reconcile() fully settles the customer due in one step.
"""
receivable = partner.property_account_receivable_id
if not receivable:
return
counterpart_credits = stmt_line.move_id.line_ids.filtered(
lambda l: l.account_id == receivable
and l.credit > 0
and not l.reconciled
)
if not counterpart_credits:
return
AML = self.env['account.move.line']
open_debits = AML.search([
('partner_id', '=', partner.id),
('account_id', '=', receivable.id),
('reconciled', '=', False),
('parent_state', '=', 'posted'),
('debit', '>', 0),
], order='date asc, id asc')
if not open_debits:
return
try:
(counterpart_credits | open_debits).reconcile()
except Exception:
_logger.exception(
'Reconciliation failed for cash settlement statement line %s',
stmt_line.id,
)
@api.model
def _create_settlement_account_payment(
self, partner, journal, amount, method_id, session,
):
"""Non-cash settlement: standard account.payment + reconcile."""
Payment = self.env['account.payment']
pmt_vals = {
'payment_type': 'inbound',
'partner_type': 'customer',
'partner_id': partner.id,
'amount': amount,
'journal_id': journal.id,
'currency_id': (journal.currency_id or self.env.company.currency_id).id,
'date': fields.Date.context_today(self),
'memo': _('Laundry dues settlement — %s', partner.name),
'settlement_pos_pm_id': method_id,
}
if session:
pmt_vals['pos_session_id'] = session.id
pmt = Payment.sudo().create(pmt_vals)
try:
pmt.action_post()
except Exception as e:
_logger.exception(
'Failed to post settlement payment for partner %s', partner.id,
)
raise UserError(_('Failed to post payment: %s', e))
self._reconcile_settlement_payment(pmt, partner)
return pmt
@api.model
def _resolve_settlement_journal(self, pos_payment_method_id):
"""Pick the account.journal for a settlement payment.
1. If a pos.payment.method id is given, enforce split_transactions=False
and return its journal.
2. Else return the company's default cash journal, then bank journal.
Raises UserError if no valid journal can be found.
"""
if pos_payment_method_id:
method = self.env['pos.payment.method'].browse(int(pos_payment_method_id))
if not method.exists():
raise UserError(_('Selected payment method not found.'))
if method.split_transactions:
raise ValidationError(_(
'Customer Account / pay-later methods cannot be used to '
'settle laundry dues — they would create new receivable '
'instead of reducing it.'
))
if not method.journal_id:
raise UserError(_(
'Payment method "%s" has no journal configured.',
method.name,
))
return method.journal_id
Journal = self.env['account.journal']
j = Journal.search([
('company_id', '=', self.env.company.id),
('type', '=', 'cash'),
], limit=1)
if not j:
j = Journal.search([
('company_id', '=', self.env.company.id),
('type', '=', 'bank'),
], limit=1)
if not j:
raise UserError(_('No cash or bank journal available for settlement.'))
return j
@api.model
def _reconcile_settlement_payment(self, payment, partner):
"""Reconcile the payment's partner-receivable line against open
receivable AMLs from prior customer-account POS sales.
Journals without an outstanding-receipts account post directly to the
partner receivable (Dr Bank / Cr Receivable, state=paid). Journals
configured with an outstanding-receipts account post to it first
(Dr Bank / Cr Outstanding Receipts, state=in_process) — no partner
receivable line yet, so there's nothing to reconcile here until the
bank statement clears. This method safely no-ops in that case.
"""
receivable_account = partner.property_account_receivable_id
if not receivable_account:
_logger.warning('Partner %s has no receivable account', partner.id)
return
AML = self.env['account.move.line']
payment_lines = payment.move_id.line_ids.filtered(
lambda l: l.account_id == receivable_account and not l.reconciled
)
if not payment_lines:
return # state=in_process — reconciliation happens at bank clearing
open_debits = AML.search([
('partner_id', '=', partner.id),
('account_id', '=', receivable_account.id),
('reconciled', '=', False),
('parent_state', '=', 'posted'),
('debit', '>', 0),
], order='date asc, id asc')
if not open_debits:
return
try:
(payment_lines | open_debits).reconcile()
except Exception:
_logger.exception(
'Reconciliation failed for settlement payment %s', payment.name,
)
@api.model
def _distribute_settlement_fifo(self, partner, amount):
"""Spread an already-collected amount across the partner's open
laundry orders, oldest first. Returns (settled_orders, remaining_due).
"""
LaundryOrder = self.env['laundry.order']
open_orders = LaundryOrder.search([
('partner_id', '=', partner.id),
('amount_due', '>', 0),
('state', '!=', 'delivered'),
], order='create_date asc, id asc')
remaining = amount
settled_orders = []
for lo in open_orders:
if remaining <= 0:
break
apply_amount = min(remaining, lo.amount_due)
# Defense-in-depth: `amount_settled` is not in
# LOCKED_HEADER_FIELDS so this write already sails through,
# but we pass laundry_pos_sync=True to guarantee the bypass
# even if the locked-field whitelist changes later.
lo.sudo().with_context(laundry_pos_sync=True).write({
'amount_settled': (lo.amount_settled or 0.0) + apply_amount,
})
remaining -= apply_amount
settled_orders.append({
'id': lo.id,
'name': lo.name,
'applied': apply_amount,
'remaining_on_order': lo.amount_due,
})
total_due = sum(
LaundryOrder.search([
('partner_id', '=', partner.id),
('amount_due', '>', 0),
('state', '!=', 'delivered'),
]).mapped('amount_due')
)
return settled_orders, total_due
@api.model
def get_session_settlements(self, pos_session_id):
"""Return laundry settlement payments stamped on a POS session.
Used by the closing-screen extension to show a read-only summary
of settlement collections made during this session. Does NOT inject
into POS cash-control totals.
"""
Payment = self.env['account.payment']
payments = Payment.sudo().search([
('pos_session_id', '=', int(pos_session_id)),
('payment_type', '=', 'inbound'),
('partner_type', '=', 'customer'),
], order='date asc, id asc')
by_journal = {}
for p in payments:
jname = p.journal_id.name
jtype = p.journal_id.type # 'cash' or 'bank'
key = jname
if key not in by_journal:
by_journal[key] = {'name': jname, 'type': jtype, 'total': 0.0}
by_journal[key]['total'] += p.amount
return {
'total': sum(p.amount for p in payments),
'count': len(payments),
'by_journal': list(by_journal.values()),
'payments': [{
'id': p.id,
'name': p.name,
'partner_name': p.partner_id.name,
'amount': p.amount,
'journal_name': p.journal_id.name,
'date': fields.Date.to_string(p.date),
} for p in payments],
}
@api.model
def get_laundry_orders_for_pos(self, partner_id, limit=20):
"""Legacy shim — delegates to the canonical method on laundry.order.
Kept so older POS bundles that still call this RPC keep working.
New code should call `laundry.order.pos_search_customer_orders`
directly; it supports a `search_query` parameter and returns a
richer payload (payment_state, allowed_actions, …).
"""
return self.env['laundry.order'].pos_search_customer_orders(
partner_id=partner_id, limit=limit,
)