From 52b130631c750035dafd8f331a3090fd67c890c2 Mon Sep 17 00:00:00 2001 From: git_admin Date: Fri, 1 May 2026 15:00:57 +0000 Subject: [PATCH] Tower: upload laundry_management 19.0.19.0.4 (via marketplace) --- .../wizard/laundry_session_wizard.py | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 addons/laundry_management/wizard/laundry_session_wizard.py diff --git a/addons/laundry_management/wizard/laundry_session_wizard.py b/addons/laundry_management/wizard/laundry_session_wizard.py new file mode 100644 index 0000000..9f68d56 --- /dev/null +++ b/addons/laundry_management/wizard/laundry_session_wizard.py @@ -0,0 +1,230 @@ +from odoo import models, fields, api +from odoo.exceptions import UserError + + +class LaundrySessionCloseWizard(models.TransientModel): + """Session closing wizard. + + Summarises the session's accounting figures (from account.payment / invoices) + and asks the operator to count and enter the actual physical cash. + + On confirm: + 1. If cash_difference != 0 and post_difference_entry is True: + → Posts a journal entry: Cash ↔ Difference Account + 2. Marks the session closed, records actual cash count. + """ + _name = 'laundry.session.close.wizard' + _description = 'Close Laundry Session' + + session_id = fields.Many2one( + 'laundry.session', string='Session', + required=True, readonly=True, + ) + + # ── Session summary (read-only, from session computed fields) ───── + opening_cash = fields.Float( + related='session_id.opening_cash', + string='Opening Float', readonly=True, digits=(10, 2), + ) + total_sales = fields.Float( + related='session_id.total_sales', + string='Total Sales', readonly=True, digits=(10, 2), + ) + total_cash = fields.Float( + related='session_id.total_cash', + string='Cash Received', readonly=True, digits=(10, 2), + ) + total_bank = fields.Float( + related='session_id.total_bank', + string='Bank / Card', readonly=True, digits=(10, 2), + ) + total_credit = fields.Float( + related='session_id.total_credit', + string='Outstanding / Deferred', readonly=True, digits=(10, 2), + ) + total_paid = fields.Float( + related='session_id.total_paid', + string='Total Collected', readonly=True, digits=(10, 2), + ) + outstanding_amount = fields.Float( + related='session_id.outstanding_amount', + string='Outstanding', readonly=True, digits=(10, 2), + ) + expected_closing_cash = fields.Float( + related='session_id.expected_closing_cash', + string='Expected Cash in Drawer', readonly=True, digits=(10, 2), + ) + order_count = fields.Integer( + related='session_id.order_count', + string='Orders Processed', readonly=True, + ) + + # ── Operator input ──────────────────────────────────────────────── + actual_closing_cash = fields.Float( + string='Actual Cash Count', + digits=(10, 2), + help='Count the physical cash in the drawer and enter the total here.', + ) + cash_difference = fields.Float( + string='Difference (Actual − Expected)', + compute='_compute_difference', + digits=(10, 2), + ) + post_difference_entry = fields.Boolean( + string='Post Difference Journal Entry', + default=True, + ) + difference_account_id = fields.Many2one( + 'account.account', + string='Difference Account', + help='Account to post cash count variance against.', + ) + closing_notes = fields.Text(string='Closing Notes') + + # ── Computed ────────────────────────────────────────────────────── + @api.depends('actual_closing_cash', 'expected_closing_cash') + def _compute_difference(self): + for wiz in self: + wiz.cash_difference = ( + wiz.actual_closing_cash - wiz.expected_closing_cash + ) + + @api.onchange('post_difference_entry') + def _onchange_post_difference(self): + if self.post_difference_entry and not self.difference_account_id: + ICP = self.env['ir.config_parameter'].sudo() + # 1. Try configured difference account from Laundry Settings + configured_id = ICP.get_param('laundry_management.difference_account_id', '') + if configured_id: + try: + acc = self.env['account.account'].browse(int(configured_id)) + if acc.exists(): + self.difference_account_id = acc + return + except (ValueError, TypeError): + pass + # 2. Fallback: first cash-difference account on the company + company = ( + self.session_id.company_id if self.session_id else self.env.company + ) + acc = ( + company.default_cash_difference_expense_account_id + or company.default_cash_difference_income_account_id + ) + if acc: + self.difference_account_id = acc + + # ── Confirm ─────────────────────────────────────────────────────── + def action_close(self): + self.ensure_one() + session = self.session_id + if session.state != 'opened': + raise UserError('Session is not open — cannot close.') + + diff = self.actual_closing_cash - self.expected_closing_cash + if self.post_difference_entry and abs(diff) > 0.001: + if not self.difference_account_id: + raise UserError( + 'Select a Difference Account before closing, ' + 'or uncheck "Post Difference Journal Entry".' + ) + self._post_cash_difference_entry(session, diff) + + session.write({ + 'state': 'closed', + 'closing_datetime': fields.Datetime.now(), + 'actual_closing_cash': self.actual_closing_cash, + 'notes': self.closing_notes or session.notes or '', + }) + session.message_post( + body=( + f'Session closed by {self.env.user.name}. ' + f'Orders: {self.order_count} | ' + f'Sales: {self.total_sales:.2f} | ' + f'Cash: {self.total_cash:.2f} | ' + f'Bank: {self.total_bank:.2f} | ' + f'Actual: {self.actual_closing_cash:.2f} | ' + f'Diff: {diff:+.2f}' + ), + ) + return {'type': 'ir.actions.act_window_close'} + + def _post_cash_difference_entry(self, session, difference): + """Post a journal entry for the cash count variance. + + Surplus (difference > 0): Dr Cash / Cr Variance Account + Shortage (difference < 0): Dr Variance Account / Cr Cash + + Journal priority: + 1. Laundry default cash journal from Settings + 2. First cash journal for the company + """ + company = session.company_id or self.env.company + ICP = self.env['ir.config_parameter'].sudo() + + # 1. Try configured default from Laundry Settings + configured_journal_id = ICP.get_param('laundry_management.cash_journal_id', '') + cash_journal = False + if configured_journal_id: + try: + cash_journal = self.env['account.journal'].browse(int(configured_journal_id)) + if not cash_journal.exists() or cash_journal.type != 'cash': + cash_journal = False + except (ValueError, TypeError): + cash_journal = False + + # 2. Fallback: first cash journal for the company + if not cash_journal: + cash_journal = self.env['account.journal'].search([ + ('type', '=', 'cash'), + ('company_id', '=', company.id), + ], limit=1) + + if not cash_journal: + raise UserError( + 'No Cash journal found. ' + 'Configure one in Laundry → Configuration → Settings → Default Cash Journal, ' + 'or in Accounting → Journals.' + ) + cash_account = cash_journal.default_account_id + if not cash_account: + raise UserError( + f'Cash journal "{cash_journal.name}" has no default account.' + ) + + amount = abs(difference) + if difference > 0: + debit_account, credit_account = cash_account, self.difference_account_id + else: + debit_account, credit_account = self.difference_account_id, cash_account + + move = self.env['account.move'].create({ + 'move_type' : 'entry', + 'journal_id': cash_journal.id, + 'date' : fields.Date.today(), + 'ref' : f'Cash variance — {session.name}', + 'company_id': company.id, + 'line_ids': [ + (0, 0, { + 'account_id': debit_account.id, + 'name' : f'Cash difference — {session.name}', + 'debit' : amount, + 'credit' : 0.0, + }), + (0, 0, { + 'account_id': credit_account.id, + 'name' : f'Cash difference — {session.name}', + 'debit' : 0.0, + 'credit' : amount, + }), + ], + }) + move.action_post() + session.message_post( + body=( + f'Cash variance entry posted: ' + f'{company.currency_id.symbol}{amount:.2f} ' + f'({"surplus" if difference > 0 else "shortage"}). ' + f'Entry: {move.name}' + ), + )