260 lines
11 KiB
Python
260 lines
11 KiB
Python
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)
|