Tower: upload laundry_management 19.0.19.0.4 (via marketplace)

This commit is contained in:
2026-05-01 15:01:06 +00:00
parent 368561c08a
commit 84e6416ca1

View File

@@ -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)