Tower: upload laundry_management 19.0.19.0.4 (via marketplace)
This commit is contained in:
513
addons/laundry_management/models/res_partner.py
Normal file
513
addons/laundry_management/models/res_partner.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user