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)