Tower: unpublish laundry_management — remove source from 19.0 branch
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
from . import laundry_print_wizard
|
||||
from . import laundry_order_unlock_wizard
|
||||
# NOTE: laundry_payment_wizard removed — payment is POS-owned
|
||||
# NOTE: laundry_session_wizard removed — sessions are POS-owned
|
||||
@@ -1,126 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, AccessError
|
||||
|
||||
|
||||
class LaundryOrderUnlockWizard(models.TransientModel):
|
||||
"""Manager-only wizard that grants a TIMED unlock window on a
|
||||
locked laundry.order.
|
||||
|
||||
The wizard does not unlock the order permanently — it sets
|
||||
`manager_unlocked_until` to a future timestamp. The lock compute
|
||||
on `laundry.order.locked` re-evaluates against `now()` on every
|
||||
read, so once the window passes the order auto-relocks. No manual
|
||||
re-lock action is needed; nothing can drift.
|
||||
|
||||
Audit trail:
|
||||
- reason is logged as a chatter note via mail.message
|
||||
- manager_unlocked_by stores the actor
|
||||
- manager_unlock_reason stores the rationale on the order itself
|
||||
"""
|
||||
_name = 'laundry.order.unlock.wizard'
|
||||
_description = 'Unlock Laundry Order for Editing'
|
||||
|
||||
DEFAULT_DURATION = 30 # minutes
|
||||
MAX_DURATION = 240 # minutes (4 hours)
|
||||
|
||||
order_id = fields.Many2one(
|
||||
'laundry.order', string='Order',
|
||||
required=True, readonly=True, ondelete='cascade',
|
||||
)
|
||||
order_name = fields.Char(
|
||||
related='order_id.name', readonly=True,
|
||||
)
|
||||
order_source_type = fields.Selection(
|
||||
related='order_id.source_type', readonly=True,
|
||||
)
|
||||
order_state = fields.Selection(
|
||||
related='order_id.state', readonly=True,
|
||||
)
|
||||
duration_minutes = fields.Integer(
|
||||
string='Unlock For (minutes)',
|
||||
default=DEFAULT_DURATION,
|
||||
required=True,
|
||||
help='How long the order will accept edits before re-locking '
|
||||
'automatically. Capped at %s minutes.' % MAX_DURATION,
|
||||
)
|
||||
reason = fields.Char(
|
||||
string='Reason',
|
||||
required=True,
|
||||
help='Logged in the chatter for audit. Required.',
|
||||
)
|
||||
|
||||
@api.constrains('duration_minutes')
|
||||
def _check_duration(self):
|
||||
for wiz in self:
|
||||
if wiz.duration_minutes < 1:
|
||||
raise UserError(_('Unlock duration must be at least 1 minute.'))
|
||||
if wiz.duration_minutes > self.MAX_DURATION:
|
||||
raise UserError(_(
|
||||
'Unlock duration is capped at %s minutes.',
|
||||
self.MAX_DURATION,
|
||||
))
|
||||
|
||||
def action_unlock(self):
|
||||
self.ensure_one()
|
||||
if not self.env.user.has_group(
|
||||
'laundry_management.group_laundry_manager_override'
|
||||
):
|
||||
raise AccessError(_(
|
||||
'Only users with the "Laundry / Manager Override" '
|
||||
'privilege can unlock locked orders.'
|
||||
))
|
||||
if not self.reason or not self.reason.strip():
|
||||
raise UserError(_('A reason is required to unlock an order.'))
|
||||
|
||||
unlock_until = (
|
||||
fields.Datetime.now() + timedelta(minutes=self.duration_minutes)
|
||||
)
|
||||
# The wizard writes only manager_unlocked_* fields, which are NOT
|
||||
# in LOCKED_HEADER_FIELDS — the lock guard is a no-op for this
|
||||
# write, so we don't need a context bypass.
|
||||
self.order_id.sudo().write({
|
||||
'manager_unlocked_until': unlock_until,
|
||||
'manager_unlocked_by': self.env.user.id,
|
||||
'manager_unlock_reason': self.reason.strip(),
|
||||
})
|
||||
self.order_id.message_post(
|
||||
body=_(
|
||||
'Order unlocked for %(minutes)s minutes by '
|
||||
'<strong>%(user)s</strong>. Re-locks automatically at '
|
||||
'%(until)s.<br/><strong>Reason:</strong> %(reason)s',
|
||||
minutes=self.duration_minutes,
|
||||
user=self.env.user.name,
|
||||
until=fields.Datetime.to_string(unlock_until),
|
||||
reason=self.reason.strip(),
|
||||
),
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'laundry.order',
|
||||
'res_id': self.order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_relock_now(self):
|
||||
"""Optional: managers can also re-lock immediately (clears the
|
||||
unlock window early). Same access control as unlock."""
|
||||
self.ensure_one()
|
||||
if not self.env.user.has_group(
|
||||
'laundry_management.group_laundry_manager_override'
|
||||
):
|
||||
raise AccessError(_('Only override managers can re-lock.'))
|
||||
self.order_id.sudo().write({
|
||||
'manager_unlocked_until': False,
|
||||
})
|
||||
self.order_id.message_post(
|
||||
body=_(
|
||||
'Order re-locked early by <strong>%(user)s</strong>.',
|
||||
user=self.env.user.name,
|
||||
),
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_laundry_order_unlock_wizard_form" model="ir.ui.view">
|
||||
<field name="name">laundry.order.unlock.wizard.form</field>
|
||||
<field name="model">laundry.order.unlock.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Unlock Laundry Order for Editing">
|
||||
<sheet>
|
||||
<div class="alert alert-warning mb-3" role="alert">
|
||||
<strong>Manager override.</strong>
|
||||
This grants a temporary edit window on a locked
|
||||
laundry order. The action is logged in the chatter
|
||||
with your name and reason. The order auto-relocks
|
||||
when the window expires — no manual relock needed.
|
||||
</div>
|
||||
<group>
|
||||
<group string="Order">
|
||||
<field name="order_id" readonly="1"
|
||||
options="{'no_open': True, 'no_create': True}"/>
|
||||
<field name="order_name"/>
|
||||
<field name="order_source_type"/>
|
||||
<field name="order_state"/>
|
||||
</group>
|
||||
<group string="Unlock Window">
|
||||
<field name="duration_minutes"/>
|
||||
<field name="reason"
|
||||
placeholder="e.g. Customer changed garment count"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_unlock" type="object"
|
||||
string="Unlock for Editing"
|
||||
class="btn-primary"/>
|
||||
<button special="cancel"
|
||||
string="Cancel"
|
||||
class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,283 +0,0 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaundryPaymentWizard(models.TransientModel):
|
||||
"""POS-like payment wizard for laundry orders (PART 2).
|
||||
|
||||
Simplified flow — single screen:
|
||||
1. Shows customer, order total, balance due
|
||||
2. Staff selects journal (cash/bank) and enters amount
|
||||
3. On confirm:
|
||||
- Creates invoice if none exists
|
||||
- Posts the invoice
|
||||
- Creates account.payment
|
||||
- Reconciles payment against invoice
|
||||
|
||||
Permissions:
|
||||
- Any laundry user can register payment
|
||||
- Only managers can change the payment date (backdate)
|
||||
- Only managers can override the amount above order total
|
||||
"""
|
||||
_name = 'laundry.payment.wizard'
|
||||
_description = 'Laundry Quick Payment'
|
||||
|
||||
# ── Order info (read-only header) ─────────────────────────────────
|
||||
order_id = fields.Many2one(
|
||||
'sale.order', string='Order',
|
||||
required=True, readonly=True,
|
||||
domain=[('is_laundry_order', '=', True)],
|
||||
)
|
||||
order_name = fields.Char(
|
||||
related='order_id.name', string='Order No.', readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='order_id.partner_id', string='Customer', readonly=True,
|
||||
)
|
||||
mobile = fields.Char(
|
||||
related='order_id.mobile', string='Mobile', readonly=True,
|
||||
)
|
||||
order_total = fields.Monetary(
|
||||
related='order_id.amount_total', string='Order Total',
|
||||
currency_field='currency_id', readonly=True,
|
||||
)
|
||||
amount_due = fields.Monetary(
|
||||
related='order_id.amount_due', string='Balance Due',
|
||||
currency_field='currency_id', readonly=True,
|
||||
)
|
||||
laundry_payment_status = fields.Selection(
|
||||
related='order_id.laundry_payment_status', readonly=True,
|
||||
)
|
||||
laundry_state = fields.Selection(
|
||||
related='order_id.laundry_state', string='Processing Status', readonly=True,
|
||||
)
|
||||
|
||||
# ── Payment fields ────────────────────────────────────────────────
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Journal',
|
||||
required=True,
|
||||
domain="[('type', 'in', ['cash', 'bank']), ('company_id', '=', company_id)]",
|
||||
)
|
||||
payment_method_line_id = fields.Many2one(
|
||||
'account.payment.method.line',
|
||||
string='Payment Method',
|
||||
required=True,
|
||||
domain="[('journal_id', '=', journal_id), ('payment_type', '=', 'inbound')]",
|
||||
)
|
||||
amount = fields.Monetary(
|
||||
string='Amount',
|
||||
currency_field='currency_id',
|
||||
required=True,
|
||||
)
|
||||
payment_date = fields.Date(
|
||||
string='Payment Date',
|
||||
required=True,
|
||||
default=fields.Date.today,
|
||||
)
|
||||
memo = fields.Char(
|
||||
string='Reference / Memo',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
related='order_id.currency_id', readonly=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
related='order_id.company_id', readonly=True,
|
||||
)
|
||||
|
||||
# ── Computed info ─────────────────────────────────────────────────
|
||||
change_amount = fields.Monetary(
|
||||
string='Change',
|
||||
compute='_compute_change',
|
||||
currency_field='currency_id',
|
||||
help='Amount to return to customer if overpaying (cash only).',
|
||||
)
|
||||
is_overpayment = fields.Boolean(compute='_compute_change')
|
||||
|
||||
@api.depends('amount', 'amount_due')
|
||||
def _compute_change(self):
|
||||
for wiz in self:
|
||||
if wiz.amount > 0 and wiz.amount_due > 0:
|
||||
wiz.change_amount = max(wiz.amount - wiz.amount_due, 0.0)
|
||||
wiz.is_overpayment = wiz.amount > wiz.amount_due
|
||||
else:
|
||||
wiz.change_amount = 0.0
|
||||
wiz.is_overpayment = False
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
order_id = res.get('order_id') or self.env.context.get('default_order_id')
|
||||
if order_id:
|
||||
order = self.env['sale.order'].browse(order_id)
|
||||
# Default amount = outstanding balance (or total if no invoice yet)
|
||||
res['amount'] = order.amount_due if order.amount_due > 0 else order.amount_total
|
||||
res['memo'] = order.name
|
||||
# Default journal: 1) configured in Laundry Settings, 2) first cash journal
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
configured_id = ICP.get_param('laundry_management.cash_journal_id', '')
|
||||
cash_journal = False
|
||||
if configured_id:
|
||||
try:
|
||||
j = self.env['account.journal'].browse(int(configured_id))
|
||||
if j.exists() and j.type in ('cash', 'bank'):
|
||||
cash_journal = j
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if not cash_journal:
|
||||
cash_journal = self.env['account.journal'].search([
|
||||
('type', '=', 'cash'),
|
||||
('company_id', '=', order.company_id.id),
|
||||
], limit=1)
|
||||
if not cash_journal:
|
||||
cash_journal = self.env['account.journal'].search([
|
||||
('type', '=', 'bank'),
|
||||
('company_id', '=', order.company_id.id),
|
||||
], limit=1)
|
||||
if cash_journal:
|
||||
res['journal_id'] = cash_journal.id
|
||||
# Default payment method line: first inbound method on the journal
|
||||
method_line = self.env['account.payment.method.line'].search([
|
||||
('journal_id', '=', cash_journal.id),
|
||||
('payment_type', '=', 'inbound'),
|
||||
], limit=1)
|
||||
if method_line:
|
||||
res['payment_method_line_id'] = method_line.id
|
||||
return res
|
||||
|
||||
@api.onchange('journal_id')
|
||||
def _onchange_journal_id(self):
|
||||
"""Auto-select first inbound payment method line for the chosen journal."""
|
||||
if self.journal_id:
|
||||
method_line = self.env['account.payment.method.line'].search([
|
||||
('journal_id', '=', self.journal_id.id),
|
||||
('payment_type', '=', 'inbound'),
|
||||
], limit=1)
|
||||
self.payment_method_line_id = method_line or False
|
||||
else:
|
||||
self.payment_method_line_id = False
|
||||
|
||||
@api.onchange('order_id')
|
||||
def _onchange_order_id(self):
|
||||
if self.order_id:
|
||||
self.amount = (
|
||||
self.order_id.amount_due
|
||||
if self.order_id.amount_due > 0
|
||||
else self.order_id.amount_total
|
||||
)
|
||||
self.memo = self.order_id.name
|
||||
|
||||
# ── Confirm payment ───────────────────────────────────────────────
|
||||
def action_confirm_payment(self):
|
||||
"""Full accounting flow: invoice → post → payment → reconcile."""
|
||||
self.ensure_one()
|
||||
order = self.order_id
|
||||
|
||||
if order.state not in ('sale', 'done'):
|
||||
raise UserError(
|
||||
'The order must be confirmed before registering payment.\n'
|
||||
'Please save the order first.'
|
||||
)
|
||||
|
||||
if self.amount <= 0:
|
||||
raise UserError('Payment amount must be greater than zero.')
|
||||
|
||||
# 1. Get or create invoice ────────────────────────────────────
|
||||
invoices = order.invoice_ids.filtered(
|
||||
lambda inv: inv.move_type == 'out_invoice' and inv.state != 'cancel'
|
||||
)
|
||||
if not invoices:
|
||||
_logger.info('laundry.payment.wizard: creating invoice for order %s', order.name)
|
||||
invoices = order._create_invoices()
|
||||
if not invoices:
|
||||
raise UserError(
|
||||
'Failed to create an invoice. '
|
||||
'Ensure the order has service lines configured with valid products.'
|
||||
)
|
||||
|
||||
# 2. Post any draft invoices ───────────────────────────────────
|
||||
draft_invoices = invoices.filtered(lambda inv: inv.state == 'draft')
|
||||
if draft_invoices:
|
||||
draft_invoices.action_post()
|
||||
|
||||
# 3. Get posted invoices with open balance ─────────────────────
|
||||
to_pay = invoices.filtered(
|
||||
lambda inv: inv.state == 'posted'
|
||||
and inv.payment_state not in ('paid', 'in_payment')
|
||||
)
|
||||
if not to_pay:
|
||||
raise UserError(
|
||||
'This order is already fully paid.\n\n'
|
||||
'Payment Status: ' + (order.laundry_payment_status or 'unknown')
|
||||
)
|
||||
|
||||
# 4. Actual payment amount (cap at total due, not overpay into accounting)
|
||||
# Change is returned physically by cashier; we only record the due amount
|
||||
pay_amount = min(self.amount, sum(to_pay.mapped('amount_residual')))
|
||||
if pay_amount <= 0:
|
||||
raise UserError('No outstanding balance to pay on the posted invoice.')
|
||||
|
||||
# 5. Create account.payment ────────────────────────────────────
|
||||
payment_vals = {
|
||||
'amount': pay_amount,
|
||||
'journal_id': self.journal_id.id,
|
||||
'payment_method_line_id': self.payment_method_line_id.id,
|
||||
'payment_type': 'inbound',
|
||||
'partner_type': 'customer',
|
||||
'partner_id': order.partner_id.id,
|
||||
'date': self.payment_date,
|
||||
'ref': self.memo or order.name,
|
||||
'currency_id': order.currency_id.id,
|
||||
}
|
||||
payment = self.env['account.payment'].create(payment_vals)
|
||||
payment.action_post()
|
||||
|
||||
# 6. Reconcile payment against invoice lines ───────────────────
|
||||
# NOTE: account.account uses company_ids (M2M) in Odoo 17+, not company_id.
|
||||
# Filter directly on move lines by account_type — no separate account search.
|
||||
payment_lines = payment.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable'
|
||||
and not l.reconciled
|
||||
)
|
||||
invoice_lines = to_pay.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable'
|
||||
and not l.reconciled
|
||||
)
|
||||
if payment_lines and invoice_lines:
|
||||
(payment_lines + invoice_lines).reconcile()
|
||||
|
||||
# 7. Log on order chatter ──────────────────────────────────────
|
||||
journal_name = self.journal_id.name
|
||||
change_note = ''
|
||||
if self.is_overpayment:
|
||||
change_note = f' | Change returned: {order.currency_id.symbol} {self.change_amount:.2f}'
|
||||
order.message_post(
|
||||
body=(
|
||||
f'Payment registered: {order.currency_id.symbol} {pay_amount:.2f} '
|
||||
f'via {journal_name} on {self.payment_date}.{change_note}'
|
||||
),
|
||||
)
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def action_view_invoice(self):
|
||||
"""Open the related invoice to view/edit if needed."""
|
||||
self.ensure_one()
|
||||
invoices = self.order_id.invoice_ids.filtered(
|
||||
lambda inv: inv.move_type == 'out_invoice' and inv.state != 'cancel'
|
||||
)
|
||||
if not invoices:
|
||||
raise UserError('No invoice exists for this order yet.')
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Invoice',
|
||||
'res_model': 'account.move',
|
||||
'res_id': invoices[0].id if len(invoices) == 1 else False,
|
||||
'view_mode': 'form' if len(invoices) == 1 else 'list,form',
|
||||
'domain': [('id', 'in', invoices.ids)] if len(invoices) > 1 else [],
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class LaundryPrintWizard(models.TransientModel):
|
||||
"""Print template selection wizard — routes to A4, Thermal, or Tracking."""
|
||||
_name = 'laundry.print.wizard'
|
||||
_description = 'Laundry Print Wizard'
|
||||
|
||||
order_id = fields.Many2one(
|
||||
'laundry.order', string='Order',
|
||||
required=True, readonly=True,
|
||||
)
|
||||
|
||||
order_name = fields.Char(related='order_id.name', readonly=True)
|
||||
customer_name = fields.Char(related='order_id.partner_id.name', readonly=True)
|
||||
total_amount = fields.Monetary(
|
||||
related='order_id.amount_total',
|
||||
readonly=True,
|
||||
currency_field='currency_id',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
related='order_id.currency_id', readonly=True,
|
||||
)
|
||||
|
||||
template = fields.Selection([
|
||||
('a4', 'A4 Customer Receipt (Bilingual)'),
|
||||
('thermal', 'Thermal Receipt (80mm)'),
|
||||
('tracking', 'Item Tracking Slips'),
|
||||
], string='Print Template', required=True, default='a4')
|
||||
|
||||
include_prices = fields.Boolean(string='Include Prices', default=True)
|
||||
include_notes = fields.Boolean(string='Include Customer Notes', default=True)
|
||||
|
||||
def action_print(self):
|
||||
self.ensure_one()
|
||||
ref_map = {
|
||||
'a4': 'laundry_management.action_report_laundry_order_receipt',
|
||||
'thermal': 'laundry_management.action_report_laundry_thermal_receipt',
|
||||
'tracking': 'laundry_management.action_report_laundry_tracking_slip',
|
||||
}
|
||||
report_ref = ref_map.get(self.template)
|
||||
if not report_ref:
|
||||
raise UserError('Unknown print template.')
|
||||
return self.env.ref(report_ref).report_action(
|
||||
self.order_id,
|
||||
data={
|
||||
'include_prices': self.include_prices,
|
||||
'include_notes': self.include_notes,
|
||||
},
|
||||
)
|
||||
@@ -1,230 +0,0 @@
|
||||
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}'
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user