Files
odoo-addons/addons/laundry_management/models/laundry_session.py

260 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from odoo import models, fields, api
from odoo.exceptions import UserError
class LaundrySession(models.Model):
"""Daily operational session — POS-style cash management.
A session groups all laundry orders placed in a single working shift.
Cash totals are derived from standard account.payment records linked
to the session's invoices, so figures are always in sync with Accounting.
Opening balance:
- When opening a new session, the system suggests the previous
session's actual_closing_cash as the opening float (carry-forward).
Closing:
- Staff enter the actual physical cash count.
- If a difference exists, a journal entry is posted (optional).
"""
_name = 'laundry.session'
_description = 'Laundry Daily Session'
_inherit = ['mail.thread']
_order = 'opening_datetime desc, id desc'
# ── Identity ──────────────────────────────────────────────────────
name = fields.Char(
string='Session',
required=True, copy=False, readonly=True,
default='New', tracking=True,
)
user_id = fields.Many2one(
'res.users', string='Opened By',
required=True, default=lambda self: self.env.user,
tracking=True,
)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
# ── State ─────────────────────────────────────────────────────────
state = fields.Selection([
('new', 'New'),
('opened', 'Open'),
('closed', 'Closed'),
], default='new', required=True, tracking=True, copy=False)
# ── Timing ────────────────────────────────────────────────────────
opening_datetime = fields.Datetime(string='Opened At', readonly=True)
closing_datetime = fields.Datetime(string='Closed At', readonly=True)
# ── Cash Control ──────────────────────────────────────────────────
opening_cash = fields.Float(
string='Opening Float',
digits=(10, 2),
help='Cash in the drawer at the start of the session.',
)
actual_closing_cash = fields.Float(
string='Actual Cash Count',
digits=(10, 2),
help='Physical cash counted at session close.',
)
expected_closing_cash = fields.Float(
string='Expected Cash',
compute='_compute_cash_control', store=True, digits=(10, 2),
)
cash_difference = fields.Float(
string='Difference',
compute='_compute_cash_control', store=True, digits=(10, 2),
help='Actual Expected. Negative = short.',
)
# ── Orders (sale.order with session_id = self) ────────────────────
order_ids = fields.One2many(
'sale.order', 'session_id', string='Orders',
)
order_count = fields.Integer(
compute='_compute_session_totals', store=True,
)
total_sales = fields.Float(
string='Total Sales',
compute='_compute_session_totals', store=True, digits=(10, 2),
)
# ── Payment totals (from account.payment via invoices) ────────────
total_cash = fields.Float(
string='Cash',
compute='_compute_session_totals', store=True, digits=(10, 2),
)
total_bank = fields.Float(
string='Bank / Card',
compute='_compute_session_totals', store=True, digits=(10, 2),
)
total_paid = fields.Float(
string='Total Collected',
compute='_compute_session_totals', store=True, digits=(10, 2),
)
total_credit = fields.Float(
string='Outstanding / Deferred',
compute='_compute_session_totals', store=True, digits=(10, 2),
help='Total not yet paid (total_sales total_paid).',
)
outstanding_amount = fields.Float(
string='Outstanding',
compute='_compute_session_totals', store=True, digits=(10, 2),
)
notes = fields.Text(string='Notes')
# ── Difference account ────────────────────────────────────────────
difference_account_id = fields.Many2one(
'account.account',
string='Cash Difference Account',
help='Account used to post cash variances at session close.',
)
# ── Constraints ───────────────────────────────────────────────────
_session_name_uniq = models.Constraint(
'UNIQUE(name)',
'Session name must be unique.',
)
@api.constrains('state', 'company_id')
def _check_single_open_session(self):
for session in self:
if session.state == 'opened':
duplicate = self.search([
('state', '=', 'opened'),
('company_id', '=', session.company_id.id),
('id', '!=', session.id),
], limit=1)
if duplicate:
raise UserError(
f'Session "{duplicate.name}" is already open.\n'
'Close it before opening a new session.'
)
# ── Computed ──────────────────────────────────────────────────────
@api.depends(
'order_ids.amount_total',
'order_ids.state',
'order_ids.invoice_ids.state',
'order_ids.invoice_ids.payment_state',
'order_ids.invoice_ids.amount_residual',
'order_ids.is_laundry_order',
)
def _compute_session_totals(self):
Payment = self.env['account.payment']
for session in self:
active = session.order_ids.filtered(
lambda o: o.is_laundry_order and o.state not in ('cancel', 'draft')
)
session.order_count = len(active)
session.total_sales = sum(active.mapped('amount_total'))
# Get posted customer invoices for active orders
invoices = active.mapped('invoice_ids').filtered(
lambda inv: inv.state == 'posted' and inv.move_type == 'out_invoice'
)
if not invoices:
session.total_cash = 0.0
session.total_bank = 0.0
session.total_paid = 0.0
session.outstanding_amount = session.total_sales
session.total_credit = session.total_sales
continue
# Payments reconciled against those invoices
# account.payment.reconciled_invoice_ids is a computed M2M in Odoo 16+
payments = Payment.search([
('reconciled_invoice_ids', 'in', invoices.ids),
('state', '=', 'posted'),
('payment_type', '=', 'inbound'),
])
cash_pmts = payments.filtered(lambda p: p.journal_id.type == 'cash')
bank_pmts = payments.filtered(lambda p: p.journal_id.type == 'bank')
session.total_cash = sum(cash_pmts.mapped('amount'))
session.total_bank = sum(bank_pmts.mapped('amount'))
session.total_paid = sum(payments.mapped('amount'))
outstanding = max(session.total_sales - session.total_paid, 0.0)
session.outstanding_amount = outstanding
session.total_credit = outstanding
@api.depends('opening_cash', 'total_cash', 'actual_closing_cash', 'state')
def _compute_cash_control(self):
for session in self:
session.expected_closing_cash = (
session.opening_cash + session.total_cash
)
session.cash_difference = (
session.actual_closing_cash - session.expected_closing_cash
if session.state == 'closed' else 0.0
)
# ── ORM ───────────────────────────────────────────────────────────
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = (
self.env['ir.sequence'].next_by_code('laundry.session')
or 'New'
)
return super().create(vals_list)
# ── Workflow ──────────────────────────────────────────────────────
def action_open_session(self):
for session in self:
if session.state != 'new':
raise UserError('This session is already open or closed.')
# Carry forward: suggest previous session's actual closing cash
if not session.opening_cash:
prev = self.search([
('state', '=', 'closed'),
('company_id', '=', session.company_id.id),
], order='closing_datetime desc', limit=1)
if prev and prev.actual_closing_cash:
session.opening_cash = prev.actual_closing_cash
session.write({
'state': 'opened',
'opening_datetime': fields.Datetime.now(),
})
session.message_post(
body=f'Session opened by {self.env.user.name}. '
f'Opening float: {session.opening_cash:.2f}'
)
def action_close_session(self):
self.ensure_one()
if self.state != 'opened':
raise UserError('Only open sessions can be closed.')
return {
'name': 'Close Session',
'type': 'ir.actions.act_window',
'res_model': 'laundry.session.close.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_session_id': self.id},
}
def action_view_orders(self):
self.ensure_one()
return {
'name': f'Orders — {self.name}',
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('session_id', '=', self.id), ('is_laundry_order', '=', True)],
'context': {'default_session_id': self.id, 'default_is_laundry_order': True},
}
def action_print_session_report(self):
self.ensure_one()
return self.env.ref(
'laundry_management.action_report_laundry_session'
).report_action(self)