diff --git a/addons/laundry_management/models/laundry_session.py b/addons/laundry_management/models/laundry_session.py new file mode 100644 index 0000000..5d9ccc5 --- /dev/null +++ b/addons/laundry_management/models/laundry_session.py @@ -0,0 +1,259 @@ +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)