Tower: upload laundry_management 19.0.19.0.4 (was 19.0.19.0.4, via marketplace)

This commit is contained in:
2026-05-02 11:57:30 +00:00
parent ee9b1958f1
commit d7bc4a4b88
230 changed files with 17001 additions and 0 deletions

View 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

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

View 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.',
)

View 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}.'
)

View 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'])],
}

View 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,
},
}

View 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',
]

View 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,
}

View 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

View 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',
]

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

View File

@@ -0,0 +1,259 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class LaundrySession(models.Model):
"""Daily operational session — POS-style cash management.
A session groups all laundry orders placed in a single working shift.
Cash totals are derived from standard account.payment records linked
to the session's invoices, so figures are always in sync with Accounting.
Opening balance:
- When opening a new session, the system suggests the previous
session's actual_closing_cash as the opening float (carry-forward).
Closing:
- Staff enter the actual physical cash count.
- If a difference exists, a journal entry is posted (optional).
"""
_name = 'laundry.session'
_description = 'Laundry Daily Session'
_inherit = ['mail.thread']
_order = 'opening_datetime desc, id desc'
# ── Identity ──────────────────────────────────────────────────────
name = fields.Char(
string='Session',
required=True, copy=False, readonly=True,
default='New', tracking=True,
)
user_id = fields.Many2one(
'res.users', string='Opened By',
required=True, default=lambda self: self.env.user,
tracking=True,
)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
# ── State ─────────────────────────────────────────────────────────
state = fields.Selection([
('new', 'New'),
('opened', 'Open'),
('closed', 'Closed'),
], default='new', required=True, tracking=True, copy=False)
# ── Timing ────────────────────────────────────────────────────────
opening_datetime = fields.Datetime(string='Opened At', readonly=True)
closing_datetime = fields.Datetime(string='Closed At', readonly=True)
# ── Cash Control ──────────────────────────────────────────────────
opening_cash = fields.Float(
string='Opening Float',
digits=(10, 2),
help='Cash in the drawer at the start of the session.',
)
actual_closing_cash = fields.Float(
string='Actual Cash Count',
digits=(10, 2),
help='Physical cash counted at session close.',
)
expected_closing_cash = fields.Float(
string='Expected Cash',
compute='_compute_cash_control', store=True, digits=(10, 2),
)
cash_difference = fields.Float(
string='Difference',
compute='_compute_cash_control', store=True, digits=(10, 2),
help='Actual Expected. Negative = short.',
)
# ── Orders (sale.order with session_id = self) ────────────────────
order_ids = fields.One2many(
'sale.order', 'session_id', string='Orders',
)
order_count = fields.Integer(
compute='_compute_session_totals', store=True,
)
total_sales = fields.Float(
string='Total Sales',
compute='_compute_session_totals', store=True, digits=(10, 2),
)
# ── Payment totals (from account.payment via invoices) ────────────
total_cash = fields.Float(
string='Cash',
compute='_compute_session_totals', store=True, digits=(10, 2),
)
total_bank = fields.Float(
string='Bank / Card',
compute='_compute_session_totals', store=True, digits=(10, 2),
)
total_paid = fields.Float(
string='Total Collected',
compute='_compute_session_totals', store=True, digits=(10, 2),
)
total_credit = fields.Float(
string='Outstanding / Deferred',
compute='_compute_session_totals', store=True, digits=(10, 2),
help='Total not yet paid (total_sales total_paid).',
)
outstanding_amount = fields.Float(
string='Outstanding',
compute='_compute_session_totals', store=True, digits=(10, 2),
)
notes = fields.Text(string='Notes')
# ── Difference account ────────────────────────────────────────────
difference_account_id = fields.Many2one(
'account.account',
string='Cash Difference Account',
help='Account used to post cash variances at session close.',
)
# ── Constraints ───────────────────────────────────────────────────
_session_name_uniq = models.Constraint(
'UNIQUE(name)',
'Session name must be unique.',
)
@api.constrains('state', 'company_id')
def _check_single_open_session(self):
for session in self:
if session.state == 'opened':
duplicate = self.search([
('state', '=', 'opened'),
('company_id', '=', session.company_id.id),
('id', '!=', session.id),
], limit=1)
if duplicate:
raise UserError(
f'Session "{duplicate.name}" is already open.\n'
'Close it before opening a new session.'
)
# ── Computed ──────────────────────────────────────────────────────
@api.depends(
'order_ids.amount_total',
'order_ids.state',
'order_ids.invoice_ids.state',
'order_ids.invoice_ids.payment_state',
'order_ids.invoice_ids.amount_residual',
'order_ids.is_laundry_order',
)
def _compute_session_totals(self):
Payment = self.env['account.payment']
for session in self:
active = session.order_ids.filtered(
lambda o: o.is_laundry_order and o.state not in ('cancel', 'draft')
)
session.order_count = len(active)
session.total_sales = sum(active.mapped('amount_total'))
# Get posted customer invoices for active orders
invoices = active.mapped('invoice_ids').filtered(
lambda inv: inv.state == 'posted' and inv.move_type == 'out_invoice'
)
if not invoices:
session.total_cash = 0.0
session.total_bank = 0.0
session.total_paid = 0.0
session.outstanding_amount = session.total_sales
session.total_credit = session.total_sales
continue
# Payments reconciled against those invoices
# account.payment.reconciled_invoice_ids is a computed M2M in Odoo 16+
payments = Payment.search([
('reconciled_invoice_ids', 'in', invoices.ids),
('state', '=', 'posted'),
('payment_type', '=', 'inbound'),
])
cash_pmts = payments.filtered(lambda p: p.journal_id.type == 'cash')
bank_pmts = payments.filtered(lambda p: p.journal_id.type == 'bank')
session.total_cash = sum(cash_pmts.mapped('amount'))
session.total_bank = sum(bank_pmts.mapped('amount'))
session.total_paid = sum(payments.mapped('amount'))
outstanding = max(session.total_sales - session.total_paid, 0.0)
session.outstanding_amount = outstanding
session.total_credit = outstanding
@api.depends('opening_cash', 'total_cash', 'actual_closing_cash', 'state')
def _compute_cash_control(self):
for session in self:
session.expected_closing_cash = (
session.opening_cash + session.total_cash
)
session.cash_difference = (
session.actual_closing_cash - session.expected_closing_cash
if session.state == 'closed' else 0.0
)
# ── ORM ───────────────────────────────────────────────────────────
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = (
self.env['ir.sequence'].next_by_code('laundry.session')
or 'New'
)
return super().create(vals_list)
# ── Workflow ──────────────────────────────────────────────────────
def action_open_session(self):
for session in self:
if session.state != 'new':
raise UserError('This session is already open or closed.')
# Carry forward: suggest previous session's actual closing cash
if not session.opening_cash:
prev = self.search([
('state', '=', 'closed'),
('company_id', '=', session.company_id.id),
], order='closing_datetime desc', limit=1)
if prev and prev.actual_closing_cash:
session.opening_cash = prev.actual_closing_cash
session.write({
'state': 'opened',
'opening_datetime': fields.Datetime.now(),
})
session.message_post(
body=f'Session opened by {self.env.user.name}. '
f'Opening float: {session.opening_cash:.2f}'
)
def action_close_session(self):
self.ensure_one()
if self.state != 'opened':
raise UserError('Only open sessions can be closed.')
return {
'name': 'Close Session',
'type': 'ir.actions.act_window',
'res_model': 'laundry.session.close.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_session_id': self.id},
}
def action_view_orders(self):
self.ensure_one()
return {
'name': f'Orders — {self.name}',
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('session_id', '=', self.id), ('is_laundry_order', '=', True)],
'context': {'default_session_id': self.id, 'default_is_laundry_order': True},
}
def action_print_session_report(self):
self.ensure_one()
return self.env.ref(
'laundry_management.action_report_laundry_session'
).report_action(self)

View 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')

View 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,
)

View 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,
}

View 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

View 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

View 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,
)

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

View 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')

View 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

View 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')