Files
tradon/modules/sale_point/sale.py
2025-12-26 13:11:43 +00:00

1362 lines
45 KiB
Python
Executable File

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from collections import defaultdict
from decimal import Decimal
from sql import Literal, Null, Union
from sql.aggregate import Sum
from sql.conditionals import Coalesce
from sql.functions import CharLength
from sql.operators import Equal
from trytond.i18n import gettext
from trytond.model import (
DeactivableMixin, Exclude, Index, ModelSQL, ModelView, Workflow, fields)
from trytond.model.exceptions import AccessError
from trytond.modules.account.tax import TaxableMixin
from trytond.modules.currency.fields import Monetary
from trytond.modules.product import price_digits, round_price
from trytond.pool import Pool
from trytond.pyson import Bool, Eval, Id, If
from trytond.tools import grouped_slice, reduce_ids
from trytond.transaction import Transaction
from trytond.wizard import Button, StateTransition, StateView, Wizard
from .exceptions import SaleValidationError, SessionValidationError
class POS(ModelSQL, ModelView):
"Point of Sale"
__name__ = 'sale.point'
name = fields.Char("Name", required=True)
company = fields.Many2One('company.company', "Company", required=True)
tax_included = fields.Boolean(
"Tax Included",
help="Check if unit Price includes tax.")
sequence = fields.Many2One(
'ir.sequence.strict', "Sequence", required=True,
domain=[
('company', '=', Eval('company', -1)),
('sequence_type', '=', Id('sale_point', 'sequence_type_sale')),
])
address = fields.Many2One('party.address', "Address")
storage_location = fields.Many2One(
'stock.location', "Storage Location", required=True,
domain=[('type', '=', 'storage')],
help="The location from where goods are taken when sold.")
return_location = fields.Many2One(
'stock.location', "Return Location",
domain=[('type', '=', 'storage')],
help="The location where goods are put when returned.\n"
"If empty the storage location is used.")
customer_location = fields.Many2One(
'stock.location', "To Location", required=True,
domain=[('type', '=', 'customer')],
help="The location where goods are put when sold.")
journal = fields.Many2One(
'account.journal', "Journal", required=True,
domain=[
('type', '=', 'revenue'),
])
@classmethod
def default_company(cls):
return Transaction().context.get('company')
@classmethod
def default_tax_included(cls):
return True
@classmethod
def write(cls, *args):
pool = Pool()
Sale = pool.get('sale.point.sale')
transaction = Transaction()
if transaction.user and transaction.check_access:
actions = iter(args)
for points, values in zip(actions, actions):
if 'tax_included' in values:
for sub_points in grouped_slice(points):
if Sale.search([
('point', 'in',
list(map(int, sub_points))),
],
limit=1, order=[]):
raise AccessError(gettext(
'sale_point.msg_point_change_tax_included')
)
super().write(*args)
class POSSale(Workflow, ModelSQL, ModelView, TaxableMixin):
"POS Sale"
__name__ = 'sale.point.sale'
_rec_name = 'number'
_states = {
'readonly': Eval('state') != 'open',
}
company = fields.Many2One(
'company.company', "Company", required=True, states=_states)
employee = fields.Many2One(
'company.employee', "Employee",
domain=[
('company', '=', Eval('company', -1)),
],
states=_states)
point = fields.Many2One(
'sale.point', "Point", required=True, ondelete='RESTRICT',
states={
'readonly': ((Eval('id', 0) > 0)
| Bool(Eval('lines', [0]))
| _states['readonly']),
},
domain=[
('company', '=', Eval('company', -1)),
])
number = fields.Char(
"Number", readonly=True,
states={
'required': Eval('state').in_(['done', 'posted']),
})
date = fields.Date("Date", required=True, states=_states)
lines = fields.One2Many(
'sale.point.sale.line', 'sale', "Lines",
states={
'readonly': (~Eval('point')
| _states['readonly']),
})
payments = fields.One2Many(
'sale.point.payment', 'sale', "Payments", states=_states)
total_tax = fields.Function(Monetary(
"Total Tax", currency='currency', digits='currency'),
'on_change_with_total_tax')
total = fields.Function(Monetary(
"Total", currency='currency', digits='currency'),
'on_change_with_total')
amount_paid = fields.Function(Monetary(
"Paid", currency='currency', digits='currency'),
'on_change_with_amount_paid')
amount_to_pay = fields.Function(Monetary(
"To Pay", currency='currency', digits='currency'),
'on_change_with_amount_to_pay')
state = fields.Selection([
('open', "Open"),
('done', "Done"),
('posted', "Posted"),
('cancelled', "Cancelled"),
], "State", readonly=True, sort=False)
move = fields.Many2One(
'account.move', "Move", readonly=True,
states={
'invisible': ~Eval('move'),
})
currency = fields.Function(fields.Many2One(
'currency.currency', "Currency"), 'on_change_with_currency')
del _states
@classmethod
def __setup__(cls):
cls.number.search_unaccented = False
super().__setup__()
t = cls.__table__()
cls._sql_indexes.add(
Index(
t, (t.state, Index.Equality()),
where=t.state.in_(['open', 'done'])))
cls._transitions |= {
('open', 'done'),
('done', 'posted'),
('done', 'open'),
('open', 'cancelled'),
('cancelled', 'open'),
}
cls._buttons.update(
pay={
'invisible': Eval('state') != 'open',
'readonly': ~Eval('amount_to_pay'),
'depends': ['amount_to_pay'],
},
cancel={
'invisible': Eval('state') != 'open',
'depends': ['state'],
},
open={
'invisible': ~Eval('state').in_(['done', 'cancelled']),
'depends': ['state'],
},
process={
'invisible': Eval('state') != 'open',
'readonly': (
Bool(Eval('amount_to_pay', 0))
| ~Eval('lines', [-1])),
'depends': ['state', 'amount_to_pay'],
},
post={
'invisible': Eval('state') != 'done',
'depends': ['state'],
},
)
@classmethod
def order_number(cls, tables):
table, _ = tables[None]
return [
~((table.state == 'cancelled') & (table.number == Null)),
CharLength(table.number), table.number]
@classmethod
def default_company(cls):
return Transaction().context.get('company')
@classmethod
def default_employee(cls):
User = Pool().get('res.user')
if Transaction().context.get('employee'):
return Transaction().context['employee']
else:
user = User(Transaction().user)
if user.employee:
return user.employee.id
@classmethod
def default_date(cls):
return Pool().get('ir.date').today()
@classmethod
def default_state(cls):
return 'open'
@fields.depends('company')
def on_change_with_currency(self, name=None):
return self.company.currency if self.company else None
@fields.depends('lines')
def on_change_with_total_tax(self, name=None):
return sum(line.tax_amount for line in self.lines)
@fields.depends('company', 'lines')
def on_change_with_total(self, name=None):
return sum(line.gross_amount for line in self.lines)
@fields.depends('payments')
def on_change_with_amount_paid(self, name=None):
return sum(p.amount or Decimal('0') for p in self.payments)
@fields.depends(methods=[
'on_change_with_total', 'on_change_with_amount_paid'])
def on_change_with_amount_to_pay(self, name=None):
return (self.on_change_with_total()
- self.on_change_with_amount_paid())
def get_rec_name(self, name):
if self.number:
return self.number
else:
return '(%s)' % self.id
@classmethod
def copy(cls, sales, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('employee')
default.setdefault('number')
default.setdefault('payments')
default.setdefault('move')
return super().copy(sales, default=default)
@classmethod
def delete(cls, sales):
for sale in sales:
if sale.number:
raise AccessError(
gettext('sale_point.msg_sale_delete_numbered',
sale=sale.rec_name))
super().delete(sales)
@classmethod
def validate(cls, sales):
super().validate(sales)
for sale in sales:
if sale.state in {'done', 'posted'}:
if sale.amount_to_pay:
raise SaleValidationError(gettext(
'sale_point.msg_sale_amount_to_pay_done_posted',
sale=sale.rec_name))
elif sale.state == 'cancelled':
if sale.amount_paid:
raise SaleValidationError(gettext(
'sale_point.msg_sale_amount_paid_cancelled',
sale=sale.rec_name))
@classmethod
@ModelView.button_action('sale_point.wizard_pay')
def pay(cls, sales):
pass
@classmethod
@ModelView.button
@Workflow.transition('cancelled')
def cancel(cls, sales):
pass
@classmethod
@ModelView.button
@Workflow.transition('open')
def open(cls, sales):
pass
@classmethod
@ModelView.button
def process(cls, sales):
cls.do([sale for sale in sales if not sale.amount_to_pay])
@classmethod
@Workflow.transition('done')
def do(cls, sales):
for sale in sales:
with Transaction().set_context(company=sale.company.id):
sale.number = sale.point.sequence.get()
cls.save(sales)
@classmethod
@Workflow.transition('posted')
def post(cls, sales):
pool = Pool()
StockMove = pool.get('stock.move')
AccountMove = pool.get('account.move')
stock_moves = []
account_moves = []
for sale in sales:
assert not sale.move
for line in sale.lines:
move = line.get_stock_move()
if move:
stock_moves.append(move)
account_move = sale.get_account_move()
sale.move = account_move
account_moves.append(account_move)
if stock_moves:
StockMove.save(stock_moves)
StockMove.do(stock_moves)
if account_moves:
AccountMove.save(account_moves)
AccountMove.post(account_moves)
cls.save(sales)
def get_account_move(self):
'Return account move'
pool = Pool()
Move = pool.get('account.move')
Period = pool.get('account.period')
period = Period.find(self.company, date=self.date)
lines = []
for line in self.lines:
move_lines = line.get_account_move_lines()
if move_lines:
lines.extend(move_lines)
move_lines = self.get_tax_move_lines()
if move_lines:
lines.extend(move_lines)
for payment in self.payments:
move_lines = payment.get_account_move_lines()
if move_lines:
lines.extend(move_lines)
move = Move()
move.journal = self.point.journal
move.period = period
move.date = self.date
move.origin = self
move.company = self.company
move.lines = lines
return move
def get_tax_move_lines(self):
"Return account move lines for taxes"
pool = Pool()
Line = pool.get('account.move.line')
TaxLine = pool.get('account.tax.line')
lines = []
taxes = self._get_taxes()
# Distribute rounding error from tax computation
tax_amount = sum(t['amount'] for t in taxes)
difference = self.total_tax - tax_amount
if difference:
remaining = difference
for tax in taxes:
if tax['amount']:
if tax_amount:
ratio = tax['amount'] / tax_amount
else:
ratio = 1 / len(taxes)
value = self.currency.round(difference * ratio)
tax['amount'] += value
remaining -= value
# Add remaining rounding error to the first tax
if remaining:
for tax in taxes:
if tax['amount']:
tax['amount'] += remaining
break
for tax in taxes:
amount = tax['amount']
if not amount:
continue
line = Line()
line.description = tax['description']
if amount >= 0:
line.debit, line.credit = Decimal(0), amount
else:
line.debit, line.credit = -amount, 0
line.account = tax['account']
if tax['tax']:
tax_line = TaxLine()
tax_line.amount = amount
tax_line.type = 'tax'
tax_line.tax = tax['tax']
line.tax_lines = [tax_line]
lines.append(line)
return lines
@property
def taxable_lines(self):
return sum((line.taxable_lines for line in self.lines), [])
@property
def tax_date(self):
return self.date
class POSSaleLine(ModelSQL, ModelView, TaxableMixin):
"POS Sale Line"
__name__ = 'sale.point.sale.line'
_states = {
'readonly': Eval('sale_state') != 'open',
}
sale = fields.Many2One(
'sale.point.sale', "Sale", required=True, ondelete='CASCADE',
states=_states)
product = fields.Many2One(
'product.product', "Product", required=True,
domain=[
If((Eval('sale_state') == 'open')
& ~(Eval('quantity', 0) < 0),
('salable', '=', True),
()),
],
context={
'company': Eval('company'),
},
states=_states, depends={'company'})
quantity = fields.Float(
"Quantity", digits='unit', required=True, states=_states)
unit = fields.Function(
fields.Many2One('product.uom', "Unit"), 'on_change_with_unit')
unit_list_price = fields.Numeric(
"Unit List Price", digits=price_digits, required=True,
states={
'readonly': True, # Allow client to sent value
})
unit_gross_price = fields.Numeric(
"Unit Gross Price", digits=price_digits, required=True,
states={
'readonly': True, # Allow client to sent value
})
unit_price = fields.Function(fields.Numeric(
"Unit Price", digits=price_digits,
depends={'unit_list_price', 'unit_gross_price'}),
'on_change_with_unit_price')
amount = fields.Function(Monetary(
"Amount", currency='currency', digits='currency'),
'on_change_with_amount')
moves = fields.One2Many(
'stock.move', 'origin', 'Moves', readonly=True,
states={
'invisible': ~Eval('moves', []),
})
sale_state = fields.Function(fields.Selection(
'get_sale_states', "Sale State"), 'on_change_with_sale_state')
company = fields.Function(
fields.Many2One('company.company', "Company"),
'on_change_with_company', searcher='search_company')
currency = fields.Function(
fields.Many2One('currency.currency', "Currency"),
'on_change_with_currency')
del _states
@classmethod
def __setup__(cls):
super().__setup__()
cls.__access__.add('sale')
@fields.depends('product')
def on_change_with_unit(self, name=None):
# TODO packaging UOM
return self.product.sale_uom if self.product else None
@classmethod
def get_sale_states(cls):
pool = Pool()
Sale = pool.get('sale.point.sale')
return Sale.fields_get(['state'])['state']['selection']
@fields.depends('sale', '_parent_sale.state')
def on_change_with_sale_state(self, name=None):
if self.sale:
return self.sale.state
@fields.depends(
'product', 'sale', '_parent_sale.point', '_parent_sale.date',
methods=['on_change_with_unit_price', 'on_change_with_amount'])
def on_change_product(self):
pool = Pool()
Tax = pool.get('account.tax')
self.unit_gross_price = None
self.unit_list_price = None
if self.product and self.sale and self.sale.point:
if (self.sale.point.tax_included
and self.product.gross_price is not None):
self.unit_gross_price = self.product.gross_price
self.unit_list_price = round_price(Tax.reverse_compute(
self.unit_gross_price, self.taxes,
date=self.sale.date))
elif self.product.list_price is not None:
self.unit_list_price = self.product.list_price
taxes = Tax.compute(
self.taxes, self.unit_list_price, 1,
date=self.sale.date)
tax_amount = sum(t['amount'] for t in taxes)
self.unit_gross_price = (
self.unit_list_price + tax_amount).quantize(
Decimal(1)
/ 10 ** self.__class__.unit_gross_price.digits[1])
self.unit_price = self.on_change_with_unit_price()
self.amount = self.on_change_with_amount()
@fields.depends('unit_list_price', 'unit_gross_price',
'sale', '_parent_sale.point')
def on_change_with_unit_price(self, name=None):
if self.sale and self.sale.point:
if self.sale.point.tax_included:
return self.unit_gross_price
else:
return self.unit_list_price
else:
return
@fields.depends('currency', 'quantity', 'unit_price')
def on_change_with_amount(self, name=None):
amount = (Decimal(str(self.quantity or 0))
* (self.unit_price or Decimal('0')))
if self.currency:
return self.currency.round(amount)
return amount
@fields.depends('sale', '_parent_sale.company')
def on_change_with_company(self, name=None):
return self.sale.company if self.sale else None
@classmethod
def search_company(cls, name, clause):
return [('sale.company',) + tuple(clause[1:])]
@fields.depends('sale', '_parent_sale.currency')
def on_change_with_currency(self, name=None):
return self.sale.currency if self.sale else None
@property
def untax_amount(self):
amount = (Decimal(str(self.quantity or 0))
* (self.unit_list_price or Decimal('0')))
if getattr(self, 'currency', None):
amount = self.currency.round(amount)
return amount
@property
def tax_amount(self):
amount = self.gross_amount - self.untax_amount
if getattr(self, 'currency', None):
amount = self.currency.round(amount)
return amount
@property
def gross_amount(self):
amount = (Decimal(str(self.quantity or 0))
* (self.unit_gross_price or Decimal('0')))
if getattr(self, 'currency', None):
amount = self.currency.round(amount)
return amount
def get_rec_name(self, name):
return '%s @ %s' % (self.product.rec_name, self.sale.rec_name)
@classmethod
def search_rec_name(cls, name, clause):
_, operator, value = clause
if operator.startswith('!') or operator.startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
return [bool_op,
('sale.rec_name', *clause[1:]),
('product.rec_name', *clause[1:]),
]
@classmethod
def copy(cls, lines, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('moves')
return super().copy(lines, default=default)
def get_stock_move(self):
'Return stock move'
pool = Pool()
Move = pool.get('stock.move')
if self.product.type == 'service':
return
move = Move()
move.quantity = abs(self.quantity)
move.unit = self.unit
move.product = self.product
move.from_location = self.from_location
move.to_location = self.to_location
move.company = self.company
move.unit_price = self.unit_list_price
move.currency = self.currency
move.effective_date = self.sale.date
move.origin = self
return move
@property
def from_location(self):
if self.quantity >= 0:
return self.sale.point.storage_location
else:
return self.sale.point.customer_location
@property
def to_location(self):
if self.quantity >= 0:
return self.sale.point.customer_location
else:
return self.sale.point.return_location
def get_account_move_lines(self):
"Return account move lines"
pool = Pool()
Line = pool.get('account.move.line')
TaxLine = pool.get('account.tax.line')
line = Line()
if self.quantity >= 0:
line.debit, line.credit = Decimal(0), self.untax_amount
else:
line.debit, line.credit = -self.untax_amount, Decimal(0)
with Transaction().set_context(company=self.company.id):
line.account = self.product.account_revenue_used
line.origin = self
tax_lines = []
for tax in self._get_taxes():
tax_line = TaxLine()
tax_line.amount = tax['base']
tax_line.type = 'base'
tax_line.tax = tax['tax']
tax_lines.append(tax_line)
line.tax_lines = tax_lines
return [line]
@property
def taxes(self):
pool = Pool()
Product = pool.get('product.product')
with Transaction().set_context(company=self.company.id):
return Product(self.product).customer_taxes_used
@property
def taxable_lines(self):
return [
(self.taxes, self.unit_list_price, self.quantity, self.tax_date)]
@property
def tax_type(self):
if self.quantity >= 0:
return 'invoice'
else:
return 'credit_note'
@property
def tax_date(self):
return self.sale.date
class POSCashSession(Workflow, ModelSQL, ModelView):
"POS Cash Session"
__name__ = 'sale.point.cash.session'
point = fields.Many2One(
'sale.point', "Point", required=True, ondelete='CASCADE')
previous_session = fields.Many2One(
'sale.point.cash.session', "Previous Session",
readonly=True, ondelete='RESTRICT',
domain=[
('point', '=', Eval('point', -1)),
])
next_session = fields.One2One(
'sale.point.cash.session.relation', 'previous', 'next', "Next Session",
readonly=True,
domain=[
('point', '=', Eval('point', -1)),
])
payments = fields.One2Many(
'sale.point.payment', 'session', "Payments", readonly=True,
domain=[
('sale.point', '=', Eval('point', -1)),
])
transfers = fields.One2Many(
'sale.point.cash.transfer', 'session', "Transfers",
domain=[
('point', '=', Eval('point', -1)),
],
states={
'readonly': ~Eval('point') | (Eval('state') != 'open'),
})
start_amount = fields.Function(
Monetary("Start Amount", currency='currency', digits='currency'),
'get_start_amount')
balance = fields.Function(Monetary(
"Balance", currency='currency', digits='currency'),
'get_balance')
end_amount = Monetary(
"End Amount", currency='currency', digits='currency', required=True,
states={
'required': Eval('state').in_(['closed', 'posted']),
'readonly': Eval('state') != 'open',
})
state = fields.Selection([
('open', "Open"),
('closed', "Closed"),
('posted', "Posted"),
], "State", readonly=True, required=True, sort=False)
currency = fields.Function(fields.Many2One(
'currency.currency', "Currency"),
'on_change_with_currency')
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_constraints = [
('point_cash_session_open_unique',
Exclude(t, (t.point, Equal),
where=t.state == 'open'),
'sale_point.msg_point_cash_session_open_unique'),
('point_cash_session_previous_unique',
Exclude(t, (Coalesce(t.previous_session, -t.point), Equal)),
'sale_point.msg_cash_session_previous_unique'),
]
cls._sql_indexes.add(
Index(t, (t.state, Index.Equality()), where=t.state == 'open'))
cls._transitions |= {
('open', 'closed'),
('closed', 'open'),
('closed', 'posted'),
}
cls._buttons.update(
open={
'invisible': Eval('state') != 'closed',
'depends': ['state'],
},
close={
'pre_validate': [
('end_amount', '!=', None),
],
'invisible': Eval('state') != 'open',
'depends': ['state'],
},
post={
'invisible': Eval('state') != 'closed',
'depends': ['state'],
},
)
@classmethod
def __register__(cls, module_name):
super().__register__(module_name)
table_h = cls.__table_handler__(module_name)
# Migration from 6.2: add point to previous session uniqueness.
table_h.drop_constraint('previous_session_unique')
@classmethod
def default_state(cls):
return 'open'
@fields.depends('point')
def on_change_point(self):
if self.point:
last = self.__class__.search([
('point', '=', self.point.id),
('next_session', '=', None),
], limit=1)
if last:
self.previous_session, = last
def get_start_amount(self, name):
if self.previous_session:
return self.previous_session.end_amount
else:
return Decimal(0)
@classmethod
def get_balance(cls, sessions, name):
pool = Pool()
Payment = pool.get('sale.point.payment')
Transfer = pool.get('sale.point.cash.transfer')
payment = Payment.__table__()
transfer = Transfer.__table__()
cursor = Transaction().connection.cursor()
balances = defaultdict(Decimal)
for sub_sessions in grouped_slice(sessions):
sub_ids = [s.id for s in sub_sessions]
query = Union(
payment.select(
payment.session.as_('session'),
Sum(payment.amount).as_('amount'),
where=reduce_ids(payment.session, sub_ids),
group_by=[payment.session]),
transfer.select(
transfer.session.as_('session'),
Sum(transfer.amount).as_('amount'),
where=reduce_ids(transfer.session, sub_ids),
group_by=[transfer.session]),
all_=True)
query = query.select(
query.session, Sum(query.amount),
group_by=[query.session])
cursor.execute(*query)
for session_id, balance in cursor:
if not isinstance(balance, Decimal):
balance = Decimal(str(balance))
balances[session_id] = balance
for session in sessions:
balances[session.id] = session.currency.round(balances[session.id])
return balances
@classmethod
def default_end_amount(cls):
return Decimal(0)
@fields.depends('point')
def on_change_with_currency(self, name=None):
return self.point.company.currency if self.point else None
@classmethod
@ModelView.button
@Workflow.transition('open')
def open(cls, sessions):
pass
@classmethod
@ModelView.button
@Workflow.transition('closed')
def close(cls, sessions):
pool = Pool()
Lang = pool.get('ir.lang')
for session in sessions:
if session.end_amount - session.start_amount != session.balance:
lang = Lang.get()
raise SessionValidationError(gettext(
'sale_point.msg_cash_session_wrong_end_amount',
session=session.rec_name,
end_amount=lang.currency(
session.end_amount, session.currency),
amount=lang.currency(
session.start_amount + session.balance,
session.currency)))
@classmethod
def delete(cls, sessions):
cls.open(sessions)
for session in sessions:
if session.state != 'open':
raise AccessError(
gettext('sale_point.msg_cash_session_delete_open',
session=session.rec_name))
super().delete(sessions)
@classmethod
@ModelView.button
@Workflow.transition('posted')
def post(cls, sessions):
pool = Pool()
Transfer = pool.get('sale.point.cash.transfer')
Transfer.post(sum((s.transfers for s in sessions), ()))
@classmethod
def get_current(cls, point):
sessions = cls.search([
('point', '=', point.id),
('state', '=', 'open'),
], limit=1)
if sessions:
session, = sessions
else:
cls.lock()
last = cls.search([
('point', '=', point.id),
('next_session', '=', None),
], limit=1)
if last:
last, = last
else:
last = None
session = cls(point=point, previous_session=last)
session.save()
return session
class POSCashSessionRelation(ModelSQL):
"POS Session Relation"
__name__ = 'sale.point.cash.session.relation'
previous = fields.Many2One('sale.point.cash.session', "Previous")
next = fields.Many2One('sale.point.cash.session', "Next")
@classmethod
def table_query(cls):
pool = Pool()
Session = pool.get('sale.point.cash.session')
session = Session.__table__()
return session.select(
session.id.as_('id'),
session.create_date.as_('create_date'),
session.create_uid.as_('create_uid'),
session.write_date.as_('write_date'),
session.write_uid.as_('write_uid'),
session.previous_session.as_('previous'),
session.id.as_('next'))
class POSPayment(ModelSQL, ModelView):
"POS Payment"
__name__ = 'sale.point.payment'
_states = {
'readonly': Eval('sale_state') != 'open',
}
sale = fields.Many2One(
'sale.point.sale', "Sale", required=True, ondelete='CASCADE',
states=_states)
method = fields.Many2One(
'sale.point.payment.method', "Method", required=True,
domain=[
('company', '=', Eval('company')),
If(Eval('amount', 0) < 0,
('cash', '=', True),
()),
],
states=_states)
amount = Monetary(
"Amount", currency='currency', digits='currency', states=_states)
session = fields.Many2One(
'sale.point.cash.session', "Session",
ondelete='RESTRICT', readonly=True,
domain=[
('point', '=', Eval('point', -1)),
],
states={
'invisible': ~Eval('cash'),
'required': Eval('cash', False),
})
currency = fields.Function(fields.Many2One(
'currency.currency', "Currency"), 'on_change_with_currency')
company = fields.Function(
fields.Many2One('company.company', "Company"),
'on_change_with_company', searcher='search_company')
sale_state = fields.Function(fields.Selection(
'get_sale_states', "Sale State"), 'on_change_with_sale_state')
point = fields.Function(fields.Many2One(
'sale.point', "Point"), 'on_change_with_point')
cash = fields.Function(fields.Boolean("Cash"), 'on_change_with_cash')
del _states
@classmethod
def __setup__(cls):
super().__setup__()
cls.__access__.add('sale')
@fields.depends('sale', '_parent_sale.amount_to_pay')
def on_change_sale(self):
if self.sale:
self.amount = self.sale.amount_to_pay
@fields.depends('company')
def on_change_with_currency(self, name=None):
return self.company.currency if self.company else None
@fields.depends('sale', '_parent_sale.company')
def on_change_with_company(self, name=None):
return self.sale.company if self.sale else None
@classmethod
def search_company(cls, name, clause):
return [('sale.company',) + tuple(clause[1:])]
@classmethod
def get_sale_states(cls):
pool = Pool()
Sale = pool.get('sale.point.sale')
return Sale.fields_get(['state'])['state']['selection']
@fields.depends('sale', '_parent_sale.state')
def on_change_with_sale_state(self, name=None):
if self.sale:
return self.sale.state
@fields.depends('sale', '_parent_sale.point')
def on_change_with_point(self, name=None):
return self.sale.point if self.sale else None
@fields.depends('method')
def on_change_with_cash(self, name=None):
if self.method:
return self.method.cash
@classmethod
def create(cls, vlist):
pool = Pool()
Method = pool.get('sale.point.payment.method')
Sale = pool.get('sale.point.sale')
Session = pool.get('sale.point.cash.session')
vlist = [v.copy() for v in vlist]
for values in vlist:
if values.get('method') and values.get('sale'):
method = Method(values['method'])
if method.cash:
sale = Sale(values['sale'])
values['session'] = Session.get_current(sale.point)
return super().create(vlist)
@classmethod
def delete(cls, payments):
for payment in payments:
if payment.sale.state != 'open':
raise AccessError(
gettext('sale_point.msg_payment_delete_sale_open',
payment=payment.rec_name,
sale=payment.sale.rec_name))
if payment.session and payment.session.state != 'open':
raise AccessError(
gettext('sale_point.msg_payment_delete_session_open',
payment=payment.rec_name,
session=payment.session.rec_name))
super().delete(payments)
def get_account_move_lines(self):
"Return account move lines"
pool = Pool()
Line = pool.get('account.move.line')
line = Line()
if self.sale.total >= 0:
line.debit, line.credit = self.amount, Decimal(0)
else:
line.debit, line.credit = Decimal(0), -self.amount
line.account = self.method.account
line.origin = self
return [line]
class POSPaymentMethod(DeactivableMixin, ModelSQL, ModelView):
"POS Payment Method"
__name__ = 'sale.point.payment.method'
company = fields.Many2One('company.company', "Company", required=True)
name = fields.Char("Name", required=True, translate=True)
account = fields.Many2One(
'account.account', "Account", required=True,
domain=[
('type', '!=', None),
('closed', '!=', True),
('company', '=', Eval('company')),
])
cash = fields.Boolean("Cash")
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_constraints = [
('cash_company_unique',
Exclude(t, (t.company, Equal),
where=(t.active == Literal(True))
& (t.cash == Literal(True))),
'sale_point.msg_payment_method_cash_unique'),
]
cls._order.insert(0, ('name', 'ASC'))
@classmethod
def default_company(cls):
return Transaction().context.get('company')
class POSPay(Wizard):
"POS Pay"
__name__ = 'sale.point.sale.pay'
start_state = 'payment'
payment = StateView(
'sale.point.payment',
'sale_point.payment_view_form_wizard', [
Button("Cancel", 'end', 'tryton-cancel'),
Button("OK", 'pay', 'tryton-ok', default=True),
])
pay = StateTransition()
@classmethod
def __setup__(cls):
super().__setup__()
cls.__rpc__['create'].fresh_session = True
def default_payment(self, fields):
pool = Pool()
PaymentMethod = pool.get('sale.point.payment.method')
default = {
'sale': self.record.id,
'amount': self.record.amount_to_pay,
}
if default['amount'] < 0:
methods = PaymentMethod.search([
('company', '=', self.record.company.id),
('cash', '=', True),
])
if methods:
method, = methods
default['method'] = method.id
return default
def transition_pay(self):
self.payment.sale = self.record
self.payment.save()
if self.record.amount_to_pay:
return 'payment'
else:
self.model.process([self.record])
return 'end'
class POSCashTransfer(Workflow, ModelSQL, ModelView):
"POS Cash Transfer"
__name__ = 'sale.point.cash.transfer'
_states = {
'readonly': Eval('state') == 'posted',
}
point = fields.Many2One(
'sale.point', "Point", required=True, states=_states)
session = fields.Many2One(
'sale.point.cash.session', "Session", required=True,
domain=[
('point', '=', Eval('point', -1)),
If(Eval('state') == 'draft',
('state', '=', 'open'),
()),
],
states={
'required': Eval('id', -1) >= 0,
'readonly': _states['readonly'],
'invisible': ~Eval('point'),
})
type = fields.Many2One(
'sale.point.cash.transfer.type', "Type", required=True,
domain=[
('company', '=', Eval('company', -1)),
])
amount = Monetary(
"Amount", currency='currency', digits='currency', states=_states)
date = fields.Date("Date", required=True)
state = fields.Selection([
('draft', "Draft"),
('posted', "Posted"),
], "State", readonly=True, required=True, sort=False)
move = fields.Many2One(
'account.move', "Move", readonly=True,
states={
'invisible': ~Eval('move'),
})
company = fields.Function(fields.Many2One(
'company.company', "Company"),
'on_change_with_company')
currency = fields.Function(fields.Many2One(
'currency.currency', "Currency"),
'on_change_with_currency')
del _states
@classmethod
def __setup__(cls):
super().__setup__()
t = cls.__table__()
cls._sql_indexes.add(
Index(t, (t.state, Index.Equality()), where=t.state == 'draft'))
cls._transitions |= {
('draft', 'posted'),
}
cls._buttons.update(
post={
'invisible': Eval('state') != 'draft',
'depends': ['state'],
},
)
@classmethod
def default_state(cls):
return 'draft'
@classmethod
def default_date(cls):
return Pool().get('ir.date').today()
@fields.depends('point')
def on_change_with_company(self, name=None):
return self.point.company if self.point else None
@fields.depends('point')
def on_change_with_currency(self, name=None):
return self.point.company.currency if self.point else None
@classmethod
def delete(cls, transfers):
for transfer in transfers:
if transfer.state == 'posted':
raise AccessError(
gettext('sale_point.msg_transfer_delete_posted',
transfer=transfer.rec_name))
if transfer.session.state != 'open':
raise AccessError(
gettext('sale_point.msg_transfer_delete_session_open',
transfer=transfer.rec_name,
session=transfer.session.rec_name))
super().delete(transfers)
@classmethod
@ModelView.button
@Workflow.transition('posted')
def post(cls, transfers):
pool = Pool()
Move = pool.get('account.move')
PaymentMethod = pool.get('sale.point.payment.method')
methods = PaymentMethod.search([('cash', '=', True)])
company2account = {m.company: m.account for m in methods}
moves = []
for transfer in transfers:
assert not transfer.move
move = transfer.get_move(
account=company2account.get(transfer.company))
transfer.move = move
moves.append(move)
Move.save(moves)
Move.post(moves)
cls.save(transfers)
def get_move(self, account):
pool = Pool()
Line = pool.get('account.move.line')
Move = pool.get('account.move')
Period = pool.get('account.period')
period = Period.find(self.company, date=self.date)
line = Line()
if self.amount >= 0:
line.debit, line.credit = self.amount, Decimal(0)
else:
line.debit, line.credit = Decimal(0), -self.amount
line.account = account
counterpart = Line()
counterpart.debit, counterpart.credit = line.credit, line.debit
counterpart.account = self.type.account
move = Move()
move.journal = self.type.journal
move.period = period
move.date = self.date
move.origin = self
move.company = self.company
move.lines = [line, counterpart]
return move
@classmethod
def create(cls, vlist):
pool = Pool()
Point = pool.get('sale.point')
Session = pool.get('sale.point.cash.session')
vlist = [v.copy() for v in vlist]
for values in vlist:
if values.get('point') and not values.get('session'):
point = Point(values['point'])
values['session'] = Session.get_current(point)
return super().create(vlist)
class POSCashTransferType(ModelSQL, ModelView):
"POS Cash Transfer Type"
__name__ = 'sale.point.cash.transfer.type'
company = fields.Many2One('company.company', "Company", required=True)
name = fields.Char("Name", required=True, translate=True)
journal = fields.Many2One(
'account.journal', "Journal", required=True,
context={
'company': Eval('company', -1),
},
depends={'company'})
account = fields.Many2One(
'account.account', "Account", required=True,
domain=[
('type', '!=', None),
('closed', '!=', True),
('company', '=', Eval('company', -1)),
])
@classmethod
def default_company(cls):
return Transaction().context.get('company')
# TODO: wizard to create an invoice from sale