Files
tradon/modules/sale_point/sale.py
2026-03-14 09:42:12 +00:00

1371 lines
45 KiB
Python

# 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 itertools import groupby
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 import backend
from trytond.i18n import gettext
from trytond.model import (
ChatMixin, 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, sqlite_apply_types
from trytond.transaction import Transaction
from trytond.wizard import Button, StateTransition, StateView, Wizard
from .exceptions import SaleValidationError, SessionValidationError
class POS(ModelSQL, ModelView):
__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 check_modification(cls, mode, points, values=None, external=False):
pool = Pool()
Sale = pool.get('sale.point.sale')
super().check_modification(
mode, points, values=values, external=external)
if mode == 'write':
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'))
class POSSale(Workflow, ModelSQL, ModelView, TaxableMixin, ChatMixin):
__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(cardinality='low')),
where=t.state.in_(['open', 'done'])))
cls._order = [
('date', 'DESC'),
('id', 'DESC'),
]
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())
@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 check_modification(cls, mode, sales, values=None, external=False):
super().check_modification(
mode, sales, values=values, external=external)
if mode == 'delete':
for sale in sales:
if sale.number:
raise AccessError(gettext(
'sale_point.msg_sale_delete_numbered',
sale=sale.rec_name))
@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 (company, sequence), c_sales in groupby(
sales, key=lambda s: (s.company, s.point.sequence)):
c_sales = list(c_sales)
with Transaction().set_context(company=company.id):
for sale, number in zip(
c_sales, sequence.get_many(len(c_sales))):
sale.number = number
cls.save(sales)
@classmethod
@ModelView.button
@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().values()
# 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.tax.description
if amount >= 0:
line.debit, line.credit = Decimal(0), amount
else:
line.debit, line.credit = -amount, 0
line.account = tax.account
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 or super().tax_date
class POSSaleLine(ModelSQL, ModelView, TaxableMixin):
__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_used is not None):
self.unit_gross_price = self.product.gross_price_used
self.unit_list_price = round_price(Tax.reverse_compute(
self.unit_gross_price, self.taxes,
date=self.sale.date))
elif self.product.list_price_used is not None:
self.unit_list_price = self.product.list_price_used
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().values():
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 or super().tax_date
class POSCashSession(Workflow, ModelSQL, ModelView):
__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(cardinality='low')),
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).as_('amount'),
group_by=[query.session])
if backend.name == 'sqlite':
sqlite_apply_types(query, [None, 'NUMERIC'])
cursor.execute(*query)
balances.update(cursor)
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 on_modification(cls, mode, sessions, field_names=None):
super().on_modification(mode, sessions, field_names=field_names)
if mode == 'delete':
cls.open(sessions)
@classmethod
def check_modification(cls, mode, sessions, values=None, external=False):
super().check_modification(
mode, sessions, values=values, external=external)
if mode == 'delete':
for session in sessions:
if session.state != 'open':
raise AccessError(gettext(
'sale_point.msg_cash_session_delete_open',
session=session.rec_name))
@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):
__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.previous_session.as_('previous'),
session.id.as_('next'))
class POSPayment(ModelSQL, ModelView):
__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', -1)),
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 preprocess_values(cls, mode, values):
pool = Pool()
Method = pool.get('sale.point.payment.method')
Sale = pool.get('sale.point.sale')
Session = pool.get('sale.point.cash.session')
values = super().preprocess_values(mode, values)
if mode == 'create':
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 values
@classmethod
def check_modification(cls, mode, payments, values=None, external=False):
super().check_modification(
mode, payments, values=values, external=external)
if mode == 'delete':
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))
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):
__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', -1)),
])
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):
__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):
__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(cardinality='low')),
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 check_modification(cls, mode, transfers, values=None, external=False):
super().check_modification(
mode, transfers, values=values, external=external)
if mode == 'delete':
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))
@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 preprocess_values(cls, mode, values):
pool = Pool()
Point = pool.get('sale.point')
Session = pool.get('sale.point.cash.session')
values = super().preprocess_values(mode, values)
if mode == 'create':
if values.get('point') is not None and not values.get('session'):
point = Point(values['point'])
values['session'] = Session.get_current(point)
return values
class POSCashTransferType(ModelSQL, ModelView):
__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