Tower: upload laundry_management 19.0.19.0.4 (was 19.0.19.0.4, via marketplace)
This commit is contained in:
16
addons/laundry_management/models/__init__.py
Normal file
16
addons/laundry_management/models/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from . import product_template_ext # adds is_laundry_service to product.template
|
||||
from . import laundry_order_type # standalone: laundry.order.type
|
||||
from . import laundry_order_attribute # standalone: laundry.order.attribute
|
||||
from . import laundry_order # standalone: laundry.order
|
||||
from . import laundry_order_line # standalone: laundry.order.line
|
||||
from . import pos_order # extends pos.order with sync_from_ui hook
|
||||
from . import pos_config_ext # extends pos.config with laundry-pos settings
|
||||
from . import res_partner # extends res.partner with laundry unpaid count
|
||||
from . import laundry_order_line_addon # add-on services per order line
|
||||
from . import laundry_commission # standalone: commission tracking
|
||||
# NOTE: laundry_dashboard removed — depends on laundry.session (POS-owned)
|
||||
from . import laundry_payment_method # standalone: configurable payment methods
|
||||
from . import laundry_settings # extends res.config.settings
|
||||
from . import account_payment_ext # stamps pos_session_id on settlement payments
|
||||
from . import pos_session_ext # ships new POS models to the client
|
||||
# NOTE: laundry_session and account_move removed — session/accounting is POS-owned
|
||||
21
addons/laundry_management/models/account_move.py
Normal file
21
addons/laundry_management/models/account_move.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class AccountMoveLaundryExt(models.Model):
|
||||
"""Flag invoices originating from laundry orders."""
|
||||
_inherit = 'account.move'
|
||||
|
||||
is_laundry_invoice = fields.Boolean(
|
||||
string='Laundry Invoice',
|
||||
compute='_compute_is_laundry_invoice',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('invoice_line_ids.sale_line_ids.order_id.is_laundry_order')
|
||||
def _compute_is_laundry_invoice(self):
|
||||
for move in self:
|
||||
move.is_laundry_invoice = any(
|
||||
sol.order_id.is_laundry_order
|
||||
for line in move.invoice_line_ids
|
||||
for sol in line.sale_line_ids
|
||||
)
|
||||
22
addons/laundry_management/models/account_payment_ext.py
Normal file
22
addons/laundry_management/models/account_payment_ext.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class AccountPaymentLaundryExt(models.Model):
|
||||
"""Informational stamp — links settlement payments to the POS session
|
||||
that was open when the cashier collected the money.
|
||||
|
||||
This field is purely for visibility (closing-screen summary). It does
|
||||
NOT inject settlement totals into POS cash-control math.
|
||||
"""
|
||||
_inherit = 'account.payment'
|
||||
|
||||
pos_session_id = fields.Many2one(
|
||||
'pos.session', string='POS Session',
|
||||
readonly=True, copy=False, index=True,
|
||||
help='POS session that was open when this settlement was created.',
|
||||
)
|
||||
settlement_pos_pm_id = fields.Many2one(
|
||||
'pos.payment.method', string='Settlement Payment Method',
|
||||
readonly=True, copy=False, index=True,
|
||||
help='Original POS payment method chosen by the cashier during settlement.',
|
||||
)
|
||||
125
addons/laundry_management/models/laundry_commission.py
Normal file
125
addons/laundry_management/models/laundry_commission.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class LaundryCommission(models.Model):
|
||||
"""Staff commission tracking (PART 3).
|
||||
|
||||
States:
|
||||
pending — auto-created when order progresses; awaiting manager review
|
||||
confirmed — manager has verified and approved the commission
|
||||
paid — commission has been settled/paid to the staff member
|
||||
|
||||
The commission_account_id (from settings) is informational for now.
|
||||
Managers can bulk-confirm and bulk-mark-paid from the list view.
|
||||
"""
|
||||
_name = 'laundry.commission'
|
||||
_description = 'Laundry Staff Commission'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
compute='_compute_name', store=True, readonly=True,
|
||||
)
|
||||
order_id = fields.Many2one(
|
||||
'sale.order', string='Order',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
related='order_id.company_id', store=True, index=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'res.users', string='Staff Member',
|
||||
required=True, domain=[('share', '=', False)],
|
||||
tracking=True,
|
||||
)
|
||||
role = fields.Selection([
|
||||
('reception', 'Reception / Intake'),
|
||||
('processing', 'Processing / Cleaning'),
|
||||
('delivery', 'Delivery / Handover'),
|
||||
], string='Role', required=True, tracking=True)
|
||||
|
||||
commission_type = fields.Selection([
|
||||
('percentage', 'Percentage (%)'),
|
||||
('fixed', 'Fixed Amount'),
|
||||
], string='Type', required=True, default='percentage')
|
||||
|
||||
rate = fields.Float(string='Rate / Amount', digits=(10, 2))
|
||||
base_amount = fields.Float(string='Order Total', digits=(10, 2))
|
||||
commission_amount = fields.Float(
|
||||
string='Commission',
|
||||
compute='_compute_commission_amount', store=True, digits=(10, 2),
|
||||
)
|
||||
|
||||
date = fields.Date(string='Date', required=True, default=fields.Date.today)
|
||||
|
||||
state = fields.Selection([
|
||||
('pending', 'Pending'),
|
||||
('confirmed', 'Confirmed'),
|
||||
('paid', 'Paid'),
|
||||
], default='pending', required=True, tracking=True, copy=False, index=True)
|
||||
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
_ROLE_LABELS = {
|
||||
'reception': 'Reception',
|
||||
'processing': 'Processing',
|
||||
'delivery': 'Delivery',
|
||||
}
|
||||
|
||||
@api.depends('order_id', 'order_id.name', 'role')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
order = rec.order_id.name or 'NEW'
|
||||
role = self._ROLE_LABELS.get(rec.role, rec.role or '')
|
||||
rec.name = f'COM/{order}/{role}'
|
||||
|
||||
@api.depends('commission_type', 'rate', 'base_amount')
|
||||
def _compute_commission_amount(self):
|
||||
for rec in self:
|
||||
if rec.commission_type == 'percentage':
|
||||
rec.commission_amount = rec.base_amount * rec.rate / 100.0
|
||||
else:
|
||||
rec.commission_amount = rec.rate
|
||||
|
||||
# ── State transitions ─────────────────────────────────────────────
|
||||
def action_confirm(self):
|
||||
"""Manager confirms commission is valid and approved."""
|
||||
for rec in self:
|
||||
if rec.state != 'pending':
|
||||
raise UserError(
|
||||
f'"{rec.name}" cannot be confirmed — current state: {rec.state}.'
|
||||
)
|
||||
rec.write({'state': 'confirmed'})
|
||||
rec.message_post(
|
||||
body=f'Commission confirmed by {self.env.user.name}. '
|
||||
f'Amount: {rec.commission_amount:.2f}'
|
||||
)
|
||||
|
||||
def action_mark_paid(self):
|
||||
"""Mark commission as settled/paid to staff."""
|
||||
for rec in self:
|
||||
if rec.state == 'paid':
|
||||
raise UserError(f'"{rec.name}" is already paid.')
|
||||
if rec.state == 'pending':
|
||||
# Allow paying directly from pending (manager shortcut)
|
||||
rec.write({'state': 'paid'})
|
||||
else:
|
||||
rec.write({'state': 'paid'})
|
||||
rec.message_post(
|
||||
body=f'Marked as paid by {self.env.user.name}.'
|
||||
)
|
||||
|
||||
def action_reset_pending(self):
|
||||
"""Reset commission back to pending (manager only)."""
|
||||
for rec in self:
|
||||
if rec.state == 'paid':
|
||||
raise UserError(
|
||||
f'Cannot reset "{rec.name}" — it has already been paid.'
|
||||
)
|
||||
rec.write({'state': 'pending'})
|
||||
rec.message_post(
|
||||
body=f'Reset to pending by {self.env.user.name}.'
|
||||
)
|
||||
180
addons/laundry_management/models/laundry_dashboard.py
Normal file
180
addons/laundry_management/models/laundry_dashboard.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class LaundryDashboard(models.TransientModel):
|
||||
"""Live KPI dashboard — queries sale.order with is_laundry_order = True."""
|
||||
_name = 'laundry.dashboard'
|
||||
_description = 'Laundry Dashboard'
|
||||
|
||||
today_orders = fields.Integer(string="Today's Orders")
|
||||
today_revenue = fields.Monetary(string="Today's Revenue", currency_field='currency_id')
|
||||
today_collected = fields.Monetary(string='Collected Today', currency_field='currency_id')
|
||||
today_outstanding = fields.Monetary(string='Outstanding Today', currency_field='currency_id')
|
||||
|
||||
pending_count = fields.Integer(string='Pending Orders')
|
||||
ready_count = fields.Integer(string='Ready for Pickup')
|
||||
in_progress_count = fields.Integer(string='In Processing')
|
||||
draft_count = fields.Integer(string='Quotes / Draft')
|
||||
|
||||
session_is_open = fields.Boolean(string='Session Open')
|
||||
session_name = fields.Char(string='Session')
|
||||
session_opening_cash = fields.Monetary(string='Opening Float', currency_field='currency_id')
|
||||
session_sales = fields.Monetary(string='Session Sales', currency_field='currency_id')
|
||||
session_cash = fields.Monetary(string='Session Cash', currency_field='currency_id')
|
||||
session_bank = fields.Monetary(string='Session Bank', currency_field='currency_id')
|
||||
session_id = fields.Many2one('laundry.session', string='Session Link')
|
||||
|
||||
month_orders = fields.Integer(string='Orders This Month')
|
||||
month_revenue = fields.Monetary(string='Revenue This Month', currency_field='currency_id')
|
||||
month_paid = fields.Monetary(string='Collected This Month', currency_field='currency_id')
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _build(self):
|
||||
today = fields.Date.today()
|
||||
month_start = today.replace(day=1)
|
||||
company = self.env.company
|
||||
Order = self.env['sale.order']
|
||||
Payment = self.env['account.payment']
|
||||
|
||||
_base_domain = [
|
||||
('is_laundry_order', '=', True),
|
||||
('company_id', '=', company.id),
|
||||
]
|
||||
|
||||
# ── Today ──────────────────────────────────────────────────────
|
||||
today_orders = Order.search(_base_domain + [
|
||||
('date_order', '>=', fields.Datetime.to_datetime(today)),
|
||||
('state', 'not in', ['cancel', 'draft']),
|
||||
])
|
||||
today_invoices = today_orders.mapped('invoice_ids').filtered(
|
||||
lambda i: i.state == 'posted' and i.move_type == 'out_invoice'
|
||||
)
|
||||
today_revenue = sum(today_orders.mapped('amount_total'))
|
||||
today_outstanding = sum(
|
||||
max(i.amount_residual, 0.0) for i in today_invoices
|
||||
)
|
||||
today_collected = today_revenue - today_outstanding
|
||||
|
||||
# ── Pipeline (all active laundry orders) ──────────────────────
|
||||
pipeline = Order.search(_base_domain + [
|
||||
('state', '=', 'sale'),
|
||||
])
|
||||
pending_count = len(pipeline)
|
||||
ready_count = len(pipeline.filtered(lambda o: o.laundry_state == 'ready'))
|
||||
in_progress_count = len(pipeline.filtered(lambda o: o.laundry_state == 'processing'))
|
||||
draft_count = len(Order.search(_base_domain + [('state', '=', 'draft')]))
|
||||
|
||||
# ── Session ────────────────────────────────────────────────────
|
||||
session = self.env['laundry.session'].search([
|
||||
('state', '=', 'opened'),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
|
||||
# ── Month ──────────────────────────────────────────────────────
|
||||
month_orders = Order.search(_base_domain + [
|
||||
('date_order', '>=', fields.Datetime.to_datetime(month_start)),
|
||||
('state', 'not in', ['cancel', 'draft']),
|
||||
])
|
||||
month_invoices = month_orders.mapped('invoice_ids').filtered(
|
||||
lambda i: i.state == 'posted' and i.move_type == 'out_invoice'
|
||||
)
|
||||
month_revenue = sum(month_orders.mapped('amount_total'))
|
||||
month_outstanding = sum(max(i.amount_residual, 0.0) for i in month_invoices)
|
||||
month_paid = month_revenue - month_outstanding
|
||||
|
||||
return self.create({
|
||||
'today_orders' : len(today_orders),
|
||||
'today_revenue' : today_revenue,
|
||||
'today_collected' : max(today_collected, 0.0),
|
||||
'today_outstanding' : today_outstanding,
|
||||
'pending_count' : pending_count,
|
||||
'ready_count' : ready_count,
|
||||
'in_progress_count' : in_progress_count,
|
||||
'draft_count' : draft_count,
|
||||
'session_is_open' : bool(session),
|
||||
'session_name' : session.name if session else '',
|
||||
'session_opening_cash' : session.opening_cash if session else 0.0,
|
||||
'session_sales' : session.total_sales if session else 0.0,
|
||||
'session_cash' : session.total_cash if session else 0.0,
|
||||
'session_bank' : session.total_bank if session else 0.0,
|
||||
'session_id' : session.id if session else False,
|
||||
'month_orders' : len(month_orders),
|
||||
'month_revenue' : month_revenue,
|
||||
'month_paid' : max(month_paid, 0.0),
|
||||
})
|
||||
|
||||
@api.model
|
||||
def action_open_dashboard(self):
|
||||
rec = self._build()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Dashboard',
|
||||
'res_model': 'laundry.dashboard',
|
||||
'res_id': rec.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'main',
|
||||
'flags': {'mode': 'readonly'},
|
||||
}
|
||||
|
||||
def action_refresh(self):
|
||||
return self.action_open_dashboard()
|
||||
|
||||
def action_new_order(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'New Laundry Order',
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'context': {
|
||||
'default_is_laundry_order': True,
|
||||
},
|
||||
}
|
||||
|
||||
def action_open_session(self):
|
||||
if self.session_id:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Session',
|
||||
'res_model': 'laundry.session',
|
||||
'res_id': self.session_id.id,
|
||||
'view_mode': 'form',
|
||||
}
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Sessions',
|
||||
'res_model': 'laundry.session',
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_new_session(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'New Session',
|
||||
'res_model': 'laundry.session',
|
||||
'view_mode': 'form',
|
||||
}
|
||||
|
||||
def action_view_ready_orders(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Ready for Pickup',
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('is_laundry_order', '=', True), ('laundry_state', '=', 'ready')],
|
||||
}
|
||||
|
||||
def action_view_pending_orders(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Pending Orders',
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('is_laundry_order', '=', True), ('state', '=', 'sale'),
|
||||
('laundry_state', 'not in', ['delivered'])],
|
||||
}
|
||||
765
addons/laundry_management/models/laundry_order.py
Normal file
765
addons/laundry_management/models/laundry_order.py
Normal file
@@ -0,0 +1,765 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
STATES = [
|
||||
('intake', 'Intake'),
|
||||
('processing', 'Processing'),
|
||||
('ready', 'Ready for Pickup'),
|
||||
('delivered', 'Delivered'),
|
||||
('cancelled', 'Cancelled'),
|
||||
]
|
||||
STATE_KEYS = [s[0] for s in STATES]
|
||||
FINAL_STATES = {'delivered', 'cancelled'}
|
||||
|
||||
SOURCE_TYPES = [
|
||||
('pos', 'Point of Sale'),
|
||||
('manual', 'Manual / Backoffice'),
|
||||
]
|
||||
|
||||
# Header fields the lock protects. NOT included on purpose:
|
||||
# - state (workflow advance is always allowed via dedicated actions)
|
||||
# - amount_settled (settlement engine writes after lock)
|
||||
# - notes (managerial commentary always allowed)
|
||||
# - manager_unlocked_* (the unlock wizard writes these)
|
||||
# - tracking_enabled (Phase-4 prep, manager configuration)
|
||||
LOCKED_HEADER_FIELDS = frozenset({
|
||||
'partner_id',
|
||||
'amount_total', 'amount_paid_cash', 'amount_deferred',
|
||||
'order_type_id', 'attribute_ids',
|
||||
'is_delivery', 'delivery_address', 'delivery_scheduled_at',
|
||||
'priority_level',
|
||||
'pos_order_id', 'pos_reference',
|
||||
'source_type', 'name', 'company_id',
|
||||
})
|
||||
|
||||
# Sentinel context flag set by the POS sync hook (and any other automated
|
||||
# server-side path) to allow the create + write that bring a fresh
|
||||
# laundry.order to life. Without this flag, locked orders refuse mutations.
|
||||
POS_SYNC_CTX = 'laundry_pos_sync'
|
||||
|
||||
|
||||
class LaundryOrder(models.Model):
|
||||
"""Standalone laundry order — created from POS.
|
||||
|
||||
POS owns payments/sessions/accounting. This model handles operational
|
||||
workflow only: intake -> processing -> ready -> delivered.
|
||||
|
||||
Financial model (Phase 1 fix):
|
||||
amount_total = mirror of pos.order.amount_total
|
||||
amount_paid_cash = real money collected at origin (cash/card)
|
||||
amount_deferred = Customer Account / pay-later amount at origin
|
||||
amount_settled = money collected later via settlement engine
|
||||
amount_due = amount_deferred - amount_settled
|
||||
|
||||
`amount_due > 0` means the customer still owes real money.
|
||||
"""
|
||||
_name = 'laundry.order'
|
||||
_description = 'Laundry Order'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Order No.',
|
||||
required=True, copy=False, readonly=True,
|
||||
default='New', tracking=True,
|
||||
)
|
||||
|
||||
# -- POS link --
|
||||
# Optional: manual/backoffice orders have no POS origin. The uniqueness
|
||||
# constraint below still enforces "one laundry order per pos.order" for
|
||||
# POS-sourced rows.
|
||||
pos_order_id = fields.Many2one(
|
||||
'pos.order', string='POS Order',
|
||||
index=True, readonly=True, copy=False,
|
||||
ondelete='restrict',
|
||||
)
|
||||
pos_reference = fields.Char(
|
||||
string='POS Reference',
|
||||
readonly=True, copy=False, index=True,
|
||||
)
|
||||
|
||||
# -- Customer --
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
required=True, index=True, tracking=True,
|
||||
)
|
||||
partner_phone = fields.Char(
|
||||
related='partner_id.phone', string='Phone', readonly=True,
|
||||
)
|
||||
|
||||
# -- Company / Currency --
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
required=True, default=lambda self: self.env.company,
|
||||
index=True,
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
related='company_id.currency_id', store=True,
|
||||
)
|
||||
|
||||
# -- Workflow --
|
||||
state = fields.Selection(
|
||||
STATES,
|
||||
string='Status',
|
||||
default='intake',
|
||||
required=True,
|
||||
tracking=True,
|
||||
copy=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# -- Lines --
|
||||
line_ids = fields.One2many(
|
||||
'laundry.order.line', 'order_id', string='Order Lines',
|
||||
)
|
||||
|
||||
# -- Financial snapshot --
|
||||
amount_total = fields.Monetary(
|
||||
string='Total',
|
||||
currency_field='currency_id',
|
||||
readonly=True, copy=False, store=True,
|
||||
)
|
||||
|
||||
amount_paid_cash = fields.Monetary(
|
||||
string='Paid (Cash/Card)',
|
||||
currency_field='currency_id',
|
||||
readonly=True, copy=False, store=True,
|
||||
default=0.0,
|
||||
help='Amount collected at origin via non-deferred payment methods '
|
||||
'(cash, card — any pos.payment.method with split_transactions=False).',
|
||||
)
|
||||
|
||||
amount_deferred = fields.Monetary(
|
||||
string='Deferred',
|
||||
currency_field='currency_id',
|
||||
readonly=True, copy=False, store=True,
|
||||
default=0.0,
|
||||
help='Amount deferred at origin via Customer Account / pay-later '
|
||||
'(any pos.payment.method with split_transactions=True).',
|
||||
)
|
||||
|
||||
amount_settled = fields.Monetary(
|
||||
string='Settled',
|
||||
currency_field='currency_id',
|
||||
readonly=True, copy=False, store=True,
|
||||
default=0.0,
|
||||
help='Amount collected later via the settlement engine '
|
||||
'(account.payment + reconciliation).',
|
||||
)
|
||||
|
||||
amount_due = fields.Monetary(
|
||||
string='Due',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_amount_due',
|
||||
store=True,
|
||||
help='Remaining balance the customer owes: amount_deferred - amount_settled.',
|
||||
)
|
||||
|
||||
# Back-compat alias — views/reports still reference `amount_paid`.
|
||||
# Computed, non-stored; reflects real cash collected at origin.
|
||||
amount_paid = fields.Monetary(
|
||||
string='Paid',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_amount_paid_alias',
|
||||
)
|
||||
|
||||
# -- Computed --
|
||||
item_count = fields.Integer(
|
||||
string='Items',
|
||||
compute='_compute_item_count',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# -- Order type / attributes / delivery --
|
||||
order_type_id = fields.Many2one(
|
||||
'laundry.order.type', string='Order Type',
|
||||
index=True, tracking=True,
|
||||
)
|
||||
attribute_ids = fields.Many2many(
|
||||
'laundry.order.attribute',
|
||||
'laundry_order_attribute_rel',
|
||||
'order_id', 'attribute_id',
|
||||
string='Attributes',
|
||||
)
|
||||
is_delivery = fields.Boolean(string='Delivery', tracking=True, index=True)
|
||||
delivery_address = fields.Text(string='Delivery Address')
|
||||
delivery_scheduled_at = fields.Datetime(string='Scheduled At')
|
||||
priority_level = fields.Selection([
|
||||
('normal', 'Normal'),
|
||||
('urgent', 'Urgent'),
|
||||
], string='Priority', default='normal', tracking=True, index=True)
|
||||
|
||||
# -- Notes --
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
# ── Source / locking (Phase 3) ────────────────────────────────────
|
||||
# source_type is the truth-bearing identity. is_from_pos is a stored
|
||||
# mirror used in domains, list filters, and rule conditions where a
|
||||
# selection field would be awkward.
|
||||
source_type = fields.Selection(
|
||||
SOURCE_TYPES,
|
||||
string='Source',
|
||||
required=True,
|
||||
default='manual',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
index=True,
|
||||
tracking=True,
|
||||
help='POS-sourced orders are hard-locked: lines, prices and the '
|
||||
'customer cannot be edited unless a manager grants a '
|
||||
'temporary unlock window. Manual orders are editable until '
|
||||
'they reach a final state (delivered / cancelled).',
|
||||
)
|
||||
|
||||
is_from_pos = fields.Boolean(
|
||||
string='From POS',
|
||||
compute='_compute_is_from_pos',
|
||||
store=True, index=True,
|
||||
)
|
||||
|
||||
# Phase-4 prep — flag only, no logic wired yet.
|
||||
tracking_enabled = fields.Boolean(
|
||||
string='Per-Item Tracking',
|
||||
default=False,
|
||||
copy=False,
|
||||
help='When enabled, each laundry.order.line will be advanced '
|
||||
'through its own state machine. Phase 4 wires the '
|
||||
'synchronization between order state and item state.',
|
||||
)
|
||||
|
||||
# Computed: True when the order refuses mutation of LOCKED_HEADER_FIELDS
|
||||
# and any line write/create/unlink. Not stored — cheap to recompute and
|
||||
# depends on a Datetime that ages out without a write.
|
||||
locked = fields.Boolean(
|
||||
string='Locked',
|
||||
compute='_compute_locked',
|
||||
help='Order is read-only when True. POS-sourced orders are '
|
||||
'always locked. Final-state orders (delivered, cancelled) '
|
||||
'are always locked. Managers can grant a temporary unlock '
|
||||
'window via the "Unlock for Editing" action.',
|
||||
)
|
||||
|
||||
manager_unlocked_until = fields.Datetime(
|
||||
string='Unlock Window Expires',
|
||||
copy=False, readonly=True,
|
||||
help='When set in the future, the lock guard is suspended. '
|
||||
'Auto-expires; no manual re-lock required.',
|
||||
)
|
||||
|
||||
manager_unlocked_by = fields.Many2one(
|
||||
'res.users', string='Last Unlocked By',
|
||||
copy=False, readonly=True,
|
||||
)
|
||||
|
||||
manager_unlock_reason = fields.Char(
|
||||
string='Last Unlock Reason',
|
||||
copy=False, readonly=True,
|
||||
)
|
||||
|
||||
# Stamped when the order moves to delivered. Powers the avg-processing
|
||||
# and on-time KPIs on the Operations Dashboard. Outside
|
||||
# LOCKED_HEADER_FIELDS so action_deliver can write it on POS-locked
|
||||
# orders without needing the bypass context.
|
||||
delivered_at = fields.Datetime(
|
||||
string='Delivered At',
|
||||
readonly=True, copy=False, index=True,
|
||||
help='Timestamp set automatically when the order moves to '
|
||||
'Delivered. Used by the analytics dashboard to compute '
|
||||
'processing time and on-time delivery percentage.',
|
||||
)
|
||||
|
||||
# -- Constraints --
|
||||
_pos_order_uniq = models.Constraint(
|
||||
'UNIQUE(pos_order_id)',
|
||||
'A laundry order already exists for this POS order.',
|
||||
)
|
||||
|
||||
@api.constrains('source_type', 'pos_order_id')
|
||||
def _check_source_type_consistency(self):
|
||||
for order in self:
|
||||
if order.source_type == 'pos' and not order.pos_order_id:
|
||||
raise UserError(_(
|
||||
'Order "%s" is marked as POS-sourced but has no '
|
||||
'linked POS order.', order.name or order.id,
|
||||
))
|
||||
if order.source_type == 'manual' and order.pos_order_id:
|
||||
raise UserError(_(
|
||||
'Order "%s" is marked as manual but has a linked '
|
||||
'POS order. Set source_type="pos" to keep them '
|
||||
'consistent.', order.name or order.id,
|
||||
))
|
||||
|
||||
# -- Computed --
|
||||
@api.depends('amount_deferred', 'amount_settled')
|
||||
def _compute_amount_due(self):
|
||||
for order in self:
|
||||
order.amount_due = max(
|
||||
(order.amount_deferred or 0.0) - (order.amount_settled or 0.0),
|
||||
0.0,
|
||||
)
|
||||
|
||||
@api.depends('amount_paid_cash')
|
||||
def _compute_amount_paid_alias(self):
|
||||
for order in self:
|
||||
order.amount_paid = order.amount_paid_cash or 0.0
|
||||
|
||||
@api.depends('source_type')
|
||||
def _compute_is_from_pos(self):
|
||||
for order in self:
|
||||
order.is_from_pos = order.source_type == 'pos'
|
||||
|
||||
@api.depends('source_type', 'state', 'manager_unlocked_until')
|
||||
def _compute_locked(self):
|
||||
now = fields.Datetime.now()
|
||||
for order in self:
|
||||
unlock_active = bool(
|
||||
order.manager_unlocked_until
|
||||
and order.manager_unlocked_until > now
|
||||
)
|
||||
base_locked = (
|
||||
order.source_type == 'pos'
|
||||
or order.state in FINAL_STATES
|
||||
)
|
||||
order.locked = base_locked and not unlock_active
|
||||
|
||||
# ── Lock enforcement helpers ──────────────────────────────────────
|
||||
def _is_pos_sync(self):
|
||||
"""True when the call originates from the POS sync hook (or any
|
||||
explicit server path that opts in via the context flag).
|
||||
|
||||
Both create() and write() honour this so the bridge from
|
||||
pos.order can build / refresh the laundry.order without fighting
|
||||
its own lock guard. This is checked BEFORE anything else in
|
||||
write() so indirect POS writes (stored-compute flushes, cascades)
|
||||
can never raise from the guard.
|
||||
"""
|
||||
return bool(self.env.context.get(POS_SYNC_CTX))
|
||||
|
||||
def _check_lock_for_write(self, vals):
|
||||
"""Raise UserError when `vals` would mutate a protected header
|
||||
field on a currently-locked order. Workflow advances (state,
|
||||
amount_settled, notes, manager_unlocked_*) are excluded by
|
||||
whitelist (LOCKED_HEADER_FIELDS).
|
||||
|
||||
Note: the POS-sync bypass is already applied at the top of
|
||||
`write()` — this helper is only invoked for non-bypassed paths.
|
||||
"""
|
||||
protected = LOCKED_HEADER_FIELDS.intersection(vals.keys())
|
||||
if not protected:
|
||||
return
|
||||
for order in self:
|
||||
if order.locked:
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" is locked. Editable fields: state '
|
||||
'transitions, internal notes, settlement amount.\n'
|
||||
'To edit %(fields)s, ask a manager to use '
|
||||
'"Unlock for Editing" first.',
|
||||
name=order.name,
|
||||
fields=', '.join(sorted(protected)),
|
||||
))
|
||||
|
||||
def _check_lock_for_unlink(self):
|
||||
"""POS-sourced and final-state orders cannot be unlinked. The
|
||||
manager unlock wizard is intentionally NOT honored here — deletion
|
||||
requires a stronger affordance (cancellation + audit trail), not
|
||||
a temporary edit window.
|
||||
"""
|
||||
for order in self:
|
||||
if order.source_type == 'pos':
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" was created from POS and cannot '
|
||||
'be deleted. Cancel the underlying POS order instead.',
|
||||
name=order.name,
|
||||
))
|
||||
if order.state in FINAL_STATES:
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" is in a final state (%(state)s) '
|
||||
'and cannot be deleted.',
|
||||
name=order.name,
|
||||
state=dict(STATES).get(order.state, order.state),
|
||||
))
|
||||
|
||||
@api.depends('line_ids.qty')
|
||||
def _compute_item_count(self):
|
||||
for order in self:
|
||||
order.item_count = int(sum(order.line_ids.mapped('qty')))
|
||||
|
||||
# -- 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.order')
|
||||
or 'New'
|
||||
)
|
||||
self._apply_type_attribute_inference(vals)
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
# STEP 1 — POS sync bypass.
|
||||
# Must be the very first thing we do. Any code path that opts
|
||||
# into the context flag (POS create/sync, settlement engine,
|
||||
# stored-compute flushes triggered from inside a bypassed write)
|
||||
# MUST sail through unconditionally. No locking check, no
|
||||
# side-effects, no iteration over self — just delegate to super.
|
||||
# This guarantee is what makes POS payment/settlement flows
|
||||
# immune to this model's lock guard.
|
||||
if self._is_pos_sync():
|
||||
if _logger.isEnabledFor(logging.DEBUG):
|
||||
_logger.debug(
|
||||
'laundry.order.write BYPASS ids=%s keys=%s',
|
||||
self.ids, list(vals.keys()),
|
||||
)
|
||||
return super().write(vals)
|
||||
|
||||
# STEP 2 — Forensic trace (DEBUG-level; off by default in prod,
|
||||
# enable with `--log-level=debug` or `--log-handler=odoo.addons
|
||||
# .laundry_management.models.laundry_order:DEBUG`).
|
||||
if _logger.isEnabledFor(logging.DEBUG):
|
||||
keys = list(vals.keys())
|
||||
for order in self:
|
||||
_logger.debug(
|
||||
'laundry.order.write id=%s source=%s state=%s '
|
||||
'locked=%s ctx_keys=%s vals_keys=%s',
|
||||
order.id, order.source_type, order.state, order.locked,
|
||||
sorted(self.env.context.keys()), keys,
|
||||
)
|
||||
|
||||
# STEP 3 — Lock guard for non-POS callers.
|
||||
self._check_lock_for_write(vals)
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
# Explicit: even with POS-sync context, refuse to delete a locked
|
||||
# order. Deletion is not a mutation the sync path ever issues.
|
||||
self._check_lock_for_unlink()
|
||||
return super().unlink()
|
||||
|
||||
@api.model
|
||||
def _apply_type_attribute_inference(self, vals):
|
||||
"""Fill in priority_level / is_delivery from the selected order
|
||||
type and attributes when the caller did not explicitly set them.
|
||||
|
||||
Rules:
|
||||
- type.priority='urgent' OR any attribute with
|
||||
is_priority_related=True → priority_level='urgent'
|
||||
- type.is_delivery=True OR any attribute with
|
||||
is_delivery_related=True → is_delivery=True
|
||||
- Do NOT overwrite explicit incoming delivery_address /
|
||||
delivery_scheduled_at with blank values.
|
||||
"""
|
||||
type_id = vals.get('order_type_id')
|
||||
order_type = (
|
||||
self.env['laundry.order.type'].browse(type_id) if type_id else None
|
||||
)
|
||||
|
||||
attribute_ids = []
|
||||
raw_attrs = vals.get('attribute_ids') or []
|
||||
for cmd in raw_attrs:
|
||||
if isinstance(cmd, (list, tuple)) and len(cmd) >= 3:
|
||||
# Odoo x2m commands: (6,0,[ids]), (4,id), etc.
|
||||
if cmd[0] == 6 and isinstance(cmd[2], (list, tuple)):
|
||||
attribute_ids.extend(cmd[2])
|
||||
elif cmd[0] == 4 and cmd[1]:
|
||||
attribute_ids.append(cmd[1])
|
||||
elif isinstance(cmd, int):
|
||||
attribute_ids.append(cmd)
|
||||
attributes = (
|
||||
self.env['laundry.order.attribute'].browse(attribute_ids)
|
||||
if attribute_ids else self.env['laundry.order.attribute']
|
||||
)
|
||||
|
||||
# Priority
|
||||
if 'priority_level' not in vals:
|
||||
urgent = (
|
||||
(order_type and order_type.priority == 'urgent')
|
||||
or any(a.is_priority_related for a in attributes)
|
||||
)
|
||||
vals['priority_level'] = 'urgent' if urgent else 'normal'
|
||||
|
||||
# Delivery
|
||||
if 'is_delivery' not in vals:
|
||||
delivery = (
|
||||
(order_type and order_type.is_delivery)
|
||||
or any(a.is_delivery_related for a in attributes)
|
||||
)
|
||||
vals['is_delivery'] = bool(delivery)
|
||||
|
||||
# -- Workflow actions --
|
||||
def action_process(self):
|
||||
for order in self:
|
||||
if order.state != 'intake':
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" is not in Intake state.',
|
||||
name=order.name,
|
||||
))
|
||||
order.state = 'processing'
|
||||
|
||||
def action_ready(self):
|
||||
for order in self:
|
||||
if order.state != 'processing':
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" is not in Processing state.',
|
||||
name=order.name,
|
||||
))
|
||||
order.state = 'ready'
|
||||
|
||||
def action_deliver(self):
|
||||
"""Guards: must be Ready + fully paid (amount_due == 0).
|
||||
|
||||
Also stamps `delivered_at` so the dashboard KPIs (avg processing
|
||||
time, on-time delivery %) can be computed from real data instead
|
||||
of the heuristic on `write_date`.
|
||||
"""
|
||||
for order in self:
|
||||
if order.state != 'ready':
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" must be Ready before delivery.',
|
||||
name=order.name,
|
||||
))
|
||||
if order.amount_due > 0:
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" has %(due).2f outstanding. '
|
||||
'Collect payment in POS before delivery.',
|
||||
name=order.name,
|
||||
due=order.amount_due,
|
||||
))
|
||||
order.write({
|
||||
'state': 'delivered',
|
||||
'delivered_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def action_cancel(self):
|
||||
"""Cancel an order. Allowed for manual orders only — POS-sourced
|
||||
orders must be voided through the POS workflow to keep the sale
|
||||
and the operational record in sync.
|
||||
"""
|
||||
for order in self:
|
||||
if order.source_type == 'pos':
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" was created from POS and cannot '
|
||||
'be cancelled here. Cancel the underlying POS order '
|
||||
'instead.',
|
||||
name=order.name,
|
||||
))
|
||||
if order.state in FINAL_STATES:
|
||||
raise UserError(_(
|
||||
'Order "%(name)s" is already %(state)s.',
|
||||
name=order.name,
|
||||
state=dict(STATES).get(order.state, order.state),
|
||||
))
|
||||
order.state = 'cancelled'
|
||||
|
||||
# -- Smart button --
|
||||
def action_open_pos_order(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'pos.order',
|
||||
'res_id': self.pos_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════
|
||||
# POS "Laundry Orders" popup — server-side RPCs
|
||||
# ---------------------------------------------------------------
|
||||
# These methods are the ONLY way the POS popup interacts with the
|
||||
# model. Each action delegates to the corresponding workflow method
|
||||
# (`action_process` / `action_ready` / `action_deliver`) which already
|
||||
# enforces state + amount_due guards server-side. Direct writes to
|
||||
# LOCKED_HEADER_FIELDS are NOT exposed here — the Phase 3 lock
|
||||
# remains the sole authority for business-edit protection.
|
||||
# ═════════════════════════════════════════════════════════════════
|
||||
|
||||
def _pos_allowed_actions(self):
|
||||
"""Return the list of action keys the popup may render for this
|
||||
order. Pure function of state + amount_due. Final states only
|
||||
allow printing.
|
||||
"""
|
||||
self.ensure_one()
|
||||
actions = ['print_work_order']
|
||||
if self.state in FINAL_STATES:
|
||||
return actions
|
||||
if self.state == 'intake':
|
||||
actions.append('start_processing')
|
||||
elif self.state == 'processing':
|
||||
actions.append('mark_ready')
|
||||
elif self.state == 'ready':
|
||||
if self.amount_due <= 0:
|
||||
actions.append('deliver')
|
||||
else:
|
||||
actions.append('collect_payment')
|
||||
return actions
|
||||
|
||||
def _pos_payment_state(self):
|
||||
self.ensure_one()
|
||||
if self.amount_due > 0:
|
||||
return 'due'
|
||||
if self.amount_deferred > 0 and self.amount_settled >= self.amount_deferred:
|
||||
return 'settled'
|
||||
if self.amount_deferred > 0:
|
||||
return 'deferred'
|
||||
return 'paid'
|
||||
|
||||
def _pos_payload(self):
|
||||
"""Compact, UI-ready dict. Single source of truth for the popup
|
||||
shape — every RPC returns exactly this structure."""
|
||||
self.ensure_one()
|
||||
names = self.line_ids.mapped('product_id.name')
|
||||
if not names:
|
||||
names = self.line_ids.mapped('description')
|
||||
state_selection = dict(self._fields['state'].selection)
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'state': self.state,
|
||||
'state_label': state_selection.get(self.state) or self.state,
|
||||
'pos_reference': self.pos_reference or '',
|
||||
'is_from_pos': self.is_from_pos,
|
||||
'create_date': fields.Datetime.to_string(self.create_date) if self.create_date else False,
|
||||
'item_count': int(self.item_count or 0),
|
||||
'service_summary': ', '.join(dict.fromkeys(names))[:80],
|
||||
'amount_total': self.amount_total,
|
||||
'amount_paid': self.amount_paid_cash,
|
||||
'amount_deferred': self.amount_deferred,
|
||||
'amount_settled': self.amount_settled,
|
||||
'amount_due': self.amount_due,
|
||||
'payment_state': self._pos_payment_state(),
|
||||
'is_delivery': self.is_delivery,
|
||||
'allowed_actions': self._pos_allowed_actions(),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def pos_search_customer_orders(self, partner_id, search_query=False, limit=20):
|
||||
"""Partner-scoped search for the POS popup.
|
||||
|
||||
• Always filters by partner_id (no global search in this phase).
|
||||
• Optional search_query ilike-matches on name / pos_reference /
|
||||
partner_phone.
|
||||
• Hard-capped at 50 rows regardless of the caller's limit.
|
||||
"""
|
||||
if not partner_id:
|
||||
return []
|
||||
limit = max(1, min(int(limit or 20), 50))
|
||||
q = (search_query or '').strip()
|
||||
domain = [('partner_id', '=', partner_id)]
|
||||
if q:
|
||||
domain = [
|
||||
'&',
|
||||
('partner_id', '=', partner_id),
|
||||
'|', '|',
|
||||
('name', 'ilike', q),
|
||||
('pos_reference', 'ilike', q),
|
||||
('partner_phone', 'ilike', q),
|
||||
]
|
||||
orders = self.search(
|
||||
domain, order='create_date desc, id desc', limit=limit,
|
||||
)
|
||||
return [o._pos_payload() for o in orders]
|
||||
|
||||
def pos_action_start_processing(self):
|
||||
"""POS popup: advance intake → processing. Returns refreshed payload."""
|
||||
self.ensure_one()
|
||||
self.action_process()
|
||||
return self._pos_payload()
|
||||
|
||||
def pos_action_mark_ready(self):
|
||||
"""POS popup: advance processing → ready. Returns refreshed payload."""
|
||||
self.ensure_one()
|
||||
self.action_ready()
|
||||
return self._pos_payload()
|
||||
|
||||
def pos_action_deliver(self):
|
||||
"""POS popup: advance ready → delivered. Returns refreshed payload.
|
||||
|
||||
`action_deliver` raises UserError when amount_due > 0 — the popup
|
||||
surfaces that error; no client-side duplication of the rule.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self.action_deliver()
|
||||
return self._pos_payload()
|
||||
|
||||
@api.model
|
||||
def pos_get_thermal_data(self, order_id):
|
||||
"""Build a self-contained payload for the thermal Work-Order
|
||||
receipt rendered by `laundry_management.LaundryWorkOrderThermal`.
|
||||
|
||||
Independent from `_pos_payload` — that one is for the popup list
|
||||
(compact); this one carries every line + delivery meta the
|
||||
cashier needs on the printed slip.
|
||||
"""
|
||||
order = self.browse(int(order_id))
|
||||
if not order.exists():
|
||||
return False
|
||||
state_label = dict(order._fields['state'].selection).get(
|
||||
order.state, order.state,
|
||||
)
|
||||
return {
|
||||
'id': order.id,
|
||||
'name': order.name,
|
||||
'state': order.state,
|
||||
'state_label': state_label,
|
||||
'payment_state': order._pos_payment_state(),
|
||||
'pos_reference': order.pos_reference or '',
|
||||
'partner_name': order.partner_id.name or '',
|
||||
# `mobile` is provided by the optional `phone` add-on; fall
|
||||
# back gracefully when it isn't present in the install.
|
||||
'partner_phone': (
|
||||
order.partner_id.phone
|
||||
or getattr(order.partner_id, 'mobile', '')
|
||||
or ''
|
||||
),
|
||||
'company_name': order.company_id.name or '',
|
||||
'create_date': fields.Datetime.to_string(order.create_date),
|
||||
'lines': [{
|
||||
'qty': line.qty,
|
||||
'description': (
|
||||
line.description
|
||||
or (line.product_id.name if line.product_id else '')
|
||||
),
|
||||
'price_unit': line.price_unit,
|
||||
'subtotal': line.subtotal,
|
||||
'tracking_code': line.tracking_code or '',
|
||||
} for line in order.line_ids],
|
||||
'item_count': int(order.item_count or 0),
|
||||
'amount_total': order.amount_total,
|
||||
'amount_paid': order.amount_paid_cash,
|
||||
'amount_deferred': order.amount_deferred,
|
||||
'amount_settled': order.amount_settled,
|
||||
'amount_due': order.amount_due,
|
||||
'is_delivery': order.is_delivery,
|
||||
'delivery_address': order.delivery_address or '',
|
||||
'delivery_scheduled_at': (
|
||||
fields.Datetime.to_string(order.delivery_scheduled_at)
|
||||
if order.delivery_scheduled_at else ''
|
||||
),
|
||||
'currency_symbol': order.currency_id.symbol or '',
|
||||
'currency_position': order.currency_id.position or 'after',
|
||||
}
|
||||
|
||||
def action_open_unlock_wizard(self):
|
||||
"""Open the manager unlock wizard pre-filled with this order.
|
||||
Access is enforced inside the wizard's action method (group
|
||||
check + reason required), but we also short-circuit here so
|
||||
the button itself is silent for non-managers.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.locked:
|
||||
raise UserError(_(
|
||||
'Order "%s" is already editable.', self.name,
|
||||
))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'laundry.order.unlock.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_order_id': self.id,
|
||||
},
|
||||
}
|
||||
62
addons/laundry_management/models/laundry_order_attribute.py
Normal file
62
addons/laundry_management/models/laundry_order_attribute.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class LaundryOrderAttribute(models.Model):
|
||||
"""Optional per-order badge (Urgent / Hanger / Fold / Delicate / ...).
|
||||
|
||||
Multi-selectable on a laundry order. Semantic flags drive behavior
|
||||
without name matching, so admins can rename freely.
|
||||
"""
|
||||
_name = 'laundry.order.attribute'
|
||||
_inherit = ['pos.load.mixin']
|
||||
_description = 'Laundry Order Attribute'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True, translate=True)
|
||||
code = fields.Char(string='Code')
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
color = fields.Char(string='Color')
|
||||
icon_image = fields.Binary(string='Icon')
|
||||
description = fields.Text(string='Description', translate=True)
|
||||
|
||||
extra_price = fields.Float(
|
||||
string='Extra Price',
|
||||
help='Reserved for future pricing rules. Not applied automatically.',
|
||||
)
|
||||
|
||||
pos_available = fields.Boolean(string='Available in POS', default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company, index=True,
|
||||
)
|
||||
|
||||
is_delivery_related = fields.Boolean(
|
||||
string='Delivery Related',
|
||||
help='Selecting this attribute marks the order as delivery and '
|
||||
'triggers the delivery-details prompt.',
|
||||
)
|
||||
is_priority_related = fields.Boolean(
|
||||
string='Priority Related',
|
||||
help='Selecting this attribute promotes the order to urgent priority.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _load_pos_data_domain(self, data, config):
|
||||
return [
|
||||
('pos_available', '=', True),
|
||||
('active', '=', True),
|
||||
'|',
|
||||
('company_id', '=', False),
|
||||
('company_id', 'in', config.company_id.ids),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _load_pos_data_fields(self, config):
|
||||
return [
|
||||
'id', 'name', 'code', 'sequence',
|
||||
'color', 'description',
|
||||
'extra_price',
|
||||
'is_delivery_related', 'is_priority_related',
|
||||
]
|
||||
288
addons/laundry_management/models/laundry_order_line.py
Normal file
288
addons/laundry_management/models/laundry_order_line.py
Normal file
@@ -0,0 +1,288 @@
|
||||
import logging
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from .laundry_order import POS_SYNC_CTX
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
LINE_STATES = [
|
||||
('received', 'Received'),
|
||||
('processing', 'Processing'),
|
||||
('ready', 'Ready'),
|
||||
('delivered', 'Delivered'),
|
||||
]
|
||||
|
||||
# Line fields the lock protects. NOT included on purpose:
|
||||
# - state (per-item workflow advance is always allowed)
|
||||
# - customer_note (operator commentary)
|
||||
# - tracking_code (auto-assigned; cannot change after create either way)
|
||||
LOCKED_LINE_FIELDS = frozenset({
|
||||
'product_id', 'description', 'qty', 'price_unit',
|
||||
})
|
||||
|
||||
|
||||
class LaundryOrderLine(models.Model):
|
||||
"""Line item on a laundry order — maps from pos.order.line.
|
||||
|
||||
Each line carries a unique scannable tracking_code (barcode) and its
|
||||
own per-item workflow state. The order-level state on laundry.order
|
||||
remains the source of truth for financial gates; the per-line state
|
||||
is an operational overlay that supports items moving through the
|
||||
workflow at different speeds.
|
||||
"""
|
||||
_name = 'laundry.order.line'
|
||||
_description = 'Laundry Order Line'
|
||||
_order = 'order_id, id'
|
||||
|
||||
order_id = fields.Many2one(
|
||||
'laundry.order', string='Order',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
# Mirror order-level partner/state so list + kanban views can filter/group
|
||||
# without costly cross-model joins.
|
||||
order_partner_id = fields.Many2one(
|
||||
related='order_id.partner_id', store=True, index=True, readonly=True,
|
||||
)
|
||||
order_state = fields.Selection(
|
||||
related='order_id.state', store=True, index=True, readonly=True,
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product', string='Product',
|
||||
)
|
||||
description = fields.Char(
|
||||
string='Description',
|
||||
)
|
||||
qty = fields.Float(
|
||||
string='Quantity',
|
||||
default=1.0, digits=(10, 2),
|
||||
)
|
||||
price_unit = fields.Float(
|
||||
string='Unit Price',
|
||||
digits=(10, 2), readonly=True,
|
||||
)
|
||||
customer_note = fields.Char(
|
||||
string='Customer Note',
|
||||
)
|
||||
subtotal = fields.Float(
|
||||
string='Subtotal',
|
||||
compute='_compute_subtotal',
|
||||
store=True, digits=(10, 2),
|
||||
)
|
||||
|
||||
# -- Per-item tracking --
|
||||
tracking_code = fields.Char(
|
||||
string='Tracking Code',
|
||||
copy=False, readonly=True, index=True,
|
||||
help='Unique scannable barcode for this item.',
|
||||
)
|
||||
state = fields.Selection(
|
||||
LINE_STATES,
|
||||
string='Item Status',
|
||||
default='received',
|
||||
required=True, copy=False, index=True,
|
||||
)
|
||||
|
||||
_tracking_code_uniq = models.Constraint(
|
||||
'UNIQUE(tracking_code)',
|
||||
'Tracking code must be unique across all laundry items.',
|
||||
)
|
||||
|
||||
@api.depends('qty', 'price_unit')
|
||||
def _compute_subtotal(self):
|
||||
for line in self:
|
||||
line.subtotal = line.qty * line.price_unit
|
||||
|
||||
# Sequence code for the auto-generated tracking_code (barcode).
|
||||
_TRACKING_SEQ_CODE = 'laundry.order.line.tracking'
|
||||
|
||||
@api.model
|
||||
def _next_tracking_code(self):
|
||||
"""Allocate a tracking_code that is GUARANTEED unique across the
|
||||
existing laundry_order_line table.
|
||||
|
||||
Why this exists
|
||||
───────────────
|
||||
Postgres sequences are NON-transactional: a `nextval()` advances
|
||||
the sequence even when the surrounding ORM transaction rolls
|
||||
back. Repeated POS validates that fail (lock, missing partner,
|
||||
anything) eat sequence values without consuming them in real
|
||||
rows. Conversely, a partial reseed / data import that inserts
|
||||
rows with manual tracking_codes leaves the sequence BEHIND the
|
||||
table's MAX. Either way, `next_by_code()` can return a code
|
||||
that already exists → UniqueViolation → POS sale silently
|
||||
misses its laundry-order link (the savepoint in pos_order.py
|
||||
catches the SQL error to protect the POS commit).
|
||||
|
||||
How this fixes it
|
||||
─────────────────
|
||||
On collision, repair the sequence to (max_tracking_num + 1)
|
||||
using a direct SQL nextval-skip, then ask the sequence again.
|
||||
Capped at a few attempts so a real bug (e.g. malformed schema)
|
||||
still surfaces instead of looping forever.
|
||||
"""
|
||||
seq = self.env['ir.sequence']
|
||||
for attempt in range(5):
|
||||
code = seq.next_by_code(self._TRACKING_SEQ_CODE) or False
|
||||
if not code:
|
||||
return False
|
||||
# Cheap collision check; the underlying UNIQUE constraint is
|
||||
# the real safety net — this just avoids paying the round-trip.
|
||||
existing = self.sudo().search_count([('tracking_code', '=', code)])
|
||||
if not existing:
|
||||
return code
|
||||
# Collision — repair sequence past the current MAX, then retry.
|
||||
self._repair_tracking_sequence()
|
||||
_logger.warning(
|
||||
"laundry.order.line tracking sequence collided on %s "
|
||||
"(attempt %d); repaired and retrying.", code, attempt + 1,
|
||||
)
|
||||
# If we still can't get a unique code after 5 tries, surface the
|
||||
# problem instead of writing an empty code.
|
||||
raise UserError(_(
|
||||
'Could not allocate a unique tracking code after 5 attempts. '
|
||||
'Check the laundry_management sequence configuration.'
|
||||
))
|
||||
|
||||
@api.model
|
||||
def _repair_tracking_sequence(self):
|
||||
"""Advance the tracking-code sequence past the actual MAX in the
|
||||
table. Idempotent — safe to call repeatedly. SQL-level so it
|
||||
works even when the ORM env context is unusual (sudo, sync hook).
|
||||
"""
|
||||
self.env.cr.execute("""
|
||||
SELECT COALESCE(MAX(
|
||||
CAST(NULLIF(REGEXP_REPLACE(tracking_code, '[^0-9]', '', 'g'), '')
|
||||
AS INTEGER)
|
||||
), 0)
|
||||
FROM laundry_order_line
|
||||
WHERE tracking_code IS NOT NULL;
|
||||
""")
|
||||
max_existing = self.env.cr.fetchone()[0] or 0
|
||||
# `ir.sequence` writes update number_next; we use the API for
|
||||
# safety (handles ranges, prefixes, padding).
|
||||
seq = self.env['ir.sequence'].sudo().search(
|
||||
[('code', '=', self._TRACKING_SEQ_CODE)], limit=1,
|
||||
)
|
||||
if seq:
|
||||
seq.write({'number_next': max_existing + 1})
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# POS-sync bypass FIRST — POS creates the order and its lines in a
|
||||
# single create_vals payload; both must sail through the guard.
|
||||
pos_sync = bool(self.env.context.get(POS_SYNC_CTX))
|
||||
if not pos_sync:
|
||||
order_ids = {v.get('order_id') for v in vals_list if v.get('order_id')}
|
||||
if order_ids:
|
||||
orders = self.env['laundry.order'].browse(list(order_ids))
|
||||
for order in orders:
|
||||
if order.locked:
|
||||
raise UserError(_(
|
||||
'Cannot add a line to locked order "%s".',
|
||||
order.name,
|
||||
))
|
||||
for vals in vals_list:
|
||||
if not vals.get('tracking_code'):
|
||||
vals['tracking_code'] = self._next_tracking_code()
|
||||
if _logger.isEnabledFor(logging.DEBUG):
|
||||
_logger.debug(
|
||||
'laundry.order.line.create pos_sync=%s count=%d',
|
||||
pos_sync, len(vals_list),
|
||||
)
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
if self.env.context.get(POS_SYNC_CTX):
|
||||
if _logger.isEnabledFor(logging.DEBUG):
|
||||
_logger.debug(
|
||||
'laundry.order.line.write BYPASS ids=%s keys=%s',
|
||||
self.ids, list(vals.keys()),
|
||||
)
|
||||
return super().write(vals)
|
||||
protected = LOCKED_LINE_FIELDS.intersection(vals.keys())
|
||||
if protected:
|
||||
for line in self:
|
||||
if line.order_id.locked:
|
||||
raise UserError(_(
|
||||
'Line on locked order "%(order)s" cannot edit '
|
||||
'%(fields)s. Ask a manager to use "Unlock for '
|
||||
'Editing" first.',
|
||||
order=line.order_id.name,
|
||||
fields=', '.join(sorted(protected)),
|
||||
))
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
# No bypass: deletion is never issued by the POS sync path.
|
||||
for line in self:
|
||||
if line.order_id.locked:
|
||||
raise UserError(_(
|
||||
'Cannot delete a line from locked order "%s".',
|
||||
line.order_id.name,
|
||||
))
|
||||
return super().unlink()
|
||||
|
||||
# -- Per-line workflow actions (1-click) --
|
||||
def action_line_process(self):
|
||||
for line in self:
|
||||
if line.state != 'received':
|
||||
raise UserError(_(
|
||||
'Item %(code)s is not in Received state.',
|
||||
code=line.tracking_code or line.id,
|
||||
))
|
||||
line.state = 'processing'
|
||||
|
||||
def action_line_ready(self):
|
||||
for line in self:
|
||||
if line.state != 'processing':
|
||||
raise UserError(_(
|
||||
'Item %(code)s is not in Processing state.',
|
||||
code=line.tracking_code or line.id,
|
||||
))
|
||||
line.state = 'ready'
|
||||
|
||||
def action_line_deliver(self):
|
||||
for line in self:
|
||||
if line.state != 'ready':
|
||||
raise UserError(_(
|
||||
'Item %(code)s must be Ready before delivery.',
|
||||
code=line.tracking_code or line.id,
|
||||
))
|
||||
line.state = 'delivered'
|
||||
|
||||
@api.model
|
||||
def action_scan_advance(self, tracking_code):
|
||||
"""Advance an item one stage by its scanned tracking code.
|
||||
|
||||
Intended for barcode scanner workflow: scanner types the code,
|
||||
this method finds the line and bumps its state to the next stage.
|
||||
Returns the new state or raises UserError if terminal / unknown.
|
||||
"""
|
||||
if not tracking_code:
|
||||
raise UserError(_('Scan a tracking code.'))
|
||||
line = self.search([('tracking_code', '=', tracking_code.strip())], limit=1)
|
||||
if not line:
|
||||
raise UserError(_('No item with tracking code %s.', tracking_code))
|
||||
transitions = {
|
||||
'received': line.action_line_process,
|
||||
'processing': line.action_line_ready,
|
||||
'ready': line.action_line_deliver,
|
||||
}
|
||||
action = transitions.get(line.state)
|
||||
if not action:
|
||||
raise UserError(_(
|
||||
'Item %(code)s is already %(state)s.',
|
||||
code=line.tracking_code, state=line.state,
|
||||
))
|
||||
action()
|
||||
return {
|
||||
'id': line.id,
|
||||
'tracking_code': line.tracking_code,
|
||||
'state': line.state,
|
||||
'order_name': line.order_id.name,
|
||||
'partner_name': line.order_partner_id.name,
|
||||
'product_name': line.product_id.display_name,
|
||||
}
|
||||
53
addons/laundry_management/models/laundry_order_line_addon.py
Normal file
53
addons/laundry_management/models/laundry_order_line_addon.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class LaundryOrderLineAddon(models.Model):
|
||||
"""Optional add-on service attached to one sale.order.line.
|
||||
|
||||
Examples per item:
|
||||
- Express handling (+10 SAR)
|
||||
- Starch / تقطير (+3 SAR)
|
||||
- Packaging (+2 SAR)
|
||||
- Perfume scent (+5 SAR)
|
||||
|
||||
The parent line's subtotal does NOT automatically include add-on prices
|
||||
(sale.order.line controls its own subtotal). Add-ons are tracked here for
|
||||
printing and reporting purposes; staff can create a separate order line for
|
||||
the add-on product if billing is required.
|
||||
"""
|
||||
_name = 'laundry.order.line.addon'
|
||||
_description = 'Order Line Add-on'
|
||||
_order = 'line_id, id'
|
||||
|
||||
line_id = fields.Many2one(
|
||||
'sale.order.line', string='Order Line',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
# Denormal for easy reporting — derived from sale.order.line.order_id
|
||||
order_id = fields.Many2one(
|
||||
'sale.order',
|
||||
related='line_id.order_id', store=True, index=True,
|
||||
)
|
||||
|
||||
name = fields.Char(
|
||||
string='Add-on / الإضافة',
|
||||
required=True,
|
||||
help='Name of the additional service (e.g. Express, Starch, Packaging).',
|
||||
)
|
||||
price = fields.Float(
|
||||
string='Price / السعر',
|
||||
required=True, digits=(10, 2), default=0.0,
|
||||
)
|
||||
quantity = fields.Float(
|
||||
string='Qty',
|
||||
default=1.0, digits=(10, 2),
|
||||
)
|
||||
subtotal = fields.Float(
|
||||
string='Subtotal',
|
||||
compute='_compute_subtotal', store=True, digits=(10, 2),
|
||||
)
|
||||
|
||||
@api.depends('price', 'quantity')
|
||||
def _compute_subtotal(self):
|
||||
for addon in self:
|
||||
addon.subtotal = addon.price * addon.quantity
|
||||
75
addons/laundry_management/models/laundry_order_type.py
Normal file
75
addons/laundry_management/models/laundry_order_type.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class LaundryOrderType(models.Model):
|
||||
"""Main intake type for a laundry order (Standard / Express / Delivery / VIP ...).
|
||||
|
||||
Drives workflow hints (priority, is_delivery) and suggests default
|
||||
attributes. Admin-configurable from the backend; exposed to POS via
|
||||
_load_pos_data_* so cashiers see live options in a popup.
|
||||
"""
|
||||
_name = 'laundry.order.type'
|
||||
_inherit = ['pos.load.mixin']
|
||||
_description = 'Laundry Order Type'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True, translate=True)
|
||||
code = fields.Char(string='Code')
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
priority = fields.Selection([
|
||||
('normal', 'Normal'),
|
||||
('urgent', 'Urgent'),
|
||||
], string='Priority', default='normal', required=True)
|
||||
|
||||
is_delivery = fields.Boolean(string='Is Delivery')
|
||||
requires_address = fields.Boolean(string='Requires Address')
|
||||
requires_scheduled_time = fields.Boolean(string='Requires Scheduled Time')
|
||||
|
||||
color = fields.Char(string='Color', help='Hex color, e.g. #FF8800')
|
||||
icon_image = fields.Binary(string='Icon')
|
||||
description = fields.Text(string='Description', translate=True)
|
||||
|
||||
extra_price = fields.Float(
|
||||
string='Extra Price',
|
||||
help='Reserved for future pricing rules. Not applied automatically.',
|
||||
)
|
||||
|
||||
pos_available = fields.Boolean(
|
||||
string='Available in POS', default=True,
|
||||
help='Uncheck to hide this type from the POS popup without archiving.',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company, index=True,
|
||||
)
|
||||
|
||||
attribute_ids = fields.Many2many(
|
||||
'laundry.order.attribute',
|
||||
'laundry_order_type_attribute_rel',
|
||||
'type_id', 'attribute_id',
|
||||
string='Default Attributes',
|
||||
help='Attributes pre-suggested (pre-checked) when this type is chosen.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _load_pos_data_domain(self, data, config):
|
||||
return [
|
||||
('pos_available', '=', True),
|
||||
('active', '=', True),
|
||||
'|',
|
||||
('company_id', '=', False),
|
||||
('company_id', 'in', config.company_id.ids),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _load_pos_data_fields(self, config):
|
||||
return [
|
||||
'id', 'name', 'code', 'sequence',
|
||||
'priority', 'is_delivery',
|
||||
'requires_address', 'requires_scheduled_time',
|
||||
'color', 'description',
|
||||
'extra_price',
|
||||
'attribute_ids',
|
||||
]
|
||||
101
addons/laundry_management/models/laundry_payment_method.py
Normal file
101
addons/laundry_management/models/laundry_payment_method.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class LaundryPaymentMethod(models.Model):
|
||||
"""Configurable payment method linked to an accounting journal.
|
||||
|
||||
One record per payment option (e.g. Cash, Visa, Bank Transfer, Credit).
|
||||
Each method is typed as cash / bank / credit so that session cash-control
|
||||
and accounting routing work exactly like Odoo POS payment methods.
|
||||
|
||||
cash → counted in the session cash drawer
|
||||
bank → posted to a bank/card journal, not counted in cash
|
||||
credit → deferred / no-posting (customer owes)
|
||||
"""
|
||||
_name = 'laundry.payment.method'
|
||||
_description = 'Laundry Payment Method'
|
||||
_order = 'sequence, id'
|
||||
|
||||
# ── Identity ──────────────────────────────────────────────────────
|
||||
name = fields.Char(
|
||||
string='Method Name',
|
||||
required=True,
|
||||
translate=True,
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ── Type ──────────────────────────────────────────────────────────
|
||||
payment_type = fields.Selection([
|
||||
('cash', 'Cash / نقد'),
|
||||
('bank', 'Bank / Card / بنك'),
|
||||
('credit', 'Credit / Deferred / آجل'),
|
||||
], string='Type', required=True, default='cash',
|
||||
help=(
|
||||
'cash → counts in session cash drawer\n'
|
||||
'bank → posted to bank/card journal\n'
|
||||
'credit → deferred, no immediate accounting entry'
|
||||
),
|
||||
)
|
||||
|
||||
# ── Journal ───────────────────────────────────────────────────────
|
||||
journal_id = fields.Many2one(
|
||||
'account.journal', string='Accounting Journal',
|
||||
domain="[('type', 'in', ['cash', 'bank']), ('company_id', '=', company_id)]",
|
||||
help='Leave blank only for Credit/Deferred methods.',
|
||||
)
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────
|
||||
is_default = fields.Boolean(
|
||||
string='Default',
|
||||
help='Pre-selected when staff opens the Register Payment wizard.',
|
||||
)
|
||||
|
||||
# ── Constraints ───────────────────────────────────────────────────
|
||||
@api.constrains('is_default', 'company_id')
|
||||
def _check_single_default(self):
|
||||
for rec in self.filtered('is_default'):
|
||||
duplicate = self.search([
|
||||
('is_default', '=', True),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
('id', '!=', rec.id),
|
||||
], limit=1)
|
||||
if duplicate:
|
||||
raise UserError(
|
||||
f'Only one default payment method is allowed per company.\n'
|
||||
f'"{duplicate.name}" is already the default.\n'
|
||||
'Unset it first before marking this one as default.'
|
||||
)
|
||||
|
||||
@api.constrains('payment_type', 'journal_id')
|
||||
def _check_journal_required(self):
|
||||
for rec in self:
|
||||
if rec.payment_type in ('cash', 'bank') and not rec.journal_id:
|
||||
raise UserError(
|
||||
f'Payment method "{rec.name}" is of type "{rec.payment_type}" '
|
||||
'and requires an accounting journal. '
|
||||
'Please select a journal or change the type to Credit/Deferred.'
|
||||
)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
@api.model
|
||||
def get_default_method(self):
|
||||
"""Return the default payment method for the current company."""
|
||||
company = self.env.company
|
||||
return (
|
||||
self.search([
|
||||
('is_default', '=', True),
|
||||
('company_id', '=', company.id),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
or self.search([
|
||||
('payment_type', '=', 'cash'),
|
||||
('company_id', '=', company.id),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
)
|
||||
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)
|
||||
137
addons/laundry_management/models/laundry_settings.py
Normal file
137
addons/laundry_management/models/laundry_settings.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class LaundrySettings(models.TransientModel):
|
||||
"""Laundry Management settings panel (PART 9).
|
||||
|
||||
Extends res.config.settings to add laundry-specific configuration:
|
||||
- WhatsApp Business API credentials and behaviour
|
||||
- Commission tracking rates, rules, and default account
|
||||
- Session enforcement and default cash journal
|
||||
- Print format defaults
|
||||
- Cash control: default difference account
|
||||
"""
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
# ── WhatsApp ──────────────────────────────────────────────────────
|
||||
laundry_wa_token = fields.Char(
|
||||
string='WhatsApp Cloud API Token',
|
||||
config_parameter='laundry.whatsapp_api_token',
|
||||
)
|
||||
laundry_wa_phone_id = fields.Char(
|
||||
string='Phone Number ID',
|
||||
config_parameter='laundry.whatsapp_phone_id',
|
||||
)
|
||||
laundry_wa_store_number = fields.Char(
|
||||
string='Store WhatsApp Number',
|
||||
config_parameter='laundry.whatsapp_store_number',
|
||||
help='E.164 format without + (e.g. 966501234567). Used as fallback number.',
|
||||
)
|
||||
laundry_wa_auto_send = fields.Boolean(
|
||||
string='Auto-send WhatsApp on Order Confirm',
|
||||
)
|
||||
|
||||
# ── Commission ────────────────────────────────────────────────────
|
||||
laundry_commission_enabled = fields.Boolean(
|
||||
string='Enable Staff Commission Tracking',
|
||||
)
|
||||
laundry_commission_type = fields.Selection([
|
||||
('percentage', 'Percentage of Order Total (%)'),
|
||||
('fixed', 'Fixed Amount per Order'),
|
||||
], string='Commission Type')
|
||||
laundry_commission_reception_rate = fields.Float(
|
||||
string='Reception Commission', digits=(10, 2),
|
||||
)
|
||||
laundry_commission_processing_rate = fields.Float(
|
||||
string='Processing Commission', digits=(10, 2),
|
||||
)
|
||||
laundry_commission_delivery_rate = fields.Float(
|
||||
string='Delivery Commission', digits=(10, 2),
|
||||
)
|
||||
laundry_commission_account_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Commission Expense Account',
|
||||
domain="[('account_type', 'in', ['expense', 'expense_direct_cost']), ('company_ids', 'in', [company_id])]",
|
||||
help='Account for booking commission expenses (for future accounting integration).',
|
||||
)
|
||||
|
||||
# ── Session / Cash Control ────────────────────────────────────────
|
||||
laundry_require_session = fields.Boolean(
|
||||
string='Require Open Session to Confirm Orders',
|
||||
)
|
||||
laundry_cash_journal_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Default Cash Journal',
|
||||
domain="[('type', '=', 'cash'), ('company_id', '=', company_id)]",
|
||||
help='Default cash journal used in the payment wizard.',
|
||||
)
|
||||
laundry_difference_account_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Cash Difference Account',
|
||||
domain="[('company_ids', 'in', [company_id])]",
|
||||
help='Default account for posting session cash count variances.',
|
||||
)
|
||||
|
||||
# ── Print ─────────────────────────────────────────────────────────
|
||||
laundry_default_paper = fields.Selection([
|
||||
('a4', 'A4 Full Receipt'),
|
||||
('thermal', 'Thermal / 80mm Roll'),
|
||||
], string='Default Print Format')
|
||||
|
||||
# company_id helper for domain filtering
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# ── Load & Save ───────────────────────────────────────────────────
|
||||
def get_values(self):
|
||||
res = super().get_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
def _bool(key, default='False'):
|
||||
return ICP.get_param(key, default) == 'True'
|
||||
|
||||
def _float(key, default='0.0'):
|
||||
try:
|
||||
return float(ICP.get_param(key, default))
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
def _int_record(key):
|
||||
val = ICP.get_param(key, '')
|
||||
try:
|
||||
return int(val) if val else False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
res.update({
|
||||
'laundry_wa_auto_send': _bool('laundry_management.wa_auto_send'),
|
||||
'laundry_commission_enabled': _bool('laundry_management.commission_enabled'),
|
||||
'laundry_commission_type': ICP.get_param('laundry_management.commission_type', 'percentage'),
|
||||
'laundry_commission_reception_rate': _float('laundry_management.commission_reception_rate'),
|
||||
'laundry_commission_processing_rate': _float('laundry_management.commission_processing_rate'),
|
||||
'laundry_commission_delivery_rate': _float('laundry_management.commission_delivery_rate'),
|
||||
'laundry_commission_account_id': _int_record('laundry_management.commission_account_id'),
|
||||
'laundry_require_session': _bool('laundry_management.require_session'),
|
||||
'laundry_cash_journal_id': _int_record('laundry_management.cash_journal_id'),
|
||||
'laundry_difference_account_id': _int_record('laundry_management.difference_account_id'),
|
||||
'laundry_default_paper': ICP.get_param('laundry_management.default_paper', 'a4'),
|
||||
})
|
||||
return res
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
ICP.set_param('laundry_management.wa_auto_send', str(self.laundry_wa_auto_send))
|
||||
ICP.set_param('laundry_management.commission_enabled', str(self.laundry_commission_enabled))
|
||||
ICP.set_param('laundry_management.commission_type', self.laundry_commission_type or 'percentage')
|
||||
ICP.set_param('laundry_management.commission_reception_rate', str(self.laundry_commission_reception_rate))
|
||||
ICP.set_param('laundry_management.commission_processing_rate', str(self.laundry_commission_processing_rate))
|
||||
ICP.set_param('laundry_management.commission_delivery_rate', str(self.laundry_commission_delivery_rate))
|
||||
ICP.set_param('laundry_management.commission_account_id', str(self.laundry_commission_account_id.id or ''))
|
||||
ICP.set_param('laundry_management.require_session', str(self.laundry_require_session))
|
||||
ICP.set_param('laundry_management.cash_journal_id', str(self.laundry_cash_journal_id.id or ''))
|
||||
ICP.set_param('laundry_management.difference_account_id', str(self.laundry_difference_account_id.id or ''))
|
||||
ICP.set_param('laundry_management.default_paper', self.laundry_default_paper or 'a4')
|
||||
54
addons/laundry_management/models/pos_config_ext.py
Normal file
54
addons/laundry_management/models/pos_config_ext.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class PosConfigLaundryExt(models.Model):
|
||||
"""Laundry POS settings — dedicated section in POS configuration.
|
||||
|
||||
All defaults are intentionally conservative: feature OFF until the
|
||||
operator opts in. When `enable_laundry_order_type` is False the
|
||||
popups are skipped entirely — existing POS flow is unchanged.
|
||||
"""
|
||||
_inherit = 'pos.config'
|
||||
|
||||
# ── Order-type / attribute / delivery flow ────────────────────────
|
||||
enable_laundry_order_type = fields.Boolean(
|
||||
string='Ask Laundry Order Type in POS',
|
||||
default=False,
|
||||
)
|
||||
require_laundry_order_type = fields.Boolean(
|
||||
string='Order Type Required',
|
||||
default=False,
|
||||
)
|
||||
ask_laundry_order_type_on_first_line = fields.Boolean(
|
||||
string='Ask on First Laundry Line',
|
||||
default=True,
|
||||
)
|
||||
allow_change_laundry_order_type_before_payment = fields.Boolean(
|
||||
string='Allow Change Before Payment',
|
||||
default=True,
|
||||
)
|
||||
default_laundry_order_type_id = fields.Many2one(
|
||||
'laundry.order.type',
|
||||
string='Default Order Type',
|
||||
)
|
||||
enable_laundry_attributes = fields.Boolean(
|
||||
string='Enable Order Attributes',
|
||||
default=True,
|
||||
)
|
||||
require_delivery_details_if_needed = fields.Boolean(
|
||||
string='Require Delivery Details',
|
||||
default=True,
|
||||
help='Prompt for delivery address / scheduled time when the selected '
|
||||
'type or attributes flag this as a delivery order.',
|
||||
)
|
||||
require_delivery_time = fields.Boolean(
|
||||
string='Require Delivery Time',
|
||||
default=False,
|
||||
help='When enabled, the delivery details popup enforces a scheduled '
|
||||
'time before it can be confirmed. When disabled, cashiers may '
|
||||
'skip the time field and the order is saved without one.',
|
||||
)
|
||||
show_order_type_icons = fields.Boolean(
|
||||
string='Show Icons in POS',
|
||||
default=True,
|
||||
)
|
||||
411
addons/laundry_management/models/pos_order.py
Normal file
411
addons/laundry_management/models/pos_order.py
Normal file
@@ -0,0 +1,411 @@
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PosOrderLaundryExt(models.Model):
|
||||
"""Extend pos.order to:
|
||||
1. Auto-create laundry.order for sales containing laundry products.
|
||||
2. Split POS payments into real cash vs deferred (Customer Account).
|
||||
|
||||
Settlement is NO LONGER a POS order. It is a pure account.payment
|
||||
invoked via res.partner.settle_laundry_dues_rpc — see res_partner.py.
|
||||
|
||||
The `is_laundry_settlement` field and the old settlement-product path
|
||||
are preserved in schema form for historical compatibility with DB rows
|
||||
created before this refactor, but the sync hook no longer creates or
|
||||
processes settlement POS orders.
|
||||
"""
|
||||
_inherit = 'pos.order'
|
||||
|
||||
laundry_order_id = fields.Many2one(
|
||||
'laundry.order', string='Laundry Order',
|
||||
readonly=True, copy=False, index=True,
|
||||
)
|
||||
is_laundry_settlement = fields.Boolean(
|
||||
string='Laundry Settlement (legacy)',
|
||||
readonly=True, copy=False, default=False,
|
||||
help='Legacy flag from the old settlement-product flow. '
|
||||
'New settlements go through account.payment directly and do '
|
||||
'not create POS orders.',
|
||||
)
|
||||
|
||||
# -- Order type / attributes / delivery (set in POS UI) --
|
||||
laundry_order_type_id = fields.Many2one(
|
||||
'laundry.order.type', string='Laundry Order Type',
|
||||
index=True, copy=False,
|
||||
)
|
||||
laundry_order_attribute_ids = fields.Many2many(
|
||||
'laundry.order.attribute',
|
||||
'pos_order_laundry_attribute_rel',
|
||||
'pos_order_id', 'attribute_id',
|
||||
string='Laundry Attributes',
|
||||
copy=False,
|
||||
)
|
||||
laundry_is_delivery = fields.Boolean(
|
||||
string='Laundry Delivery', copy=False,
|
||||
)
|
||||
laundry_delivery_address = fields.Text(
|
||||
string='Laundry Delivery Address', copy=False,
|
||||
)
|
||||
laundry_delivery_scheduled_at = fields.Datetime(
|
||||
string='Laundry Delivery Scheduled At', copy=False,
|
||||
)
|
||||
|
||||
# NOTE: pos.order._load_pos_data_fields is intentionally NOT overridden.
|
||||
# Bisection proved that adding ANY custom field to this loader breaks
|
||||
# POS order construction (`lines is undefined` in _computeAllPrices).
|
||||
# Custom values are sent by serializeForORM (see pos_order_patch.js)
|
||||
# and written directly by core's _process_order via create(**order),
|
||||
# since the columns already exist on this model.
|
||||
|
||||
def action_open_laundry_order(self):
|
||||
self.ensure_one()
|
||||
if not self.laundry_order_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'laundry.order',
|
||||
'res_id': self.laundry_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Sync hook
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@api.model
|
||||
def _extract_pos_order_ids(self, sync_result):
|
||||
"""Defensive extraction of pos.order ids from the value returned
|
||||
by `super().sync_from_ui(...)`. Across Odoo 19 patch levels and
|
||||
edge paths this value can be:
|
||||
|
||||
- a dict {'pos.order': [{'id': N, ...}, ...], ...}
|
||||
- a list of integer ids
|
||||
- a recordset of pos.order (if a downstream module already
|
||||
normalized it)
|
||||
- an empty container of any of the above
|
||||
|
||||
Returns a list of integer pos.order ids. Logs unknown shapes
|
||||
instead of silently dropping them.
|
||||
"""
|
||||
if not sync_result:
|
||||
return []
|
||||
# Recordset → use .ids
|
||||
if isinstance(sync_result, models.BaseModel):
|
||||
return list(sync_result.ids)
|
||||
# Dict payload (canonical in mainline Odoo 19)
|
||||
if isinstance(sync_result, dict):
|
||||
payload = sync_result.get('pos.order', [])
|
||||
ids = []
|
||||
for entry in payload or []:
|
||||
if isinstance(entry, dict) and entry.get('id'):
|
||||
ids.append(entry['id'])
|
||||
elif isinstance(entry, int):
|
||||
ids.append(entry)
|
||||
return ids
|
||||
# Plain list of ids (some patch levels / community forks)
|
||||
if isinstance(sync_result, (list, tuple)):
|
||||
ids = []
|
||||
for entry in sync_result:
|
||||
if isinstance(entry, int):
|
||||
ids.append(entry)
|
||||
elif isinstance(entry, dict) and entry.get('id'):
|
||||
ids.append(entry['id'])
|
||||
return ids
|
||||
_logger.warning(
|
||||
"[laundry] unknown sync_from_ui return shape: %s %r",
|
||||
type(sync_result).__name__, sync_result,
|
||||
)
|
||||
return []
|
||||
|
||||
@api.model
|
||||
def sync_from_ui(self, orders):
|
||||
# CRITICAL: super() runs the entire POS payment commit. The
|
||||
# laundry hand-off MUST be additive and side-effect-free if it
|
||||
# fails — savepoint per pos.order so a SQL-level error cannot
|
||||
# poison the parent transaction.
|
||||
result = super().sync_from_ui(orders)
|
||||
|
||||
order_ids = self._extract_pos_order_ids(result)
|
||||
_logger.info(
|
||||
"[laundry] sync_from_ui post-process: %d pos.order id(s) extracted",
|
||||
len(order_ids),
|
||||
)
|
||||
|
||||
if not order_ids:
|
||||
return result
|
||||
|
||||
# `.exists()` filters out any id the caller provided that isn't
|
||||
# actually in the DB anymore (deleted in a concurrent flow).
|
||||
# `sudo()` is bounded to this internal hand-off — POS users
|
||||
# may not hold full create/write rights on laundry.order, but
|
||||
# the hand-off itself is a server-controlled, validated path.
|
||||
for order in self.browse(order_ids).exists():
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
order.sudo()._maybe_create_laundry_order()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"[laundry] Failed to create/sync laundry.order from "
|
||||
"POS %s (POS sale committed; rolled back laundry-side "
|
||||
"savepoint only)", order.id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
# Same isolation as above. The amount-sync to laundry is a
|
||||
# SECONDARY effect of a POS payment write; it must never be the
|
||||
# reason a payment fails.
|
||||
if 'amount_paid' in vals or 'payment_ids' in vals or 'amount_total' in vals:
|
||||
for order in self:
|
||||
if not order.laundry_order_id:
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
order._sync_laundry_amounts()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'Failed to sync amounts to laundry order %s '
|
||||
'from POS %s (POS write succeeded; rolled back '
|
||||
'laundry-side savepoint only)',
|
||||
order.laundry_order_id.id, order.id,
|
||||
)
|
||||
return res
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Payment classification
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
def _classify_pos_payments(self):
|
||||
"""Return (cash_total, deferred_total) by inspecting pos.payment rows.
|
||||
|
||||
cash_total = Σ amount where method.split_transactions is False
|
||||
deferred_total = Σ amount where method.split_transactions is True
|
||||
"""
|
||||
self.ensure_one()
|
||||
cash_total = 0.0
|
||||
deferred_total = 0.0
|
||||
for pmt in self.payment_ids:
|
||||
method = pmt.payment_method_id
|
||||
if method and method.split_transactions:
|
||||
deferred_total += pmt.amount
|
||||
else:
|
||||
cash_total += pmt.amount
|
||||
return cash_total, deferred_total
|
||||
|
||||
def _sync_laundry_amounts(self):
|
||||
"""Push current financial split to the linked laundry.order.
|
||||
|
||||
amount_total / amount_paid_cash / amount_deferred are all in
|
||||
LOCKED_HEADER_FIELDS — without the POS-sync context bypass the
|
||||
lock guard would block this write on every payment edit.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.laundry_order_id:
|
||||
return
|
||||
cash, deferred = self._classify_pos_payments()
|
||||
self.laundry_order_id.sudo().with_context(
|
||||
laundry_pos_sync=True
|
||||
).write({
|
||||
'amount_total': self.amount_total,
|
||||
'amount_paid_cash': cash,
|
||||
'amount_deferred': deferred,
|
||||
})
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Laundry order creation
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
def _maybe_create_laundry_order(self):
|
||||
"""Create laundry.order if this POS order contains laundry products."""
|
||||
self.ensure_one()
|
||||
_logger.warning(
|
||||
"[laundry-trace] _maybe_create_laundry_order POS %s "
|
||||
"(ref=%s, lines=%d, existing_link=%s)",
|
||||
self.id, self.pos_reference or '-',
|
||||
len(self.lines), self.laundry_order_id.id or False,
|
||||
)
|
||||
|
||||
if self.laundry_order_id:
|
||||
_logger.warning(
|
||||
"[laundry-trace] POS %s already linked to laundry.order %s "
|
||||
"→ syncing amounts only", self.id, self.laundry_order_id.id,
|
||||
)
|
||||
self._sync_laundry_amounts()
|
||||
return
|
||||
|
||||
if not self.lines:
|
||||
_logger.warning("[laundry-trace] POS %s has no lines → skip", self.id)
|
||||
return
|
||||
|
||||
partner = self.partner_id or self.env.company.partner_id
|
||||
|
||||
laundry_lines = []
|
||||
skipped = []
|
||||
for line in self.lines:
|
||||
tmpl = line.product_id.product_tmpl_id if line.product_id else None
|
||||
if not tmpl:
|
||||
skipped.append((line.id, 'no template'))
|
||||
continue
|
||||
if not tmpl.is_laundry_service:
|
||||
skipped.append((line.id, f'not laundry ({tmpl.name})'))
|
||||
continue
|
||||
laundry_lines.append((0, 0, {
|
||||
'product_id': line.product_id.id,
|
||||
'description': line.full_product_name or line.product_id.name,
|
||||
'qty': line.qty,
|
||||
'price_unit': line.price_unit,
|
||||
'customer_note': line.customer_note or '',
|
||||
}))
|
||||
|
||||
_logger.warning(
|
||||
"[laundry-trace] POS %s line scan: %d laundry, %d skipped %s",
|
||||
self.id, len(laundry_lines), len(skipped), skipped[:5],
|
||||
)
|
||||
|
||||
if not laundry_lines:
|
||||
_logger.warning(
|
||||
"[laundry-trace] POS %s has no laundry-service lines → skip "
|
||||
"(this is the most common cause of 'no laundry order created')",
|
||||
self.id,
|
||||
)
|
||||
return
|
||||
|
||||
LaundryOrder = self.env['laundry.order']
|
||||
existing = LaundryOrder.search(
|
||||
[('pos_order_id', '=', self.id)], limit=1,
|
||||
)
|
||||
if existing:
|
||||
self.laundry_order_id = existing.id
|
||||
self._sync_laundry_amounts()
|
||||
return
|
||||
|
||||
cash, deferred = self._classify_pos_payments()
|
||||
create_vals = {
|
||||
'pos_order_id': self.id,
|
||||
'pos_reference': self.pos_reference or '',
|
||||
'partner_id': partner.id,
|
||||
'company_id': self.company_id.id,
|
||||
# Phase 3: every laundry.order born from POS is hard-locked
|
||||
# by `source_type`. Header lock + line lock both kick in
|
||||
# immediately. The POS-sync context bypass below lets the
|
||||
# initial create + line writes go through.
|
||||
'source_type': 'pos',
|
||||
'amount_total': self.amount_total,
|
||||
'amount_paid_cash': cash,
|
||||
'amount_deferred': deferred,
|
||||
'notes': self.general_customer_note or '',
|
||||
'line_ids': laundry_lines,
|
||||
}
|
||||
# Propagate order type / attributes / delivery — inference in
|
||||
# laundry.order.create fills priority_level and is_delivery from
|
||||
# these when not explicitly provided.
|
||||
if self.laundry_order_type_id:
|
||||
create_vals['order_type_id'] = self.laundry_order_type_id.id
|
||||
if self.laundry_order_attribute_ids:
|
||||
create_vals['attribute_ids'] = [(6, 0, self.laundry_order_attribute_ids.ids)]
|
||||
if self.laundry_is_delivery:
|
||||
create_vals['is_delivery'] = True
|
||||
if self.laundry_delivery_address:
|
||||
create_vals['delivery_address'] = self.laundry_delivery_address
|
||||
if self.laundry_delivery_scheduled_at:
|
||||
create_vals['delivery_scheduled_at'] = self.laundry_delivery_scheduled_at
|
||||
|
||||
laundry_order = LaundryOrder.with_context(
|
||||
laundry_pos_sync=True
|
||||
).create(create_vals)
|
||||
self.laundry_order_id = laundry_order.id
|
||||
|
||||
_logger.info(
|
||||
'Created laundry order %s from POS %s: total=%.2f, cash=%.2f, deferred=%.2f',
|
||||
laundry_order.name, self.pos_reference or self.id,
|
||||
self.amount_total, cash, deferred,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Integrity audit — Step 4 of the stabilization brief
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@api.model
|
||||
def audit_laundry_links(self, heal=False):
|
||||
"""Report (and optionally heal) POS orders that should have a
|
||||
linked laundry.order but don't.
|
||||
|
||||
Returns a dict:
|
||||
{
|
||||
'checked': int,
|
||||
'missing': [{pos_order_id, pos_reference, partner_name,
|
||||
amount_total}, ...],
|
||||
'duplicates': [pos_order_id, ...], # pos.order pointing to
|
||||
# a laundry.order that no
|
||||
# longer exists
|
||||
'healed': int, # populated only if heal=True
|
||||
}
|
||||
|
||||
A POS order is "should-link" when it has at least one line whose
|
||||
product_template is flagged is_laundry_service AND is NOT the
|
||||
settlement product. This is exactly the same condition
|
||||
_maybe_create_laundry_order applies, so the audit and the live
|
||||
hand-off agree.
|
||||
|
||||
Heal mode re-runs `_maybe_create_laundry_order` for missing
|
||||
rows, each in its own savepoint — same isolation guarantee as
|
||||
the live hand-off. Safe to call repeatedly.
|
||||
"""
|
||||
Order = self.env['pos.order']
|
||||
all_orders = Order.search([])
|
||||
missing = []
|
||||
duplicates = []
|
||||
for o in all_orders:
|
||||
has_laundry_line = any(
|
||||
line.product_id.product_tmpl_id.is_laundry_service
|
||||
and not line.product_id.product_tmpl_id.is_laundry_settlement
|
||||
for line in o.lines
|
||||
if line.product_id and line.product_id.product_tmpl_id
|
||||
)
|
||||
if not has_laundry_line:
|
||||
continue
|
||||
if o.laundry_order_id and not o.laundry_order_id.exists():
|
||||
# Stored fk to a deleted laundry.order — orphan link
|
||||
duplicates.append(o.id)
|
||||
continue
|
||||
if not o.laundry_order_id:
|
||||
missing.append({
|
||||
'pos_order_id': o.id,
|
||||
'pos_reference': o.pos_reference or '',
|
||||
'partner_name': o.partner_id.name if o.partner_id else '',
|
||||
'amount_total': o.amount_total,
|
||||
})
|
||||
|
||||
healed = 0
|
||||
if heal:
|
||||
for entry in missing:
|
||||
pos_order = Order.browse(entry['pos_order_id'])
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
pos_order._maybe_create_laundry_order()
|
||||
if pos_order.laundry_order_id:
|
||||
healed += 1
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'audit_laundry_links: heal failed for POS %s',
|
||||
pos_order.id,
|
||||
)
|
||||
# Clear orphan fk pointers (the linked laundry.order was deleted)
|
||||
for pos_id in duplicates:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
Order.browse(pos_id).laundry_order_id = False
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'audit_laundry_links: orphan-clear failed for POS %s',
|
||||
pos_id,
|
||||
)
|
||||
|
||||
return {
|
||||
'checked': len(all_orders),
|
||||
'missing': missing,
|
||||
'duplicates': duplicates,
|
||||
'healed': healed,
|
||||
}
|
||||
22
addons/laundry_management/models/pos_session_ext.py
Normal file
22
addons/laundry_management/models/pos_session_ext.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""POS session extension for laundry.
|
||||
|
||||
Cash settlements use account.bank.statement.line tagged with
|
||||
pos_session_id, so POS's native _compute_cash_balance picks them up
|
||||
automatically — no display/math override is required.
|
||||
|
||||
We only override _load_pos_data_models to ship our two new configuration
|
||||
models (laundry.order.type, laundry.order.attribute) to the POS client.
|
||||
"""
|
||||
import logging
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PosSessionLaundryExt(models.Model):
|
||||
_inherit = 'pos.session'
|
||||
|
||||
def _load_pos_data_models(self, config):
|
||||
models_to_load = super()._load_pos_data_models(config)
|
||||
models_to_load += ['laundry.order.type', 'laundry.order.attribute']
|
||||
return models_to_load
|
||||
33
addons/laundry_management/models/product_template_ext.py
Normal file
33
addons/laundry_management/models/product_template_ext.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class ProductTemplateExt(models.Model):
|
||||
"""Extend product.template with the two flags the laundry workflow needs.
|
||||
|
||||
is_laundry_service: marks products as laundry services. POS sales of
|
||||
these products auto-create / link a laundry.order.
|
||||
is_laundry_settlement: internal flag for the dedicated settlement
|
||||
product used to collect outstanding laundry dues without
|
||||
generating revenue lines.
|
||||
"""
|
||||
_inherit = 'product.template'
|
||||
|
||||
is_laundry_service = fields.Boolean(
|
||||
string='Laundry Service',
|
||||
default=False,
|
||||
help='Tick to include this product in the Laundry Service Catalog '
|
||||
'and allow selection on laundry order lines.',
|
||||
)
|
||||
is_laundry_settlement = fields.Boolean(
|
||||
string='Laundry Settlement',
|
||||
default=False,
|
||||
help='Internal flag for the settlement product. '
|
||||
'Do not enable on regular products.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _load_pos_data_fields(self, config_id):
|
||||
fields = super()._load_pos_data_fields(config_id)
|
||||
fields.append('is_laundry_service')
|
||||
fields.append('is_laundry_settlement')
|
||||
return fields
|
||||
513
addons/laundry_management/models/res_partner.py
Normal file
513
addons/laundry_management/models/res_partner.py
Normal file
@@ -0,0 +1,513 @@
|
||||
import logging
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResPartnerLaundry(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
laundry_unpaid_count = fields.Integer(
|
||||
string='Unpaid Laundry Orders',
|
||||
compute='_compute_laundry_unpaid_count',
|
||||
)
|
||||
|
||||
# Customer defaults — seed values only; cashier can override in POS.
|
||||
default_laundry_order_type_id = fields.Many2one(
|
||||
'laundry.order.type', string='Default Laundry Order Type',
|
||||
)
|
||||
default_laundry_attribute_ids = fields.Many2many(
|
||||
'laundry.order.attribute',
|
||||
'res_partner_laundry_default_attr_rel',
|
||||
'partner_id', 'attribute_id',
|
||||
string='Default Laundry Attributes',
|
||||
)
|
||||
|
||||
def _compute_laundry_unpaid_count(self):
|
||||
if not self.ids:
|
||||
self.laundry_unpaid_count = 0
|
||||
return
|
||||
self.env.cr.execute("""
|
||||
SELECT partner_id, COUNT(*)
|
||||
FROM laundry_order
|
||||
WHERE partner_id IN %s
|
||||
AND amount_due > 0
|
||||
AND state != 'delivered'
|
||||
GROUP BY partner_id
|
||||
""", [tuple(self.ids)])
|
||||
counts = dict(self.env.cr.fetchall())
|
||||
for partner in self:
|
||||
partner.laundry_unpaid_count = counts.get(partner.id, 0)
|
||||
|
||||
@api.model
|
||||
def _load_pos_data_fields(self, config):
|
||||
fields = super()._load_pos_data_fields(config)
|
||||
fields.append('laundry_unpaid_count')
|
||||
fields.append('default_laundry_order_type_id')
|
||||
fields.append('default_laundry_attribute_ids')
|
||||
return fields
|
||||
|
||||
@api.model
|
||||
def laundry_find_by_phone(self, phone):
|
||||
"""Search for existing partner by phone. Returns dict or False."""
|
||||
phone_clean = phone.strip().replace(' ', '')
|
||||
if not phone_clean:
|
||||
return False
|
||||
domain = [('phone', 'ilike', phone_clean)]
|
||||
if 'mobile' in self._fields:
|
||||
domain = ['|', ('phone', 'ilike', phone_clean), ('mobile', 'ilike', phone_clean)]
|
||||
partner = self.search(domain, limit=1)
|
||||
if partner:
|
||||
phone_val = partner.phone or ''
|
||||
if 'mobile' in self._fields and partner.mobile:
|
||||
phone_val = phone_val or partner.mobile
|
||||
return {'id': partner.id, 'name': partner.name, 'phone': phone_val}
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def laundry_quick_create(self, vals):
|
||||
"""Create partner from POS quick-create popup. Returns partner id.
|
||||
|
||||
UX contract:
|
||||
- phone is required (the JS popup enforces this; we also guard).
|
||||
- name is optional. If empty, the partner name defaults to the
|
||||
phone number — keeps lists scannable when a cashier doesn't
|
||||
have time to type a name.
|
||||
- mobile (when the field exists in this Odoo install) is mirrored
|
||||
from phone so duplicate-search by phone OR mobile keeps working.
|
||||
"""
|
||||
phone = (vals.get('phone') or '').strip()
|
||||
if not phone:
|
||||
raise UserError(_('Phone is required to create a customer.'))
|
||||
name = (vals.get('name') or '').strip() or phone
|
||||
street = (vals.get('street') or '').strip() or False
|
||||
create_vals = {'name': name, 'phone': phone, 'street': street}
|
||||
# `mobile` is provided by the optional phone add-on. Mirror only
|
||||
# when the field exists, so this stays portable across Odoo
|
||||
# distributions without bringing in extra deps.
|
||||
if 'mobile' in self._fields:
|
||||
create_vals['mobile'] = phone
|
||||
partner = self.create(create_vals)
|
||||
return partner.id
|
||||
|
||||
@api.model
|
||||
def get_laundry_dues(self, partner_id):
|
||||
"""Return outstanding laundry dues for a partner (read-only).
|
||||
|
||||
`amount_due` is now `amount_deferred - amount_settled` — the real
|
||||
money still owed — not `total - paid`.
|
||||
"""
|
||||
orders = self.env['laundry.order'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
('amount_due', '>', 0),
|
||||
('state', '!=', 'delivered'),
|
||||
], order='create_date asc, id asc')
|
||||
order_details = []
|
||||
total_due = 0.0
|
||||
for o in orders:
|
||||
total_due += o.amount_due
|
||||
services = o.line_ids.mapped('product_id.name')
|
||||
service_summary = ', '.join(dict.fromkeys(services))[:80]
|
||||
order_details.append({
|
||||
'id': o.id,
|
||||
'name': o.name,
|
||||
'amount_due': o.amount_due,
|
||||
'amount_total': o.amount_total,
|
||||
'amount_paid_cash': o.amount_paid_cash,
|
||||
'amount_deferred': o.amount_deferred,
|
||||
'amount_settled': o.amount_settled,
|
||||
'state': o.state,
|
||||
'intake_date': fields.Datetime.to_string(o.create_date) if o.create_date else False,
|
||||
'item_count': o.item_count,
|
||||
'service_summary': service_summary or '',
|
||||
})
|
||||
return {
|
||||
'total_due': total_due,
|
||||
'order_count': len(orders),
|
||||
'orders': order_details,
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Settlement engine
|
||||
# CASH → account.bank.statement.line (Dr Cash / Cr Receivable)
|
||||
# + immediate reconcile vs open AR debits (FIFO)
|
||||
# → POS expected cash auto-includes it via statement_line_ids
|
||||
# NON-CASH → account.payment (Dr Bank / Cr Receivable) + reconcile
|
||||
# → drawer untouched, no duplication risk
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@api.model
|
||||
def settle_laundry_dues_rpc(self, partner_id, payment_lines, pos_session_id=None):
|
||||
"""Collect a laundry receivable via one or more journal entries.
|
||||
|
||||
Per-line routing by journal type — see module docstring above.
|
||||
|
||||
Args:
|
||||
partner_id: res.partner id.
|
||||
payment_lines: list of dicts, each with:
|
||||
- pos_payment_method_id: pos.payment.method id
|
||||
- amount: float > 0
|
||||
pos_session_id: optional pos.session id. For CASH it tags the
|
||||
statement line so POS includes the amount in expected cash.
|
||||
For NON-CASH it's stamped on account.payment for audit.
|
||||
|
||||
Returns:
|
||||
dict with: settled_total, remaining_due, payments[], settled_orders[].
|
||||
"""
|
||||
partner = self.browse(partner_id)
|
||||
if not partner.exists():
|
||||
raise UserError(_('Customer not found.'))
|
||||
|
||||
if not payment_lines or not isinstance(payment_lines, list):
|
||||
raise UserError(_('At least one payment line is required.'))
|
||||
|
||||
normalized = []
|
||||
total = 0.0
|
||||
for idx, line in enumerate(payment_lines, start=1):
|
||||
if not isinstance(line, dict):
|
||||
raise UserError(_('Payment line %s is malformed.', idx))
|
||||
method_id = line.get('pos_payment_method_id')
|
||||
if not method_id:
|
||||
raise UserError(_('Payment line %s has no payment method.', idx))
|
||||
try:
|
||||
amt = float(line.get('amount') or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
raise UserError(_('Payment line %s has an invalid amount.', idx))
|
||||
if amt <= 0:
|
||||
raise UserError(_('Payment line %s must have a positive amount.', idx))
|
||||
journal = self._resolve_settlement_journal(method_id)
|
||||
normalized.append({
|
||||
'method_id': int(method_id),
|
||||
'amount': amt,
|
||||
'journal': journal,
|
||||
})
|
||||
total += amt
|
||||
|
||||
session = None
|
||||
if pos_session_id:
|
||||
session = self.env['pos.session'].browse(int(pos_session_id))
|
||||
if not session.exists():
|
||||
session = None
|
||||
|
||||
receipt_entries = []
|
||||
for line in normalized:
|
||||
journal = line['journal']
|
||||
method = self.env['pos.payment.method'].browse(line['method_id'])
|
||||
if journal.type == 'cash':
|
||||
stmt_line = self._create_cash_settlement_statement_line(
|
||||
partner, journal, line['amount'], session,
|
||||
)
|
||||
self._reconcile_statement_line_to_receivable(stmt_line, partner)
|
||||
receipt_entries.append({
|
||||
'id': stmt_line.id,
|
||||
'name': stmt_line.move_id.name or stmt_line.payment_ref,
|
||||
'state': stmt_line.move_id.state,
|
||||
'amount': stmt_line.amount,
|
||||
'journal_name': journal.name,
|
||||
'journal_type': 'cash',
|
||||
'method_name': method.name or journal.name,
|
||||
})
|
||||
else:
|
||||
pmt = self._create_settlement_account_payment(
|
||||
partner, journal, line['amount'], line['method_id'], session,
|
||||
)
|
||||
receipt_entries.append({
|
||||
'id': pmt.id,
|
||||
'name': pmt.name,
|
||||
'state': pmt.state,
|
||||
'amount': pmt.amount,
|
||||
'journal_name': pmt.journal_id.name,
|
||||
'journal_type': pmt.journal_id.type,
|
||||
'method_name': pmt.settlement_pos_pm_id.name or pmt.journal_id.name,
|
||||
})
|
||||
|
||||
settled_orders, remaining_due = self._distribute_settlement_fifo(partner, total)
|
||||
|
||||
_logger.info(
|
||||
'Settlement RPC: partner=%s total=%.2f lines=%d remaining=%.2f',
|
||||
partner.name, total, len(receipt_entries), remaining_due,
|
||||
)
|
||||
|
||||
return {
|
||||
'settled_total': total,
|
||||
'remaining_due': remaining_due,
|
||||
'payments': receipt_entries,
|
||||
'settled_orders': settled_orders,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _create_cash_settlement_statement_line(self, partner, journal, amount, session):
|
||||
"""Create a POS-tagged cash-in statement line that posts directly
|
||||
Dr Cash / Cr Receivable.
|
||||
|
||||
`counterpart_account_id` is consumed by account.bank.statement.line.create
|
||||
to override the journal's default suspense account — so the credit
|
||||
leg lands on the partner's receivable account, ready for reconcile.
|
||||
The statement line auto-posts its move (Odoo handles action_post).
|
||||
|
||||
Tagging `pos_session_id` makes the amount flow into POS's native
|
||||
expected-cash formula (statement_line_ids sum) → no closing diff.
|
||||
"""
|
||||
receivable = partner.property_account_receivable_id
|
||||
if not receivable:
|
||||
raise UserError(_(
|
||||
'Customer "%s" has no receivable account configured.',
|
||||
partner.name,
|
||||
))
|
||||
vals = {
|
||||
'journal_id': journal.id,
|
||||
'amount': amount,
|
||||
'partner_id': partner.id,
|
||||
'payment_ref': _('Customer Settlement (POS) — %s', partner.name),
|
||||
'counterpart_account_id': receivable.id,
|
||||
'date': fields.Date.context_today(self),
|
||||
}
|
||||
if session:
|
||||
vals['pos_session_id'] = session.id
|
||||
try:
|
||||
return self.env['account.bank.statement.line'].sudo().create(vals)
|
||||
except Exception as e:
|
||||
_logger.exception(
|
||||
'Failed to create cash settlement statement line for partner %s',
|
||||
partner.id,
|
||||
)
|
||||
raise UserError(_('Failed to record cash settlement: %s', e))
|
||||
|
||||
@api.model
|
||||
def _reconcile_statement_line_to_receivable(self, stmt_line, partner):
|
||||
"""Reconcile the statement line's receivable credit against the
|
||||
partner's open receivable debits (FIFO).
|
||||
|
||||
Both sides live on the partner's receivable account, so a direct
|
||||
AML.reconcile() fully settles the customer due in one step.
|
||||
"""
|
||||
receivable = partner.property_account_receivable_id
|
||||
if not receivable:
|
||||
return
|
||||
counterpart_credits = stmt_line.move_id.line_ids.filtered(
|
||||
lambda l: l.account_id == receivable
|
||||
and l.credit > 0
|
||||
and not l.reconciled
|
||||
)
|
||||
if not counterpart_credits:
|
||||
return
|
||||
AML = self.env['account.move.line']
|
||||
open_debits = AML.search([
|
||||
('partner_id', '=', partner.id),
|
||||
('account_id', '=', receivable.id),
|
||||
('reconciled', '=', False),
|
||||
('parent_state', '=', 'posted'),
|
||||
('debit', '>', 0),
|
||||
], order='date asc, id asc')
|
||||
if not open_debits:
|
||||
return
|
||||
try:
|
||||
(counterpart_credits | open_debits).reconcile()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'Reconciliation failed for cash settlement statement line %s',
|
||||
stmt_line.id,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _create_settlement_account_payment(
|
||||
self, partner, journal, amount, method_id, session,
|
||||
):
|
||||
"""Non-cash settlement: standard account.payment + reconcile."""
|
||||
Payment = self.env['account.payment']
|
||||
pmt_vals = {
|
||||
'payment_type': 'inbound',
|
||||
'partner_type': 'customer',
|
||||
'partner_id': partner.id,
|
||||
'amount': amount,
|
||||
'journal_id': journal.id,
|
||||
'currency_id': (journal.currency_id or self.env.company.currency_id).id,
|
||||
'date': fields.Date.context_today(self),
|
||||
'memo': _('Laundry dues settlement — %s', partner.name),
|
||||
'settlement_pos_pm_id': method_id,
|
||||
}
|
||||
if session:
|
||||
pmt_vals['pos_session_id'] = session.id
|
||||
pmt = Payment.sudo().create(pmt_vals)
|
||||
try:
|
||||
pmt.action_post()
|
||||
except Exception as e:
|
||||
_logger.exception(
|
||||
'Failed to post settlement payment for partner %s', partner.id,
|
||||
)
|
||||
raise UserError(_('Failed to post payment: %s', e))
|
||||
self._reconcile_settlement_payment(pmt, partner)
|
||||
return pmt
|
||||
|
||||
@api.model
|
||||
def _resolve_settlement_journal(self, pos_payment_method_id):
|
||||
"""Pick the account.journal for a settlement payment.
|
||||
|
||||
1. If a pos.payment.method id is given, enforce split_transactions=False
|
||||
and return its journal.
|
||||
2. Else return the company's default cash journal, then bank journal.
|
||||
Raises UserError if no valid journal can be found.
|
||||
"""
|
||||
if pos_payment_method_id:
|
||||
method = self.env['pos.payment.method'].browse(int(pos_payment_method_id))
|
||||
if not method.exists():
|
||||
raise UserError(_('Selected payment method not found.'))
|
||||
if method.split_transactions:
|
||||
raise ValidationError(_(
|
||||
'Customer Account / pay-later methods cannot be used to '
|
||||
'settle laundry dues — they would create new receivable '
|
||||
'instead of reducing it.'
|
||||
))
|
||||
if not method.journal_id:
|
||||
raise UserError(_(
|
||||
'Payment method "%s" has no journal configured.',
|
||||
method.name,
|
||||
))
|
||||
return method.journal_id
|
||||
|
||||
Journal = self.env['account.journal']
|
||||
j = Journal.search([
|
||||
('company_id', '=', self.env.company.id),
|
||||
('type', '=', 'cash'),
|
||||
], limit=1)
|
||||
if not j:
|
||||
j = Journal.search([
|
||||
('company_id', '=', self.env.company.id),
|
||||
('type', '=', 'bank'),
|
||||
], limit=1)
|
||||
if not j:
|
||||
raise UserError(_('No cash or bank journal available for settlement.'))
|
||||
return j
|
||||
|
||||
@api.model
|
||||
def _reconcile_settlement_payment(self, payment, partner):
|
||||
"""Reconcile the payment's partner-receivable line against open
|
||||
receivable AMLs from prior customer-account POS sales.
|
||||
|
||||
Journals without an outstanding-receipts account post directly to the
|
||||
partner receivable (Dr Bank / Cr Receivable, state=paid). Journals
|
||||
configured with an outstanding-receipts account post to it first
|
||||
(Dr Bank / Cr Outstanding Receipts, state=in_process) — no partner
|
||||
receivable line yet, so there's nothing to reconcile here until the
|
||||
bank statement clears. This method safely no-ops in that case.
|
||||
"""
|
||||
receivable_account = partner.property_account_receivable_id
|
||||
if not receivable_account:
|
||||
_logger.warning('Partner %s has no receivable account', partner.id)
|
||||
return
|
||||
|
||||
AML = self.env['account.move.line']
|
||||
payment_lines = payment.move_id.line_ids.filtered(
|
||||
lambda l: l.account_id == receivable_account and not l.reconciled
|
||||
)
|
||||
if not payment_lines:
|
||||
return # state=in_process — reconciliation happens at bank clearing
|
||||
|
||||
open_debits = AML.search([
|
||||
('partner_id', '=', partner.id),
|
||||
('account_id', '=', receivable_account.id),
|
||||
('reconciled', '=', False),
|
||||
('parent_state', '=', 'posted'),
|
||||
('debit', '>', 0),
|
||||
], order='date asc, id asc')
|
||||
if not open_debits:
|
||||
return
|
||||
|
||||
try:
|
||||
(payment_lines | open_debits).reconcile()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'Reconciliation failed for settlement payment %s', payment.name,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _distribute_settlement_fifo(self, partner, amount):
|
||||
"""Spread an already-collected amount across the partner's open
|
||||
laundry orders, oldest first. Returns (settled_orders, remaining_due).
|
||||
"""
|
||||
LaundryOrder = self.env['laundry.order']
|
||||
open_orders = LaundryOrder.search([
|
||||
('partner_id', '=', partner.id),
|
||||
('amount_due', '>', 0),
|
||||
('state', '!=', 'delivered'),
|
||||
], order='create_date asc, id asc')
|
||||
|
||||
remaining = amount
|
||||
settled_orders = []
|
||||
for lo in open_orders:
|
||||
if remaining <= 0:
|
||||
break
|
||||
apply_amount = min(remaining, lo.amount_due)
|
||||
# Defense-in-depth: `amount_settled` is not in
|
||||
# LOCKED_HEADER_FIELDS so this write already sails through,
|
||||
# but we pass laundry_pos_sync=True to guarantee the bypass
|
||||
# even if the locked-field whitelist changes later.
|
||||
lo.sudo().with_context(laundry_pos_sync=True).write({
|
||||
'amount_settled': (lo.amount_settled or 0.0) + apply_amount,
|
||||
})
|
||||
remaining -= apply_amount
|
||||
settled_orders.append({
|
||||
'id': lo.id,
|
||||
'name': lo.name,
|
||||
'applied': apply_amount,
|
||||
'remaining_on_order': lo.amount_due,
|
||||
})
|
||||
|
||||
total_due = sum(
|
||||
LaundryOrder.search([
|
||||
('partner_id', '=', partner.id),
|
||||
('amount_due', '>', 0),
|
||||
('state', '!=', 'delivered'),
|
||||
]).mapped('amount_due')
|
||||
)
|
||||
return settled_orders, total_due
|
||||
|
||||
@api.model
|
||||
def get_session_settlements(self, pos_session_id):
|
||||
"""Return laundry settlement payments stamped on a POS session.
|
||||
|
||||
Used by the closing-screen extension to show a read-only summary
|
||||
of settlement collections made during this session. Does NOT inject
|
||||
into POS cash-control totals.
|
||||
"""
|
||||
Payment = self.env['account.payment']
|
||||
payments = Payment.sudo().search([
|
||||
('pos_session_id', '=', int(pos_session_id)),
|
||||
('payment_type', '=', 'inbound'),
|
||||
('partner_type', '=', 'customer'),
|
||||
], order='date asc, id asc')
|
||||
|
||||
by_journal = {}
|
||||
for p in payments:
|
||||
jname = p.journal_id.name
|
||||
jtype = p.journal_id.type # 'cash' or 'bank'
|
||||
key = jname
|
||||
if key not in by_journal:
|
||||
by_journal[key] = {'name': jname, 'type': jtype, 'total': 0.0}
|
||||
by_journal[key]['total'] += p.amount
|
||||
|
||||
return {
|
||||
'total': sum(p.amount for p in payments),
|
||||
'count': len(payments),
|
||||
'by_journal': list(by_journal.values()),
|
||||
'payments': [{
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'partner_name': p.partner_id.name,
|
||||
'amount': p.amount,
|
||||
'journal_name': p.journal_id.name,
|
||||
'date': fields.Date.to_string(p.date),
|
||||
} for p in payments],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def get_laundry_orders_for_pos(self, partner_id, limit=20):
|
||||
"""Legacy shim — delegates to the canonical method on laundry.order.
|
||||
Kept so older POS bundles that still call this RPC keep working.
|
||||
New code should call `laundry.order.pos_search_customer_orders`
|
||||
directly; it supports a `search_query` parameter and returns a
|
||||
richer payload (payment_state, allowed_actions, …).
|
||||
"""
|
||||
return self.env['laundry.order'].pos_search_customer_orders(
|
||||
partner_id=partner_id, limit=limit,
|
||||
)
|
||||
139
addons/laundry_management/models/sale_advance_payment.py
Normal file
139
addons/laundry_management/models/sale_advance_payment.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
#
|
||||
# Cybrosys Technologies Pvt. Ltd.
|
||||
#
|
||||
# Copyright (C) 2026-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
||||
# Author: Anaswara S (odoo@cybrosys.com)
|
||||
#
|
||||
# You can modify it under the terms of the GNU AFFERO
|
||||
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
# (AGPL v3) along with this program.
|
||||
# If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
import time
|
||||
from odoo import models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class SaleAdvancePaymentInv(models.TransientModel):
|
||||
"""Inheriting the model of sale.advance.payment.inv to generate advance
|
||||
payment of invoice"""
|
||||
_inherit = 'sale.advance.payment.inv'
|
||||
|
||||
def create_invoices(self):
|
||||
"""Function for creating invoices for the advance payment."""
|
||||
laundry_sale_id = self._context.get('laundry_sale_id')
|
||||
sale_order = self.env['sale.order']
|
||||
if laundry_sale_id:
|
||||
sale_orders = sale_order.browse(laundry_sale_id)
|
||||
else:
|
||||
sale_orders = sale_order.browse(
|
||||
self._context.get('active_ids', []))
|
||||
if self.advance_payment_method == 'delivered':
|
||||
sale_orders._create_invoices()
|
||||
elif self.advance_payment_method == 'all':
|
||||
sale_orders._create_invoices()(final=True)
|
||||
else:
|
||||
# Create deposit product if necessary
|
||||
if not self.product_id:
|
||||
vals = self._prepare_deposit_product()
|
||||
self.product_id = self.env['product.product'].create(vals)
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'sale.default_deposit_product_id', self.product_id.id)
|
||||
for order in sale_orders:
|
||||
if self.advance_payment_method == 'percentage':
|
||||
amount = order.amount_untaxed * self.amount / 100
|
||||
else:
|
||||
amount = self.amount
|
||||
if self.product_id.invoice_policy != 'order':
|
||||
raise UserError(_(
|
||||
'The product used to invoice a down payment should have'
|
||||
' an invoice policy set to "Ordered'
|
||||
' quantities". Please update your deposit product to be'
|
||||
' able to create a deposit invoice.'))
|
||||
if self.product_id.type != 'service':
|
||||
raise UserError(_(
|
||||
"The product used to invoice a down payment should be"
|
||||
" of type 'Service'. Please use another "
|
||||
"product or update this product."))
|
||||
taxes = self.product_id.taxes_id.filtered(
|
||||
lambda
|
||||
r: not order.company_id or r.company_id ==
|
||||
order.company_id)
|
||||
if order.fiscal_position_id and taxes:
|
||||
tax_ids = order.fiscal_position_id.map_tax(taxes).ids
|
||||
else:
|
||||
tax_ids = taxes.ids
|
||||
so_line = self.env['sale.order.line'].create({
|
||||
'name': _('Advance: %s') % (time.strftime('%m %Y'),),
|
||||
'price_unit': amount,
|
||||
'product_uom_qty': 0.0,
|
||||
'order_id': order.id,
|
||||
'discount': 0.0,
|
||||
'product_uom': self.product_id.uom_id.id,
|
||||
'product_id': self.product_id.id,
|
||||
'tax_id': [(6, 0, tax_ids)],
|
||||
})
|
||||
self._create_invoice(order, so_line, amount)
|
||||
if self._context.get('open_invoices', False):
|
||||
return sale_orders.action_view_invoice()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def _create_invoice(self, order, so_line):
|
||||
"""Function for creating invoice"""
|
||||
if (self.advance_payment_method == 'percentage' and
|
||||
self.amount <= 0.00) or (self.advance_payment_method == 'fixed' and
|
||||
self.fixed_amount <= 0.00):
|
||||
raise UserError(
|
||||
_('The value of the down payment amount must be positive.'))
|
||||
if self.advance_payment_method == 'percentage':
|
||||
amount = order.amount_untaxed * self.amount / 100
|
||||
name = _("Down payment of %s%%") % (self.amount,)
|
||||
else:
|
||||
amount = self.fixed_amount
|
||||
name = _('Down Payment')
|
||||
|
||||
invoice_vals = {
|
||||
'move_type': 'out_invoice',
|
||||
'invoice_origin': order.name,
|
||||
'invoice_user_id': order.user_id.id,
|
||||
'narration': order.note,
|
||||
'partner_id': order.partner_invoice_id.id,
|
||||
'fiscal_position_id': order.fiscal_position_id.id or order.
|
||||
partner_id.property_account_position_id.id,
|
||||
'partner_shipping_id': order.partner_shipping_id.id,
|
||||
'currency_id': order.pricelist_id.currency_id.id,
|
||||
'ref': order.client_order_ref,
|
||||
'invoice_payment_term_id': order.payment_term_id.id,
|
||||
'team_id': order.team_id.id,
|
||||
'campaign_id': order.campaign_id.id,
|
||||
'medium_id': order.medium_id.id,
|
||||
'source_id': order.source_id.id,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': name,
|
||||
'price_unit': amount,
|
||||
'quantity': 1.0,
|
||||
'product_id': self.product_id.id,
|
||||
'product_uom_id': so_line.product_uom.id,
|
||||
'sale_line_ids': [(6, 0, [so_line.id])],
|
||||
'analytic_tag_ids': [(6, 0, so_line.analytic_tag_ids.ids)],
|
||||
'analytic_account_id': order.analytic_account_id.id or False,
|
||||
})],
|
||||
}
|
||||
if order.fiscal_position_id:
|
||||
invoice_vals['fiscal_position_id'] = order.fiscal_position_id.id
|
||||
invoice = self.env['account.move'].create(invoice_vals)
|
||||
invoice.message_post_with_view('mail.message_origin_link',
|
||||
values={'self': invoice,
|
||||
'origin': order},
|
||||
subtype_id=self.env.ref(
|
||||
'mail.mt_note').id)
|
||||
37
addons/laundry_management/models/washing_type.py
Normal file
37
addons/laundry_management/models/washing_type.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
#
|
||||
# Cybrosys Technologies Pvt. Ltd.
|
||||
#
|
||||
# Copyright (C) 2026-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
||||
# Author: Anaswara S (odoo@cybrosys.com)
|
||||
#
|
||||
# You can modify it under the terms of the GNU AFFERO
|
||||
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
# (AGPL v3) along with this program.
|
||||
# If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class WashingType(models.Model):
|
||||
"""Washing types generating model"""
|
||||
_name = 'washing.type'
|
||||
_description = "Washing TYpe"
|
||||
|
||||
name = fields.Char(string='Name', required=True,
|
||||
help='Name of Washing type.')
|
||||
assigned_person_id = fields.Many2one('res.users',
|
||||
string='Assigned Person',
|
||||
required=True,
|
||||
help="Name of assigned person")
|
||||
amount = fields.Float(string='Service Charge', required=True,
|
||||
help='Service charge of this type')
|
||||
141
addons/laundry_management/models/washing_washing.py
Normal file
141
addons/laundry_management/models/washing_washing.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
#
|
||||
# Cybrosys Technologies Pvt. Ltd.
|
||||
#
|
||||
# Copyright (C) 2026-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
||||
# Author: Anaswara S (odoo@cybrosys.com)
|
||||
#
|
||||
# You can modify it under the terms of the GNU AFFERO
|
||||
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
# (AGPL v3) along with this program.
|
||||
# If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
from datetime import datetime
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class WashingWashing(models.Model):
|
||||
"""Washing activity generating model"""
|
||||
_name = 'washing.washing'
|
||||
_description = 'Washing Washing'
|
||||
|
||||
name = fields.Char(string='Work', help='Mention the work')
|
||||
laundry_works = fields.Boolean(default=False, help='For set conditions')
|
||||
user_id = fields.Many2one('res.users',
|
||||
string='Assigned Person',
|
||||
help="Name of assigned person")
|
||||
washing_date = fields.Datetime(string='Date', help="Date of washing")
|
||||
description = fields.Text(string='Description',
|
||||
help='Add the description')
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('process', 'Process'),
|
||||
('done', 'Done'),
|
||||
('cancel', 'Cancelled'),
|
||||
], string='Status', readonly=True, copy=False, index=True, default='draft',
|
||||
help='State of wash')
|
||||
laundry_id = fields.Many2one('laundry.order.line')
|
||||
product_line_ids = fields.One2many('wash.order.line', 'wash_id',
|
||||
string='Products', ondelete='cascade',
|
||||
help='Related Products for wash.')
|
||||
total_amount = fields.Float(compute='_compute_total_amount',
|
||||
string='Grand Total')
|
||||
|
||||
def start_wash(self):
|
||||
"""Function for initiating the activity of washing."""
|
||||
if not self.laundry_works:
|
||||
self.laundry_id.state = 'wash'
|
||||
self.laundry_id.laundry_id.state = 'process'
|
||||
for wash in self:
|
||||
for line in wash.product_line_ids:
|
||||
self.env['sale.order.line'].create(
|
||||
{'product_id': line.product_id.id,
|
||||
'name': line.name,
|
||||
'price_unit': line.price_unit,
|
||||
'order_id': wash.laundry_id.laundry_id.sale_id.id,
|
||||
'product_uom_qty': line.quantity,
|
||||
'product_uom_id': line.uom_id.id,
|
||||
})
|
||||
self.state = 'process'
|
||||
|
||||
def action_set_to_done(self):
|
||||
"""Function for set to done."""
|
||||
self.state = 'done'
|
||||
f = 0
|
||||
if not self.laundry_works:
|
||||
if self.laundry_id.extra_work_ids:
|
||||
for each in self.laundry_id.extra_work_ids:
|
||||
self.create({'name': each.name,
|
||||
'user_id': each.assigned_person_id.id,
|
||||
'description': self.laundry_id.description,
|
||||
'laundry_id': self.laundry_id.id,
|
||||
'state': 'draft',
|
||||
'laundry_works': True,
|
||||
'washing_date': datetime.now().strftime(
|
||||
'%Y-%m-%d %H:%M:%S')})
|
||||
self.laundry_id.state = 'extra_work'
|
||||
laundry_id = self.search([('laundry_id.laundry_id', '=',
|
||||
self.laundry_id.laundry_id.id)])
|
||||
for each in laundry_id:
|
||||
if each.state != 'done' or each.state == 'cancel':
|
||||
f = 1
|
||||
break
|
||||
if f == 0:
|
||||
self.laundry_id.laundry_id.state = 'done'
|
||||
laundry = self.search([('laundry_id', '=', self.laundry_id.id)])
|
||||
f1 = 0
|
||||
for each in laundry:
|
||||
if each.state != 'done' or each.state == 'cancel':
|
||||
f1 = 1
|
||||
break
|
||||
if f1 == 0:
|
||||
self.laundry_id.state = 'done'
|
||||
|
||||
@api.depends('product_line_ids')
|
||||
def _compute_total_amount(self):
|
||||
"""Total of the line"""
|
||||
total = 0
|
||||
for obj in self:
|
||||
for each in obj.product_line_ids:
|
||||
total += each.subtotal
|
||||
obj.total_amount = total
|
||||
|
||||
|
||||
class WashOrderLine(models.Model):
|
||||
"""For creating order lines in washing."""
|
||||
_name = 'wash.order.line'
|
||||
_description = 'Wash Order Line'
|
||||
|
||||
wash_id = fields.Many2one('washing.washing', string='Order Reference',
|
||||
help='Order reference from washing',
|
||||
ondelete='cascade')
|
||||
name = fields.Text(string='Description', required=True,
|
||||
help='Add description')
|
||||
uom_id = fields.Many2one('uom.uom', 'Unit of Measure ', required=True)
|
||||
quantity = fields.Integer(string='Quantity',
|
||||
help='Add the required quantity')
|
||||
product_id = fields.Many2one('product.product', string='Product',
|
||||
help='Order line Product')
|
||||
price_unit = fields.Float('Unit Price', default=0.0,
|
||||
related='product_id.list_price',
|
||||
help='Unit price of Product')
|
||||
subtotal = fields.Float(compute='_compute_subtotal', string='Subtotal',
|
||||
readonly=True, store=True,
|
||||
help='Subtotal of the order line')
|
||||
|
||||
@api.depends('price_unit', 'quantity')
|
||||
def _compute_subtotal(self):
|
||||
"""Computing the subtotal"""
|
||||
total = 0
|
||||
for wash in self:
|
||||
total += wash.price_unit * wash.quantity
|
||||
wash.subtotal = total
|
||||
36
addons/laundry_management/models/washing_work.py
Normal file
36
addons/laundry_management/models/washing_work.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
#
|
||||
# Cybrosys Technologies Pvt. Ltd.
|
||||
#
|
||||
# Copyright (C) 2026-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
|
||||
# Author: Anaswara S (odoo@cybrosys.com)
|
||||
#
|
||||
# You can modify it under the terms of the GNU AFFERO
|
||||
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU AFFERO GENERAL PUBLIC LICENSE (AGPL v3) for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
# (AGPL v3) along with this program.
|
||||
# If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
###############################################################################
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class WashingWork(models.Model):
|
||||
"""Model for creating extra work for washing."""
|
||||
_name = 'washing.work'
|
||||
_description = 'Washing Work'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
assigned_person_id = fields.Many2one('res.users',
|
||||
string='Assigned Person',
|
||||
required=True,
|
||||
help="Name of assigned person")
|
||||
amount = fields.Float(string='Service Charge', required=True,
|
||||
help='Service charge for the extra work')
|
||||
Reference in New Issue
Block a user