# 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