Tower: upload laundry_management 19.0.19.0.4 (via marketplace)
This commit is contained in:
259
addons/laundry_management/models/laundry_session.py
Normal file
259
addons/laundry_management/models/laundry_session.py
Normal 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)
|
||||
Reference in New Issue
Block a user