# -*- coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4
##
## Copyright (C) 2005-2014 Async Open Source <http://www.async.com.br>
## All rights reserved
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU Lesser General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU Lesser General Public License for more details.
##
## You should have received a copy of the GNU Lesser General Public License
## along with this program; if not, write to the Free Software
## Foundation, Inc., or visit: http://www.gnu.org/.
##
## Author(s): Stoq Team <stoq-devel@async.com.br>
##
"""
Domain objects related to the sale process in Stoq.
Sale object and related objects implementation """
# pylint: enable=E1101
import collections
from decimal import Decimal
from kiwi.currency import currency
from kiwi.python import Settable
from stoqdrivers.enum import TaxType
from storm.expr import (And, Avg, Count, LeftJoin, Join, Max,
Or, Sum, Alias, Select, Cast, Eq, Coalesce)
from storm.info import ClassAlias
from storm.references import Reference, ReferenceSet
from zope.interface import implementer
from stoqlib.api import api
from stoqlib.database.expr import (Concat, Date, Distinct, Field, NullIf,
TransactionTimestamp)
from stoqlib.database.properties import (UnicodeCol, DateTimeCol, IntCol,
PriceCol, QuantityCol, IdentifierCol,
IdCol, BoolCol, EnumCol)
from stoqlib.database.runtime import (get_current_user,
get_current_branch)
from stoqlib.database.viewable import Viewable
from stoqlib.domain.address import Address, CityLocation
from stoqlib.domain.base import Domain
from stoqlib.domain.costcenter import CostCenter
from stoqlib.domain.event import Event
from stoqlib.domain.events import (SaleStatusChangedEvent,
SaleCanCancelEvent,
SaleIsExternalEvent,
SaleItemBeforeDecreaseStockEvent,
SaleItemBeforeIncreaseStockEvent,
SaleItemAfterSetBatchesEvent,
DeliveryStatusChangedEvent,
StockOperationConfirmedEvent,
ECFGetPrinterUserNumberEvent)
from stoqlib.domain.fiscal import FiscalBookEntry, Invoice
from stoqlib.domain.interfaces import IContainer, IInvoice, IInvoiceItem
from stoqlib.domain.payment.payment import Payment
from stoqlib.domain.payment.method import PaymentMethod
from stoqlib.domain.person import (Person, Client, Branch, LoginUser,
SalesPerson, Company, Individual,
ClientCategory)
from stoqlib.domain.product import (Product, ProductHistory, Storable,
StockTransactionHistory, StorableBatch)
from stoqlib.domain.returnedsale import ReturnedSale, ReturnedSaleItem
from stoqlib.domain.sellable import Sellable, SellableCategory
from stoqlib.domain.service import Service
from stoqlib.domain.taxes import check_tax_info_presence, InvoiceItemIpi
from stoqlib.exceptions import SellError, StockError, DatabaseInconsistency
from stoqlib.lib.dateutils import localnow
from stoqlib.lib.defaults import quantize
from stoqlib.lib.formatters import format_quantity
from stoqlib.lib.parameters import sysparam
from stoqlib.lib.translation import stoqlib_gettext
_ = stoqlib_gettext
# pyflakes: Reference requires that CostCenter is imported at least once
CostCenter # pylint: disable=W0104
#
# Base Domain Classes
#
@implementer(IInvoiceItem)
[docs]class SaleItem(Domain):
"""An item of a |sellable| within a |sale|.
Different from |sellable| which contains information about
the base price, tax, etc, this contains the price in which
*self* was sold, it's taxes, the quantity, etc.
Note that objects of this type should never be created manually, only by
calling :meth:`Sale.add_sellable`
See also:
`schema <http://doc.stoq.com.br/schema/tables/sale_item.html>`__
"""
__storm_table__ = 'sale_item'
repr_fields = ['sale_id']
#: the quantity of the of sold item in this sale
quantity = QuantityCol()
#: the quantity already decreased from stock.
quantity_decreased = QuantityCol(default=0)
#: original value the |sellable| had when adding the sale item
base_price = PriceCol()
#: averiage cost of the items in this item
average_cost = PriceCol(default=0)
#: price of this item
price = PriceCol()
sale_id = IdCol()
#: |sale| for this item
sale = Reference(sale_id, 'Sale.id')
sellable_id = IdCol()
#: |sellable| for this item
sellable = Reference(sellable_id, 'Sellable.id')
batch_id = IdCol()
#: If the sellable is a storable, the |batch| that it was removed from
batch = Reference(batch_id, 'StorableBatch.id')
delivery_id = IdCol(default=None)
#: The |delivery| this sale_item *is in* or None
delivery = Reference(delivery_id, 'Delivery.id')
#: The |delivery| that this item *corresponds* to. Ie, this sale_item's sellable is
#: the Delivery service that was added to the sale.
delivery_adaptor = Reference('id', 'Delivery.service_item_id', on_remote=True)
cfop_id = IdCol(default=None)
#: :class:`fiscal entry <stoqlib.domain.fiscal.CfopData>`
cfop = Reference(cfop_id, 'CfopData.id')
#: user defined notes, currently only used by services
notes = UnicodeCol(default=None)
#: estimated date that *self* will be fixed, currently
#: only used by services
estimated_fix_date = DateTimeCol(default_factory=localnow)
# FIXME: This doesn't appear to be used anywhere. Maybe we
# should remove it from the database
completion_date = DateTimeCol(default=None)
#: Id of ICMS tax in product tax template
icms_info_id = IdCol()
#: the :class:`stoqlib.domain.taxes.InvoiceItemIcms` tax for *self*
icms_info = Reference(icms_info_id, 'InvoiceItemIcms.id')
#: Id of IPI tax in product tax template
ipi_info_id = IdCol()
#: the :class:`stoqlib.domain.taxes.InvoiceItemIpi` tax for *self*
ipi_info = Reference(ipi_info_id, 'InvoiceItemIpi.id')
#: Id of PIS tax in product tax template
pis_info_id = IdCol()
#: the :class:`stoqlib.domain.taxes.InvoiceItemPis` tax for *self*
pis_info = Reference(pis_info_id, 'InvoiceItemPis.id')
#: Id of COFINS tax in product tax template
cofins_info_id = IdCol()
#: the :class:`stoqlib.domain.taxes.InvoiceItemCofins` tax for *self*
cofins_info = Reference(cofins_info_id, 'InvoiceItemCofins.id')
#: Id of |sale_item| parent of self
parent_item_id = IdCol(default=None)
parent_item = Reference(parent_item_id, 'SaleItem.id')
#: A list of children of self
children_items = ReferenceSet('id', 'SaleItem.parent_item_id')
def __init__(self, store=None, **kw):
if not 'kw' in kw:
if not 'sellable' in kw:
raise TypeError('You must provide a sellable argument')
base_price = kw['sellable'].price
kw['base_price'] = base_price
if not kw.get('cfop'):
kw['cfop'] = kw['sellable'].default_sale_cfop
if not kw.get('cfop'):
kw['cfop'] = sysparam.get_object(store, 'DEFAULT_SALES_CFOP')
store = kw.get('store', store)
check_tax_info_presence(kw, store)
Domain.__init__(self, store=store, **kw)
product = self.sellable.product
if product:
# Set ipi details before icms, since icms may depend on the ipi
self.ipi_info.set_item_tax(self)
self.icms_info.set_item_tax(self)
self.pis_info.set_item_tax(self)
self.cofins_info.set_item_tax(self)
#
# Properties
#
@property
def returned_quantity(self):
# FIXME: Verificar status do ReturnedSale
return self.store.find(ReturnedSaleItem,
sale_item=self).sum(ReturnedSaleItem.quantity) or Decimal('0')
@property
def sale_discount(self):
"""The discount percentage (relative to the original price
when the item was sold)
:returns: the discount amount
"""
if self.price > 0 and self.price < self.base_price:
return (1 - (self.price / self.base_price)) * 100
return 0
@property
def price_with_discount(self):
"""Applies the sale discount to this item.
This will apply the discount given in the sale proportionally to this
item.
This value should be used when returning or trading this item, since the
user should not receive more than what he paid for.
Please note that this may result in rounding problems, since precision
may be lost when appling the discount in the items.
:returns: price with discount/surcharge
"""
diff = self.sale.discount_value - self.sale.surcharge_value
if diff == 0:
return currency(self.price)
# Dont use self.sale.(surcharge/discount)_percentage here since they are
# already quantized, and we may lose even more precision.
percentage = diff / self.sale.get_sale_subtotal()
return currency(quantize(self.price * (1 - percentage)))
#
# Invoice implementation
#
@property
def item_discount(self):
if self.price < self.base_price:
return self.base_price - self.price
return Decimal('0')
@property
def parent(self):
return self.sale
@property
def cfop_code(self):
"""Returns the cfop code to be used on the NF-e
If the sale was also printed on a ECF, then the cfop should be:
* 5.929: if sold to a |Client| in the same state or
:returns: the cfop code
"""
if self.sale.coupon_id:
return u'5929'
if self.cfop:
return self.cfop.code.replace(u'.', u'')
# FIXME: remove sale cfop?
return self.sale.cfop.code.replace(u'.', u'')
#
# Public API
#
def sell(self, branch):
store = self.store
if not (branch and
branch.id == get_current_branch(store).id):
raise SellError(_(u"Stoq still doesn't support sales for "
u"branch companies different than the "
u"current one"))
if not self.sellable.is_available():
raise SellError(_(u"%s is not available for sale. Try making it "
u"available first and then try again.") % (
self.sellable.get_description()))
# This is emitted here instead of inside the if bellow because one can
# connect on it and change this item in a way that, if it wasn't going
# to decrease stock before, it will after
SaleItemBeforeDecreaseStockEvent.emit(self)
quantity_to_decrease = self.quantity - self.quantity_decreased
storable = self.sellable.product_storable
if storable and quantity_to_decrease:
try:
item = storable.decrease_stock(
quantity_to_decrease, branch,
StockTransactionHistory.TYPE_SELL, self.id,
cost_center=self.sale.cost_center, batch=self.batch)
except StockError as err:
raise SellError(str(err))
self.average_cost = item.stock_cost
self.quantity_decreased += quantity_to_decrease
self.update_tax_values()
def cancel(self, branch):
# This is emitted here instead of inside the if bellow because one can
# connect on it and change this item in a way that, if it wasn't going
# to increase stock before, it will after
SaleItemBeforeIncreaseStockEvent.emit(self)
storable = self.sellable.product_storable
if storable and self.quantity_decreased:
storable.increase_stock(self.quantity_decreased,
branch,
StockTransactionHistory.TYPE_CANCELED_SALE,
self.id,
batch=self.batch)
self.quantity_decreased = Decimal(0)
[docs] def reserve(self, quantity):
"""Reserve some quantity of this this item from stock
This will remove the informed quantity from the stock.
"""
assert 0 < quantity <= (self.quantity - self.quantity_decreased)
storable = self.sellable.product_storable
if storable:
storable.decrease_stock(quantity, self.sale.branch,
StockTransactionHistory.TYPE_SALE_RESERVED,
self.id, batch=self.batch)
self.quantity_decreased += quantity
[docs] def return_to_stock(self, quantity):
"""Return some reserved quantity to stock
This will return a previously reserved quantity to stock, so that it can
be sold in any other sale.
"""
assert 0 < quantity <= self.quantity_decreased
storable = self.sellable.product_storable
if storable:
storable.increase_stock(quantity, self.sale.branch,
StockTransactionHistory.TYPE_SALE_RETURN_TO_STOCK,
self.id, batch=self.batch)
self.quantity_decreased -= quantity
[docs] def set_batches(self, batches):
"""Set batches for this sale item
Set how much quantity of each |batch| this sale item represents.
Note that this will replicate this item and create others, since
the batch reference is one per sale item.
At the end, this sale item will contain the quantity not used
by any batch yet or, if the sum of quantities on batches are
equal to :obj:`.quantity`, it will be used for one of the batches
:param batches: a dict mapping the batch to it's quantity
:returns: a list of the new created items
:raises: :exc:`ValueError` if this item already has a batch
:raises: :exc:`ValueError` if the sum of the batches quantities
is greater than this item's original quantity
"""
# Make a copy since we are going to modify this dict
batches = batches.copy()
if self.batch is not None:
raise ValueError("This item already has a batch")
quantities_sum = sum(quantity for quantity in batches.values())
if quantities_sum > self.quantity:
raise ValueError("The sum of batch quantities needs to be equal "
"or less than the item's original quantity")
missing = self.quantity - quantities_sum
# If there's some quantity missing batch information, leave self
# with that missing quantity so it can be set again in the future
if missing:
self.quantity = missing
else:
self.batch, self.quantity = batches.popitem()
self.update_tax_values()
new_sale_items = []
for batch, quantity in batches.items():
new_item = self.__class__(
store=self.store,
sellable=self.sellable,
sale=self.sale,
quantity=quantity,
batch=batch,
cfop=self.cfop,
base_price=self.base_price,
price=self.price,
notes=self.notes)
new_item.update_tax_values()
new_sale_items.append(new_item)
SaleItemAfterSetBatchesEvent.emit(self, new_sale_items)
return new_sale_items
[docs] def set_discount(self, discount):
"""Apply *discount* on this item
Note that the discount will be applied based on :obj:`.base_price`
and then substitute :obj:`.price`, making any previous
discount/surcharge being lost
:param decimal.Decimal discount: the discount to be applied
as a percentage, e.g. 10.0, 22.5
"""
if self.parent_item:
component = self.get_component(self.parent_item)
discount_value = quantize(component.price * discount / 100)
base_price = component.price
else:
discount_value = quantize(self.base_price * discount / 100)
base_price = self.base_price
self.price = max(base_price - discount_value, Decimal('0.01'))
def get_total(self):
# Sale items are suposed to have only 2 digits, but the value price
# * quantity may have more than 2, so we need to round it.
if self.ipi_info:
return currency(quantize(self.price * self.quantity +
self.ipi_info.v_ipi))
return currency(quantize(self.price * self.quantity))
def get_quantity_unit_string(self):
return u"%s %s" % (format_quantity(self.quantity),
self.sellable.unit_description)
def get_description(self):
return self.sellable.get_description()
[docs] def is_totally_returned(self):
"""If this sale item was totally returned
:returns: ``True`` if it was totally returned,
``False`` otherwise.
"""
return self.quantity == self.returned_quantity
[docs] def is_service(self):
"""If this sale item contains a |service|.
:returns: ``True`` if it's a service
"""
service = self.store.find(Service, sellable=self.sellable).one()
return service is not None
[docs] def get_sale_surcharge(self):
"""The surcharge percentage (relative to the original price
when the item was sold)
:returns: the surcharge amount
"""
if self.price > self.base_price:
return ((self.price / self.base_price) - 1) * 100
return 0
def update_tax_values(self):
if self.icms_info:
self.icms_info.update_values(self)
if self.ipi_info:
self.ipi_info.update_values(self)
if self.pis_info:
self.pis_info.update_values(self)
if self.cofins_info:
self.cofins_info.update_values(self)
def has_children(self):
return self.children_items.count() > 0
[docs] def get_component(self, parent):
"""Get the quantity of a component.
:param parent: the |sale_item| parent_item of self
:returns: the |product_component|
"""
for component in parent.sellable.product.get_components():
if self.sellable.product == component.component:
return component
return None
@implementer(IContainer)
[docs]class Delivery(Domain):
"""Delivery, transporting a set of sale items for sale.
Involves a |transporter| transporting a set of |saleitems| to a
receival |address|.
Optionally a :obj:`.tracking_code` can be set to track the items.
See also:
`schema <http://doc.stoq.com.br/schema/tables/delivery.html>`__
"""
__storm_table__ = 'delivery'
#: The delivery was created and is waiting to be picked
STATUS_INITIAL = u'initial'
#: The delivery was cancelled
STATUS_CANCELLED = u'cancelled'
#: The delivery was picked and is waiting to be packed
STATUS_PICKED = u'picked'
#: The delivery was packed and is waiting to be packed
STATUS_PACKED = u'packed'
#: sent to deliver
STATUS_SENT = u'sent'
#: received by the |client|
STATUS_RECEIVED = u'received'
#: CIF (Cost, Insurance and Freight): The freight is responsibility of
#: the receiver (i.e. the client)
FREIGHT_TYPE_CIF = u'cif'
#: CIF (Free on Board): The freight is responsibility of the sender
#: (i.e. the branch)
FREIGHT_TYPE_FOB = u'fob'
#: 3rd party: The freight is responsibility of a third party
FREIGHT_TYPE_3RDPARTY = u'3rdparty'
#: No freight: There's no freight
FREIGHT_TYPE_NONE = None
statuses = {STATUS_INITIAL: _(u"Waiting"),
STATUS_CANCELLED: _(u"Cancelled"),
STATUS_PICKED: _(u"Picked"),
STATUS_PACKED: _(u"Packed"),
STATUS_SENT: _(u"Sent"),
STATUS_RECEIVED: _(u"Received")}
freights = {FREIGHT_TYPE_NONE: _(u"No freight"),
FREIGHT_TYPE_CIF: _(u"CIF"),
FREIGHT_TYPE_FOB: _(u"FOB"),
FREIGHT_TYPE_3RDPARTY: _(u"Third party")}
#: the delivery status
status = EnumCol(allow_none=False, default=STATUS_INITIAL)
#: the date which the delivery was created
open_date = DateTimeCol(default_factory=TransactionTimestamp)
#: The date that the delivery was cancelled
cancel_date = DateTimeCol(default=None)
#: The date that the delivery was picked
pick_date = DateTimeCol(default=None)
#: The date that the delivery was packed
pack_date = DateTimeCol(default=None)
#: the date which the delivery sent to deliver
send_date = DateTimeCol(default=None)
#: the date which the delivery received by the |client|
receive_date = DateTimeCol(default=None)
#: the delivery tracking code, a transporter specific identifier that
#: can be used to look up the status of the delivery
tracking_code = UnicodeCol(default=u'')
#: The type of the freight
freight_type = EnumCol(default=FREIGHT_TYPE_CIF)
#: The kind of the volumes
volumes_kind = UnicodeCol(default=u'')
#: The quantity of volumes in this freight
volumes_quantity = IntCol()
address_id = IdCol(default=None)
#: the |address| to deliver to
address = Reference(address_id, 'Address.id')
transporter_id = IdCol(default=None)
#: the |transporter| for this delivery
transporter = Reference(transporter_id, 'Transporter.id')
service_item_id = IdCol(default=None)
#: the |saleitem| for the delivery itself
service_item = Reference(service_item_id, 'SaleItem.id')
#: the |saleitems| for the items to deliver
delivery_items = ReferenceSet('id', 'SaleItem.delivery_id')
cancel_responsible_id = IdCol(default=None)
#: The responsible for cancelling the products for delivery
cancel_responsible = Reference(cancel_responsible_id, 'LoginUser.id')
pick_responsible_id = IdCol(default=None)
#: The responsible for picking the products for delivery
pick_responsible = Reference(pick_responsible_id, 'LoginUser.id')
pack_responsible_id = IdCol(default=None)
#: The responsible for packing the products for delivery
pack_responsible = Reference(pack_responsible_id, 'LoginUser.id')
send_responsible_id = IdCol(default=None)
#: The responsible for delivering the products to the |transporter|
send_responsible = Reference(send_responsible_id, 'LoginUser.id')
#
# Properties
#
@property
def status_str(self):
return self.statuses[self.status]
@property
def address_str(self):
if self.address:
return self.address.get_address_string()
return u''
@property
def client_str(self):
client = self.service_item.sale.client
if client:
return client.get_description()
return u''
#
# Public API
#
[docs] def can_cancel(self):
"""Check if we can cancel the delivery.
Only initial, picked or packed deliveries can be cancelled.
"""
return self.status in [self.STATUS_INITIAL,
self.STATUS_PICKED,
self.STATUS_PACKED]
[docs] def can_pick(self):
"""Check if we can pick the delivery.
Only initial deliveries can be picked.
"""
return self.status == self.STATUS_INITIAL
[docs] def can_pack(self):
"""Check if we can pack the delivery.
Only picked deliveries can be packed.
"""
return self.status == self.STATUS_PICKED
[docs] def can_send(self):
"""Check if we can send the delivery.
Only packed deliveries can be sent.
"""
# FIXME: In the future once pick & pack is implemented, should we only
# allow packed deliveries to be sent?
return self.status in [self.STATUS_INITIAL,
self.STATUS_PICKED,
self.STATUS_PACKED]
[docs] def can_receive(self):
"""Check if we can receive the delivery.
Only sent deliveries can be received.
"""
return self.status == self.STATUS_SENT
[docs] def set_initial(self):
"""Set the delivery in its initial state."""
# FIXME: This should be removed in the future once we implement
# the pick & pack structure
self._set_delivery_status(self.STATUS_INITIAL)
[docs] def cancel(self, responsible):
"""Set the delivery as cancelled."""
assert self.can_cancel()
self.cancel_date = TransactionTimestamp()
self._set_delivery_status(self.STATUS_CANCELLED)
self.cancel_responsible = responsible
[docs] def pick(self, responsible):
"""Set the delivery as picked."""
assert self.can_pick()
self._set_delivery_status(self.STATUS_PICKED)
self.pick_date = TransactionTimestamp()
self.pick_responsible = responsible
[docs] def pack(self, responsible):
"""Set the delivery as packed."""
assert self.can_pack()
self._set_delivery_status(self.STATUS_PACKED)
self.pack_date = TransactionTimestamp()
self.pack_responsible = responsible
[docs] def send(self, responsible):
"""Set the delivery as sent."""
assert self.can_send()
self._set_delivery_status(self.STATUS_SENT)
self.send_date = TransactionTimestamp()
self.send_responsible = responsible
[docs] def receive(self):
"""Set the delivery as received."""
assert self.can_receive()
self._set_delivery_status(self.STATUS_RECEIVED)
self.receive_date = TransactionTimestamp()
#
# IContainer implementation
#
def add_item(self, item):
item.delivery = self
def get_items(self):
return list(self.delivery_items)
def remove_item(self, item):
item.delivery = None
def remove_all_items(self):
for item in self.get_items():
self.remove_item(item)
#
# Private
#
def _set_delivery_status(self, status):
old_status = self.status
DeliveryStatusChangedEvent.emit(self, old_status)
self.status = status
@implementer(IContainer)
@implementer(IInvoice)
[docs]class Sale(Domain):
"""Sale logic, the process of selling a |sellable| to a |client|.
* calculates the sale price including discount/interest/markup
* creates payments
* decreases the stock for products
* creates a delivery (optional)
* verifies that the client is suitable
* creates commissions to the sales person
* add money to the till (if paid with money)
* calculate taxes and fiscal book entries
+----------------------------+----------------------------+
| **Status** | **Can be set to** |
+----------------------------+----------------------------+
| :obj:`STATUS_QUOTE` | :obj:`STATUS_INITIAL` |
+----------------------------+----------------------------+
| :obj:`STATUS_INITIAL` | :obj:`STATUS_ORDERED`, |
+----------------------------+----------------------------+
| :obj:`STATUS_ORDERED` | :obj:`STATUS_CONFIRMED` |
| | :obj:`STATUS_CANCELLED` |
+----------------------------+----------------------------+
| :obj:`STATUS_CONFIRMED` | :obj:`STATUS_RENEGOTIATED` |
+----------------------------+----------------------------+
| :obj:`STATUS_CANCELLED` | None |
+----------------------------+----------------------------+
| :obj:`STATUS_RENEGOTIATED` | None |
+----------------------------+----------------------------+
| :obj:`STATUS_RETURNED` | None |
+----------------------------+----------------------------+
.. graphviz::
digraph sale_status {
STATUS_QUOTE -> STATUS_INITIAL;
STATUS_INITIAL -> STATUS_ORDERED;
STATUS_ORDERED -> STATUS_CONFIRMED;
STATUS_ORDERED -> STATUS_CANCELLED;
STATUS_CONFIRMED -> STATUS_CANCELLED;
STATUS_CONFIRMED -> STATUS_RENEGOTIATED;
}
See also:
`schema <http://doc.stoq.com.br/schema/tables/sale.html>`__
"""
__storm_table__ = 'sale'
repr_fields = ['identifier', 'status']
#: The sale is opened, products or other |sellable| items might have
#: been added.
STATUS_INITIAL = u'initial'
#: When asking for sale quote this is the initial state that is set before
#: reaching the initial state
STATUS_QUOTE = u'quote'
#: This state means the order was left the quoting state, but cant just yet
#: go to the confirmed state. This may happen for various reasons,
#: like when there is not enough stock to confirm the sale; when the sale
#: has pending work orders; or when the confirmation should happen on
#: the till app (because of the CONFIRM_SALES_AT_TILL parameter)
STATUS_ORDERED = u'ordered'
#: The sale has been confirmed and all payments have been registered,
#: but not necessarily paid.
STATUS_CONFIRMED = u'confirmed'
#: The sale has been canceled, this can only happen
#: to an sale which has not yet reached the SALE_CONFIRMED status.
STATUS_CANCELLED = u'cancelled'
#: The sale has been returned, all the payments made have been canceled
#: and the |client| has been compensated for everything already paid.
STATUS_RETURNED = u'returned'
#: A sale that is closed as renegotiated, all payments for this sale
#: should be canceled at list point. Another new sale is created with
#: the new, renegotiated payments.
STATUS_RENEGOTIATED = u'renegotiated'
statuses = collections.OrderedDict([
(STATUS_INITIAL, _(u'Opened')),
(STATUS_QUOTE, _(u'Quoting')),
(STATUS_ORDERED, _(u'Ordered')),
(STATUS_CONFIRMED, _(u'Confirmed')),
(STATUS_CANCELLED, _(u'Cancelled')),
(STATUS_RETURNED, _(u'Returned')),
(STATUS_RENEGOTIATED, _(u'Renegotiated')),
])
#: A numeric identifier for this object. This value should be used instead of
#: :obj:`Domain.id` when displaying a numerical representation of this object to
#: the user, in dialogs, lists, reports and such.
identifier = IdentifierCol()
#: status of the sale
status = EnumCol(allow_none=False, default=STATUS_INITIAL)
# FIXME: this doesn't really belong to the sale
# FIXME: it should also be renamed and avoid *_id
#: identifier for the coupon of this sale, used by a ECF printer
coupon_id = IntCol()
# FIXME: This doesn't appear to be used anywhere.
# Maybe we should remove it from the database.
service_invoice_number = IntCol(default=None)
#: the date sale was created, this is always set
open_date = DateTimeCol(default_factory=localnow)
#: the date sale was confirmed, or None if it hasn't been confirmed
confirm_date = DateTimeCol(default=None)
#: the date sale was paid, or None if it hasn't be paid
close_date = DateTimeCol(default=None)
#: the date sale was confirmed, or None if it hasn't been cancelled
cancel_date = DateTimeCol(default=None)
#: the date sale was confirmed, or None if it hasn't been returned
return_date = DateTimeCol(default=None)
#: date when this sale expires, used by quotes
expire_date = DateTimeCol(default=None)
#: This flag indicates if the sale its completely paid and received
paid = BoolCol(default=False)
#: discount of the sale, in absolute value, for instance::
#:
#: sale.total_sale_amount = 150
#: sale.discount_value = 18
#: # the price of the sale will now be 132
#:
discount_value = PriceCol(default=0)
#: surcharge of the sale, in absolute value, for instance::
#:
#: sale.total_sale_amount = 150
#: sale.surcharge_value = 18
#: # the price of the sale will now be 168
#:
surcharge_value = PriceCol(default=0)
#: the total value of all the items in the same, this is set when
#: a sale is confirmed, this is the same as calling
#: :obj:`Sale.get_total_sale_amount()` at the time of confirming the sale,
total_amount = PriceCol(default=0)
#: The reason the sale was cancelled
cancel_reason = UnicodeCol()
cfop_id = IdCol()
#: the :class:`fiscal entry <stoqlib.domain.fiscal.CfopData>`
cfop = Reference(cfop_id, 'CfopData.id')
client_id = IdCol(default=None)
#: the |client| who this sale was sold to
client = Reference(client_id, 'Client.id')
salesperson_id = IdCol()
#: the |salesperson| who sold the sale
salesperson = Reference(salesperson_id, 'SalesPerson.id')
branch_id = IdCol()
#: the |branch| this sale belongs to
branch = Reference(branch_id, 'Branch.id')
transporter_id = IdCol(default=None)
# FIXME: transporter should only be used on Delivery.
#: If we have a delivery, this is the |transporter| for this sale
transporter = Reference(transporter_id, 'Transporter.id')
group_id = IdCol()
#: the |paymentgroup| of this sale
group = Reference(group_id, 'PaymentGroup.id')
client_category_id = IdCol(default=None)
#: the |clientcategory| used for price determination.
client_category = Reference(client_category_id, 'ClientCategory.id')
cost_center_id = IdCol(default=None)
#: the |costcenter| that the cost of the products sold in this sale should
#: be accounted for. When confirming a sale with a |costcenter| set, a
#: |costcenterentry| will be created for each product
cost_center = Reference(cost_center_id, 'CostCenter.id')
#: list of :class:`comments <stoqlib.domain.sale.SaleComment>` for
#: this sale
comments = ReferenceSet('id', 'SaleComment.sale_id', order_by='SaleComment.date')
#: All returned sales of this sale
returned_sales = ReferenceSet('id', 'ReturnedSale.sale_id',
order_by='ReturnedSale.return_date')
invoice_id = IdCol()
#: The |sale_token| id
sale_token_id = IdCol()
#: Reference to |SaleToken|
sale_token = Reference(sale_token_id, 'SaleToken.id')
#: The |invoice| generated by the sale
invoice = Reference(invoice_id, 'Invoice.id')
cancel_responsible_id = IdCol()
#: The responsible for cancelling the sale. At the moment, the
#: |loginuser| that cancelled the sale
cancel_responsible = Reference(cancel_responsible_id, 'LoginUser.id')
def __init__(self, store=None, **kw):
kw['invoice'] = Invoice(store=store, invoice_type=Invoice.TYPE_OUT)
super(Sale, self).__init__(store=store, **kw)
# Branch needs to be set before cfop, which triggers an
# implicit flush.
self.branch = kw.pop('branch', None)
if not 'cfop' in kw:
self.cfop = sysparam.get_object(store, 'DEFAULT_SALES_CFOP')
#
# Classmethods
#
@classmethod
[docs] def get_status_name(cls, status):
"""The :obj:`Sale.status` as a translated string"""
if not status in cls.statuses:
raise DatabaseInconsistency(_(u"Invalid status %d") % status)
return cls.statuses[status]
#
# IContainer implementation
#
def add_item(self, sale_item):
assert not sale_item.sale
sale_item.sale = self
def get_items(self, with_children=True):
store = self.store
query = SaleItem.sale == self
if not with_children:
query = And(query,
Eq(SaleItem.parent_item_id, None))
return store.find(SaleItem, query)
def remove_item(self, sale_item):
if sale_item.quantity_decreased > 0:
sale_item.return_to_stock(sale_item.quantity_decreased)
# If the item removed is the item corresponding to the delivery, we need to
# remove all items from the delivery, including the delivery itself
delivery = sale_item.delivery_adaptor
if delivery:
delivery.remove_all_items()
self.store.maybe_remove(delivery)
sale_item.sale = None
self.store.maybe_remove(sale_item)
#
# IInvoice implementation
#
@property
def recipient(self):
if self.client:
return self.client.person
return None
@property
def invoice_subtotal(self):
return self.get_sale_subtotal()
@property
def invoice_total(self):
return self.get_total_sale_amount()
@property
def nfe_coupon_info(self):
"""Returns
"""
if not self.coupon_id:
return None
# According to the "Cartilha do ECF Emissor de Cupom Fiscal - Perguntas e
# respostas, versão 3.2 - Abril 2016 da Sefaz de MG", the ECF serial number
# is the number attributed by the establishment/user
number = ECFGetPrinterUserNumberEvent.emit() or u''
return Settable(number=number,
coo=self.coupon_id)
# Status
[docs] def can_order(self):
"""Only newly created sales can be ordered
:returns: ``True`` if the sale can be ordered
"""
return (self.status == Sale.STATUS_INITIAL or
self.status == Sale.STATUS_QUOTE)
[docs] def can_confirm(self):
"""Only ordered sales can be confirmed
:returns: ``True`` if the sale can be confirmed
"""
if self.client:
method_values = {}
for p in self.payments:
# We should ignore already paid payments
if p.is_paid():
continue
method_values.setdefault(p.method, 0)
method_values[p.method] += p.value
for method, value in method_values.items():
assert self.client.can_purchase(method, value)
return (self.status == Sale.STATUS_ORDERED or
self.status == Sale.STATUS_QUOTE)
[docs] def can_set_paid(self):
"""Only confirmed sales can raise the flag paid. Also, the sale must have at least
one payment and all the payments must be already paid.
:returns: ``True`` if the sale can be set as paid
"""
if self.paid:
return False
payments = list(self.payments)
if not payments:
return False
return all(p.is_paid() for p in payments)
[docs] def can_set_not_paid(self):
"""Only confirmed sales can be paid
:returns: ``True`` if the sale can be set as paid
"""
return self.paid
[docs] def can_set_renegotiated(self):
"""Only sales with status confirmed can be renegotiated.
:returns: ``True`` if the sale can be renegotiated
"""
# This should be as simple as:
# return self.status == Sale.STATUS_CONFIRMED
# But due to bug 3890 we have to check every payment.
return self.payments.find(
Payment.status == Payment.STATUS_PENDING).count() > 0
[docs] def can_cancel(self):
"""Only ordered, confirmed, paid and quoting sales can be cancelled.
:returns: ``True`` if the sale can be cancelled
"""
# None is acceptable as it means no one catch the event
if SaleCanCancelEvent.emit(self) is False:
return False
# If ALLOW_CANCEL_CONFIRMED_SALES is not set, we can only cancel
# quoting sales
if not sysparam.get_bool("ALLOW_CANCEL_CONFIRMED_SALES"):
return self.status == self.STATUS_QUOTE
return self.status in (Sale.STATUS_CONFIRMED, Sale.STATUS_ORDERED,
Sale.STATUS_QUOTE)
[docs] def can_return(self):
"""Only confirmed (with or without payment) sales can be returned
:returns: ``True`` if the sale can be returned
"""
return self.status == Sale.STATUS_CONFIRMED
[docs] def can_edit(self):
"""Check if the sale can be edited.
Only quoting and ordered sales can be edited, as long
as they are not external.
:returns: ``True`` if the sale can be edited
"""
if self.is_external():
return False
return (self.status == Sale.STATUS_QUOTE or
self.status == Sale.STATUS_ORDERED)
[docs] def is_external(self):
"""Check if this is an external sale.
:rtype: bool
"""
return bool(SaleIsExternalEvent.emit(self))
def is_returned(self):
return self.status == Sale.STATUS_RETURNED
[docs] def order(self):
"""Orders the sale
Ordering a sale is the first step done after creating it.
The state of the sale will change to Sale.STATUS_ORDERED.
To order a sale you need to add sale items to it.
A |client| might also be set for the sale, but it is not necessary.
"""
assert self.can_order()
if self.get_items().is_empty():
raise SellError(_('The sale must have sellable items'))
if self.client and not self.client.is_active:
raise SellError(_('Unable to make sales for clients with status '
'%s') % self.client.get_status_string())
self._set_sale_status(Sale.STATUS_ORDERED)
[docs] def confirm(self, till=None):
"""Confirms the sale
Confirming a sale means that the customer has confirmed the sale.
Sale items containing products are physically received and
the payments are agreed upon but not necessarily received.
All money payments will be set as paid.
:param till: the |till| where this sale was confirmed. Can
be `None` in case the process was automated (e.g. a virtual store)
"""
assert self.can_confirm()
assert self.branch
# FIXME: We should use self.branch, but it's not supported yet
store = self.store
branch = get_current_branch(store)
for item in self.get_items():
self.validate_batch(item.batch, sellable=item.sellable)
if item.sellable.product:
ProductHistory.add_sold_item(store, branch, item)
item.sell(branch)
self.total_amount = self.get_total_sale_amount()
self.group.confirm()
self._add_inpayments(till=till)
self._create_fiscal_entries()
# Save operation_nature and branch in Invoice table.
self.invoice.branch = branch
if self._create_commission_at_confirm():
for payment in self.payments:
self.create_commission(payment)
if self.client:
self.group.payer = self.client.person
self.confirm_date = TransactionTimestamp()
# When confirming a sale, all credit and money payments are
# automatically paid.
# Since some plugins may listen to the sale status change event, we should
# set payments as paid before the status change.
for method in self.store.find(PaymentMethod):
if method.operation.pay_on_sale_confirm():
self.group.pay_method_payments(method.method_name)
old_status = self.status
self._set_sale_status(Sale.STATUS_CONFIRMED)
if self.current_sale_token:
self.current_sale_token.close_token()
# do not log money payments twice
if not self.only_paid_with_money():
if self.client:
msg = _(u"Sale {sale_number} to client {client_name} was "
u"confirmed with value {total_value:.2f}.").format(
sale_number=self.identifier,
client_name=self.client.person.name,
total_value=self.get_total_sale_amount())
else:
msg = _(u"Sale {sale_number} without a client was "
u"confirmed with value {total_value:.2f}.").format(
sale_number=self.identifier,
total_value=self.get_total_sale_amount())
Event.log(self.store, Event.TYPE_SALE, msg)
StockOperationConfirmedEvent.emit(self, old_status)
[docs] def set_paid(self):
"""Mark the sale as paid
Marking a sale as paid means that all the payments have been received.
"""
assert self.can_set_paid()
# Right now commissions are created when the payment is confirmed
# (if the parameter is set) or when the payment is confirmed.
# This code is still here for the users that some payments created
# (and paid) but no commission created yet.
# This can be removed sometime in the future.
for payment in self.payments:
self.create_commission(payment)
self.close_date = TransactionTimestamp()
self.paid = True
if self.only_paid_with_money():
# Money payments are confirmed and paid, so lof them that way
if self.client:
msg = _(u"Sale {sale_number} to client {client_name} was paid "
u"and confirmed with value {total_value:.2f}.").format(
sale_number=self.identifier,
client_name=self.client.person.name,
total_value=self.get_total_sale_amount())
else:
msg = _(u"Sale {sale_number} without a client was paid "
u"and confirmed with value {total_value:.2f}.").format(
sale_number=self.identifier,
total_value=self.get_total_sale_amount())
else:
if self.client:
msg = _(u"Sale {sale_number} to client {client_name} was paid "
u"with value {total_value:.2f}.").format(
sale_number=self.identifier,
client_name=self.client.person.name,
total_value=self.get_total_sale_amount())
else:
msg = _(u"Sale {sale_number} without a client was paid "
u"with value {total_value:.2f}.").format(
sale_number=self.identifier,
total_value=self.get_total_sale_amount())
Event.log(self.store, Event.TYPE_SALE, msg)
[docs] def set_not_paid(self):
"""Mark a sale as not paid. This happens when the user sets a
previously paid payment as not paid.
"""
assert self.can_set_not_paid()
self.close_date = None
self.paid = False
[docs] def set_renegotiated(self):
"""Set the sale as renegotiated. The sale payments have been
renegotiated and the operations will be done in
another |paymentgroup|."""
assert self.can_set_renegotiated()
self.close_date = TransactionTimestamp()
self._set_sale_status(Sale.STATUS_RENEGOTIATED)
[docs] def set_not_returned(self):
"""Sets a sale as not returnd
This will reset the sale status to confirmed (once you can only returna
confirmed sale). Also, the return_date will be reset.
"""
self._set_sale_status(Sale.STATUS_CONFIRMED)
self.return_date = None
[docs] def cancel(self, reason, force=False):
"""Cancel the sale
You can only cancel an ordered sale. This will also cancel
all the payments related to it.
:param reason: A short text describing the cancellation reason.
:param force: if ``True``, :meth:`.can_cancel` will not be asserted.
Only use this if you really need to (for example, when canceling
the last sale on the ecf)
"""
if not force:
assert self.can_cancel()
branch = get_current_branch(self.store)
for item in self.get_items():
item.cancel(branch)
self.cancel_date = TransactionTimestamp()
self.cancel_reason = reason
self.cancel_responsible = api.get_current_user(self.store)
self.paid = False
# Cancel payments
for payment in self.payments:
if payment.can_cancel():
payment.cancel()
if self.current_sale_token:
self.current_sale_token.close_token()
self._set_sale_status(Sale.STATUS_CANCELLED)
[docs] def return_(self, returned_sale):
"""Returns a sale
Returning a sale means that all the items are returned to the stock.
A renegotiation object needs to be supplied which
contains the invoice number and the eventual penalty
:param returned_sale: a :class:`stoqlib.domain.returnedsale.ReturnedSale`
object. It can be created by :meth:`create_sale_return_adapter`
"""
assert self.can_return()
assert isinstance(returned_sale, ReturnedSale)
totally_returned = all([sale_item.is_totally_returned() for
sale_item in self.get_items()])
if totally_returned:
self.return_date = TransactionTimestamp()
self._set_sale_status(Sale.STATUS_RETURNED)
self.paid = False
if self.client:
if totally_returned:
msg = _(u"Sale {sale_number} to client {client_name} was "
u"totally returned with value {total_value:.2f}. "
u"Reason: {reason}")
else:
msg = _(u"Sale {sale_number} to client {client_name} was "
u"partially returned with value {total_value:.2f}. "
u"Reason: {reason}")
msg = msg.format(sale_number=self.identifier,
client_name=self.client.person.name,
total_value=returned_sale.returned_total,
reason=returned_sale.reason)
else:
if totally_returned:
msg = _(u"Sale {sale_number} without a client was "
u"totally returned with value {total_value:.2f}. "
u"Reason: {reason}")
else:
msg = _(u"Sale {sale_number} without a client was "
u"partially returned with value {total_value:.2f}. "
u"Reason: {reason}")
msg = msg.format(sale_number=self.identifier,
total_value=returned_sale.returned_total,
reason=returned_sale.reason)
Event.log(self.store, Event.TYPE_SALE, msg)
[docs] def set_items_discount(self, discount):
"""Apply discount on this sale's items
:param decimal.Decimal discount: the discount to be applied
as a percentage, e.g. 10.0, 22.5
"""
new_total = currency(0)
items = []
for item in self.get_items():
sellable = item.sellable
if sellable.product and sellable.product.is_package:
# We should not set discount for package_products
continue
items.append(item)
item.set_discount(discount)
new_total += item.price * item.quantity
# Since we apply the discount percentage above, items can generate a
# 3rd decimal place, that will be rounded to the 2nd, making the value
# differ. Find that difference and apply it to a sale item. The sale
# item that will be used for this rounding is the first one that the
# quantity can divide the diff.
sale_base_subtotal = self.get_sale_base_subtotal()
discount_value = quantize((sale_base_subtotal * discount) / 100)
diff = new_total - sale_base_subtotal + discount_value
if diff:
# The value cannot be <= 0
# Note that we should use price instead of base_price, since the
# for above may have changed the price already
for item in items:
if (diff * 100) % item.quantity == 0:
item.price = max(item.price - diff / item.quantity, Decimal('0.01'))
break
#
# Accessors
#
[docs] def get_total_sale_amount(self, subtotal=None):
"""
Fetches the total value paid by the |client|.
It can be calculated as::
Sale total = Sum(product and service prices) + surcharge +
interest - discount
:param subtotal: pre calculated subtotal, pass in this to avoid
a querying the database
:returns: the total value
"""
if subtotal is None:
subtotal = self.get_sale_subtotal()
surcharge_value = self.surcharge_value or Decimal(0)
discount_value = self.discount_value or Decimal(0)
total_amount = subtotal + surcharge_value - discount_value
return currency(total_amount)
[docs] def get_sale_subtotal(self):
"""Fetch the subtotal for the sale, eg the sum of the
prices for of all items.
:returns: subtotal
"""
total = 0
for sale_item in self.get_items():
total += sale_item.get_total()
return currency(total)
[docs] def get_sale_base_subtotal(self):
"""Get the base subtotal of items
Just a helper that, unlike :meth:`.get_sale_subtotal`, will
return the total based on item's base price.
:returns: the base subtotal
"""
items = self.get_items()
subtotal = 0
for item in items:
sellable = item.sellable
if sellable.product and sellable.product.is_package:
# We should not sum package_product
continue
if item.parent_item:
component = item.get_component(item.parent_item)
subtotal += item.quantity * component.price
else:
subtotal += item.quantity * item.base_price
return currency(subtotal)
[docs] def get_items_total_quantity(self):
"""Fetches the total number of items in the sale
:returns: number of items
"""
return self.get_items().sum(SaleItem.quantity) or Decimal(0)
[docs] def get_total_paid(self):
"""Return the total amount already paid for this sale
:returns: the total amount paid
"""
total_paid = 0
for payment in self.group.get_valid_payments():
if payment.is_inpayment() and payment.is_paid():
# Already paid by client. Value instead of paid_value as the
# second might have penalties and discounts not applicable here
total_paid += payment.value
elif payment.is_outpayment():
# Already returned to client
total_paid -= payment.value
return currency(total_paid)
[docs] def get_total_to_pay(self):
"""Missing payment value for this sale.
Returns the value the client still needs to pay for this sale.
This is the same as
:meth:`.get_total_sale_amount` - :meth:`.get_total_paid`
"""
return currency(self.get_total_sale_amount() - self.get_total_paid())
[docs] def get_returned_value(self):
"""The total value returned from this sale.
This will return the sum of all returned sales of this sale.
"""
return currency(sum(i.returned_total for i in self.returned_sales))
[docs] def get_available_discount_for_items(self, user=None, exclude_item=None):
"""Get available discount for items in this sale
The available items discount is the total discount not used
by items in this sale. For instance, if we have 2 products
with a price of 100 and they can have 10% of discount, we have
20 of discount available. If one of those products price
is set to 98, that is, using 2 of it's discount, the available
discount is now 18.
:param user: passed to
:meth:`stoqlib.domain.sellable.Sellable.get_maximum_discount`
together with :obj:`.client_category` to check for the max
discount for sellables on this sale
:param exclude_item: a |saleitem| to exclude from the calculations.
Useful if you are trying to get some extra discount for that
item and you don't want it's discount to be considered here
:returns: the available discount
"""
available_discount = currency(0)
used_discount = currency(0)
for item in self.get_items():
if item == exclude_item:
continue
# Don't put surcharges on the discount, or it can end up negative
if item.price > item.base_price:
continue
used_discount += item.base_price - item.price
max_discount = item.sellable.get_maximum_discount(
category=self.client_category, user=user) / 100
available_discount += item.base_price * max_discount
return available_discount - used_discount
[docs] def get_details_str(self):
"""Returns the sale details
The details are composed by the items notes, the delivery
address and the estimated fix date
Note that there might be some extra comments on :obj:`.comments`
:returns: the sale details string.
"""
details = []
delivery_added = False
for sale_item in self.get_items():
if delivery_added is False:
# FIXME: Add the delivery info just once can lead to an error.
# It's possible that some item went to delivery X while
# some went to delivery Y.
delivery = sale_item.delivery
if delivery is not None:
details.append(_(u'Delivery Address: %s') %
delivery.address.get_address_string())
# At the moment, we just support only one delivery per sale.
delivery_added = True
delivery = None
else:
if sale_item.notes:
details.append(_(u'"%s" Notes: %s') % (
sale_item.get_description(), sale_item.notes))
if sale_item.is_service() and sale_item.estimated_fix_date:
details.append(_(u'"%s" Estimated Fix Date: %s') % (
sale_item.get_description(),
sale_item.estimated_fix_date.strftime('%x')))
return u'\n'.join(sorted(details))
[docs] def get_salesperson_name(self):
"""
:returns: the sales person name
"""
return self.salesperson.get_description()
[docs] def get_client_name(self):
"""Returns the client name, if a |client| has been provided for
this sale
:returns: the client name of a place holder string for sales without
clients set.
"""
if not self.client:
return _(u'Not Specified')
return self.client.get_name()
[docs] def get_client_document(self):
"""Returns the client document for this sale
This could be either its cnpj or cpf.
"""
if not self.client_id:
return None
return self.client.person.get_cnpj_or_cpf()
# FIXME: move over to client or person
[docs] def get_client_role(self):
"""Fetches the client role
:returns: the client role (an |individual| or a |company|) instance or
None if the sale haven't |client| set.
"""
if not self.client:
return None
client_role = self.client.person.has_individual_or_company_facets()
if client_role is None:
raise DatabaseInconsistency(
_(u"The sale %r have a client but no "
u"client_role defined.") % self)
return client_role
[docs] def get_items_missing_batch(self):
"""Get all |saleitems| missing |batch|
This usually happens when we create a quote. Since we are
not removing the items from the stock, they probably were
not set on the |saleitem|.
:returns: a result set of |saleitems| that needs to set
set the batch information
"""
return self.store.find(
SaleItem,
And(SaleItem.sale_id == self.id,
SaleItem.sellable_id == Sellable.id,
Sellable.id == Storable.id,
Eq(Storable.is_batch, True),
Eq(SaleItem.batch_id, None)))
[docs] def need_adjust_batches(self):
"""Checks if we need to set |batches| for this sale's |saleitems|
This usually happens when we create a quote. Since we are
not removing the items from the stock, they probably were
not set on the |saleitem|.
:returns: ``True`` if any |saleitem| needs a |batch|,
``False`` otherwise.
"""
return not self.get_items_missing_batch().is_empty()
[docs] def check_and_adjust_batches(self):
""" Check batches and perform a first adjustment when a sale item has
only one batch.
:returns: ``True`` if all items that need a batch were adjusted, or
``False`` if there are items that were not possible to be adjusted.
"""
# No batchs or batches already adjusted
if not self.need_adjust_batches():
return True
all_adjusted = True
sale_items = self.get_items_missing_batch()
# Set unique batch.
for item in sale_items:
storable = item.sellable.product_storable
available_batches = list(storable.get_available_batches(self.branch))
if len(available_batches) == 1:
item.batch = available_batches[0]
else:
all_adjusted = False
return all_adjusted
[docs] def only_paid_with_money(self):
"""Find out if the sale is paid using money
:returns: ``True`` if the sale was paid with money
"""
if self.payments.is_empty():
return False
return all(payment.is_of_method(u'money') for payment in self.payments)
[docs] def add_sellable(self, sellable, quantity=1, price=None,
quantity_decreased=0, batch=None, parent=None):
"""Adds a new item to a sale.
:param sellable: the |sellable|
:param quantity: quantity to add, defaults to 1
:param price: optional, the price, it not set the price
from the sellable will be used
:param quantity_decreased: the quantity already decreased from
stock. e.g. The param quantity 10 and that quantity were already
decreased, so this param should be 10 too.
:param batch: the |batch| this sellable comes from, if the sellable is a
storable. Should be ``None`` if it is not a storable or if the storable
does not have batches.
:param parent: a |sale_item| parent_item of another |sale_item|
:returns: a |saleitem| for representing the
sellable within this sale.
"""
# Quote can add items without batches, but they will be validated
# after on self.confirm
if self.status not in (self.STATUS_QUOTE, self.STATUS_ORDERED):
self.validate_batch(batch, sellable=sellable)
if price is None:
price = sellable.price
return SaleItem(store=self.store,
quantity=quantity,
quantity_decreased=quantity_decreased,
sale=self,
sellable=sellable,
batch=batch,
price=price,
parent_item=parent)
def create_sale_return_adapter(self):
store = self.store
current_user = get_current_user(store)
returned_sale = ReturnedSale(
store=store,
sale=self,
branch=get_current_branch(store),
responsible=current_user,
)
for sale_item in self.get_items(with_children=False):
if sale_item.is_totally_returned():
# Exclude quantities already returned from this one
continue
r_item = ReturnedSaleItem(
store=store,
sale_item=sale_item,
returned_sale=returned_sale,
quantity=sale_item.quantity_decreased,
batch=sale_item.batch,
# XXX Please note that when applying the sale discount in the
# items, ther may be some rounding issues, leaving the total
# value either greater or lower than the expected value.
price=sale_item.price_with_discount
)
for child in sale_item.children_items:
ReturnedSaleItem(store=store,
sale_item=child,
returned_sale=returned_sale,
quantity=child.quantity_decreased,
batch=child.batch,
price=child.price_with_discount,
parent_item=r_item)
return returned_sale
[docs] def create_commission(self, payment):
"""Creates a commission for the *payment*
This will create a |commission| for the given |payment|,
:obj:`.sale` and :obj:`.sale.salesperson`. Note that, if the
payment already has a commission, nothing will be done.
"""
from stoqlib.domain.commission import Commission
if payment.has_commission():
return
commission = Commission(
commission_type=self._get_commission_type(),
sale=self,
payment=payment,
store=self.store)
if payment.is_outpayment():
commission.value = -commission.value
return commission
def get_first_sale_comment(self):
first_comment = self.comments.first()
if first_comment:
return first_comment.comment
return u''
def get_delivery_item(self):
delivery_service_id = sysparam.get_object_id('DELIVERY_SERVICE')
for item in self.get_items():
if item.sellable.id == delivery_service_id:
return item
return None
#
# Properties
#
@property
def status_str(self):
return self.get_status_name(self.status)
@property
def current_sale_token(self):
"""The current token attached to this sale."""
return self.store.find(SaleToken, sale=self).one()
@property
def products(self):
"""All |saleitems| of this sale containing a |product|.
:returns: the result set containing the |saleitems|, ordered
by :attr:`stoqlib.domain.sellable.Sellable.code`
"""
return self.store.find(
SaleItem,
And(SaleItem.sale_id == self.id,
SaleItem.sellable_id == Sellable.id,
Sellable.id == Product.id)).order_by(
Sellable.code)
@property
def services(self):
"""All |saleitems| of this sale containing a |service|.
:returns: the result set containing the |saleitems|, ordered
by :attr:`stoqlib.domain.sellable.Sellable.code`
"""
return self.store.find(
SaleItem,
And(SaleItem.sale_id == self.id,
SaleItem.sellable_id == Sellable.id,
Sellable.id == Service.id)).order_by(
Sellable.code)
@property
def payments(self):
"""Returns all valid payments for this sale ordered by open date
This will return a list of valid payments for this sale, that
is, all payments on the |paymentgroups| that were not cancelled.
If you need to get the cancelled too, use :obj:`.group.payments`.
:returns: an ordered iterable of |payment|.
"""
return self.group.get_valid_payments().order_by(Payment.open_date)
@property
def discount_percentage(self):
"""Sets a discount by percentage.
Note that percentage must be added as an absolute value, in other
words::
sale.total_sale_amount = 200
sale.discount_percentage = 5
# the price of the sale will now be be `190`
"""
discount_value = self.discount_value
if not discount_value:
return Decimal(0)
subtotal = self.get_sale_subtotal()
assert subtotal > 0, ('the sale subtotal should not be zero '
'at this point')
total = subtotal - discount_value
percentage = (1 - total / subtotal) * 100
return quantize(percentage)
@discount_percentage.setter
def discount_percentage(self, value):
self.discount_value = self._get_percentage_value(value)
@property
def surcharge_percentage(self):
"""Sets a discount by percentage.
Note that percentage must be added as an absolute value, in other
words::
sale.total_sale_amount = 200
sale.surcharge_percentage = 5
# the price of the sale will now be `210`
"""
surcharge_value = self.surcharge_value
if not surcharge_value:
return Decimal(0)
subtotal = self.get_sale_subtotal()
assert subtotal > 0, ('the sale subtotal should not be zero '
'at this point')
total = subtotal + surcharge_value
percentage = ((total / subtotal) - 1) * 100
return quantize(percentage)
@surcharge_percentage.setter
def surcharge_percentage(self, value):
self.surcharge_value = self._get_percentage_value(value)
#
# Private API
#
def _set_sale_status(self, status):
old_status = self.status
self.status = status
SaleStatusChangedEvent.emit(self, old_status)
def _get_percentage_value(self, percentage):
if not percentage:
return currency(0)
subtotal = self.get_sale_subtotal()
percentage = Decimal(percentage)
perc_value = subtotal * (percentage / Decimal(100))
# discount/surchage cannot have more than 2 decimal points
return quantize(currency(perc_value))
def _add_inpayments(self, till=None):
payments = self.payments
if not payments.count():
raise ValueError(
_('You must have at least one payment for each payment group'))
if till is None:
return
for payment in payments:
if not payment.is_inpayment():
# There may be a change payment if the client has overpaid the
# sale.
continue
till.add_entry(payment)
def _create_commission_at_confirm(self):
return sysparam.get_bool('SALE_PAY_COMMISSION_WHEN_CONFIRMED')
def _get_commission_type(self):
from stoqlib.domain.commission import Commission
nitems = 0
for item in self.payments:
if not item.is_outpayment():
nitems += 1
if nitems <= 1:
return Commission.DIRECT
return Commission.INSTALLMENTS
def _get_pm_commission_total(self):
"""Return the payment method commission total. Usually credit
card payment method is the most common method which uses
commission
"""
return currency(0)
def _get_icms_total(self, av_difference):
"""A Brazil-specific method
Calculates the icms total value
:param av_difference: the average difference for the sale items.
it means the average discount or surcharge
applied over all sale items
"""
icms_total = Decimal(0)
for item in self.products:
price = item.price + av_difference
sellable = item.sellable
tax_constant = sellable.get_tax_constant()
if tax_constant is None or tax_constant.tax_type != TaxType.CUSTOM:
continue
icms_tax = tax_constant.tax_value / Decimal(100)
icms_total += icms_tax * (price * item.quantity)
return icms_total
def _get_iss_total(self, av_difference):
"""A Brazil-specific method
Calculates the iss total value
:param av_difference: the average difference for the sale items.
it means the average discount or surcharge
applied over all sale items
"""
iss_total = Decimal(0)
iss_tax = sysparam.get_decimal('ISS_TAX') / Decimal(100)
for item in self.services:
price = item.price + av_difference
iss_total += iss_tax * (price * item.quantity)
return iss_total
def _get_average_difference(self):
if self.get_items().is_empty():
raise DatabaseInconsistency(
_(u"Sale orders must have items, which means products or "
u"services"))
total_quantity = self.get_items_total_quantity()
if not total_quantity:
raise DatabaseInconsistency(
_(u"Sale total quantity should never be zero"))
# If there is a discount or a surcharge applied in the whole total
# sale amount, we must share it between all the item values
# otherwise the icms and iss won't be calculated properly
total = (self.get_total_sale_amount() -
self._get_pm_commission_total())
subtotal = self.get_sale_subtotal()
return (total - subtotal) / total_quantity
def _get_iss_entry(self):
return FiscalBookEntry.get_entry_by_payment_group(
self.store, self.group,
FiscalBookEntry.TYPE_SERVICE)
def _create_fiscal_entries(self):
"""A Brazil-specific method
Create new ICMS and ISS entries in the fiscal book
for a given sale.
Important: freight and interest are not part of the base value for
ICMS. Only product values and surcharge which applies increasing the
product totals are considered here.
"""
av_difference = self._get_average_difference()
if not self.products.is_empty():
FiscalBookEntry.create_product_entry(
self.store,
self.group, self.cfop, self.coupon_id,
self._get_icms_total(av_difference))
if not self.services.is_empty() and self.service_invoice_number:
FiscalBookEntry.create_service_entry(
self.store,
self.group, self.cfop, self.service_invoice_number,
self._get_iss_total(av_difference))
[docs]class SaleToken(Domain):
"""A Token to help on sale for restaurants
This will be attached to a |sale| to help the sale for restaurants, hotels.
eg: table 1, table 2, room 12, room 334
"""
__storm_table__ = 'sale_token'
STATUS_AVAILABLE = u'available'
STATUS_OCCUPIED = u'occupied'
statuses = {STATUS_AVAILABLE: _(u'Available'),
STATUS_OCCUPIED: _(u'Occupied')}
#: the status of the sale_token
status = EnumCol(allow_none=False, default=STATUS_AVAILABLE)
#: the code that used to identify the token
code = UnicodeCol()
#: The name of the token
name = UnicodeCol()
sale_id = IdCol()
#: The |sale| that this token is attached to
sale = Reference(sale_id, 'Sale.id')
branch_id = IdCol()
#: The |branch| that this token belongs
branch = Reference(branch_id, 'Branch.id')
@property
def status_str(self):
return self.statuses[self.status]
@property
def description(self):
return "[{}] {}".format(self.code, self.name)
#
# Public API
#
def open_token(self, sale):
assert self.can_open()
self.sale = sale
sale.sale_token = self
self.status = SaleToken.STATUS_OCCUPIED
def close_token(self):
assert self.can_close()
self.sale = None
self.status = SaleToken.STATUS_AVAILABLE
def can_open(self):
return self.status == SaleToken.STATUS_AVAILABLE
def can_close(self):
return self.status == SaleToken.STATUS_OCCUPIED
[docs]class SaleTokenView(Viewable):
"""Sale token view."""
ClientPerson = ClassAlias(Person, 'client_person')
ClientCompany = ClassAlias(Company, 'client_company')
BranchPerson = ClassAlias(Person, 'branch_person')
BranchCompany = ClassAlias(Company, 'branch_company')
sale_token = SaleToken
sale = Sale
client = Client
branch = Branch
id = SaleToken.id
name = SaleToken.name
code = SaleToken.code
status = SaleToken.status
client_id = Client.id
client_name = Coalesce(NullIf(ClientCompany.fancy_name, u''), ClientPerson.name)
branch_id = Branch.id
branch_name = Coalesce(NullIf(BranchCompany.fancy_name, u''), BranchPerson.name)
sale_identifier = Sale.identifier
sale_identifier_str = Cast(Sale.identifier, 'text')
tables = [
SaleToken,
# Sale
LeftJoin(Sale, SaleToken.sale_id == Sale.id),
# Client
LeftJoin(Client, Sale.client_id == Client.id),
LeftJoin(ClientPerson, Client.person_id == ClientPerson.id),
LeftJoin(ClientCompany, ClientCompany.person_id == ClientPerson.id),
# Branch
LeftJoin(Branch, SaleToken.branch_id == Branch.id),
LeftJoin(BranchPerson, Branch.person_id == BranchPerson.id),
LeftJoin(BranchCompany, BranchCompany.person_id == BranchPerson.id),
]
@property
def status_str(self):
return SaleToken.statuses[self.status]
#
# Views
#
class ReturnedSaleItemsView(Viewable):
branch = Branch
returned_sale = ReturnedSale
sellable = Sellable
# returned and original sale item
id = ReturnedSaleItem.id
quantity = ReturnedSaleItem.quantity
price = ReturnedSaleItem.price
parent_item_id = ReturnedSaleItem.parent_item_id
# returned and original sale
_sale_id = Sale.id
_new_sale_id = ReturnedSale.new_sale_id
returned_identifier = ReturnedSale.identifier
invoice_number = Invoice.invoice_number
return_date = ReturnedSale.return_date
reason = ReturnedSale.reason
# sellable
sellable_id = ReturnedSaleItem.sellable_id
code = Sellable.code
description = Sellable.description
batch_number = Coalesce(StorableBatch.batch_number, u'')
batch_date = StorableBatch.create_date
# summaries
total = ReturnedSaleItem.price * ReturnedSaleItem.quantity
tables = [
ReturnedSaleItem,
LeftJoin(StorableBatch, StorableBatch.id == ReturnedSaleItem.batch_id),
Join(SaleItem, SaleItem.id == ReturnedSaleItem.sale_item_id),
Join(Sellable, Sellable.id == ReturnedSaleItem.sellable_id),
Join(ReturnedSale, ReturnedSale.id == ReturnedSaleItem.returned_sale_id),
LeftJoin(Invoice, Invoice.id == ReturnedSale.invoice_id),
Join(Sale, Sale.id == ReturnedSale.sale_id),
# Note that the sale branch may be different than the returned sale
# branch
Join(Branch, Branch.id == ReturnedSale.branch_id),
]
@property
def new_sale(self):
if not self._new_sale_id:
return None
return self.store.get(Sale, self._new_sale_id)
#
# Class methods
#
@classmethod
def find_by_sale(cls, store, sale):
return store.find(cls, _sale_id=sale.id).order_by(ReturnedSale.return_date)
@classmethod
def find_parent_items(cls, store, sale):
query = And(cls.returned_sale.sale_id == sale.id,
Eq(cls.parent_item_id, None))
return store.find(cls, query)
#
# Public API
#
def get_children(self):
return self.store.find(ReturnedSaleItemsView,
ReturnedSaleItemsView.parent_item_id == self.id)
def is_package(self):
if self.sellable.product is None:
# Services
return False
product = self.store.get(Product, self.sellable.product.id)
return product.is_package
_SaleItemSummary = Select(columns=[SaleItem.sale_id,
Alias(Sum(InvoiceItemIpi.v_ipi), 'v_ipi'),
Alias(Sum(SaleItem.quantity), 'total_quantity'),
Alias(Sum(SaleItem.quantity *
SaleItem.price), 'subtotal')],
tables=[SaleItem,
LeftJoin(InvoiceItemIpi,
InvoiceItemIpi.id == SaleItem.ipi_info_id)],
group_by=[SaleItem.sale_id])
SaleItemSummary = Alias(_SaleItemSummary, '_sale_item')
[docs]class SaleView(Viewable):
"""Stores general informatios about sales
"""
Person_Branch = ClassAlias(Person, 'person_branch')
Person_Client = ClassAlias(Person, 'person_client')
Person_SalesPerson = ClassAlias(Person, 'person_sales_person')
Company_Branch = ClassAlias(Company, 'company_branch')
Company_Client = ClassAlias(Company, 'company_client')
#: the |sale| of the view
sale = Sale
#: the |client| of the view
client = Client
#: The branch this sale was sold
branch = Branch
#: The |invoice| of the view
invoice = Invoice
#: The token referencing this sale
token = SaleToken
#: the id of the sale table
id = Sale.id
#: unique numeric identifier for the sale
identifier = Sale.identifier
#: unique numeric identifier for the sale, text representation
identifier_str = Cast(Sale.identifier, 'text')
#: The code of the current token holding the sale
token_code = SaleToken.code
#: The name of the current token holding the sale
token_name = SaleToken.name
#: the sale invoice number
invoice_number = Invoice.invoice_number
#: the id generated by the fiscal printer
coupon_id = Sale.coupon_id
#: the date when the sale was started
open_date = Sale.open_date
#: the date when the sale was closed
close_date = Sale.close_date
#: the date when the sale was confirmed
confirm_date = Sale.confirm_date
#: the date when the sale was cancelled
cancel_date = Sale.cancel_date
#: the date when the sale was returned
return_date = Sale.return_date
#: the date when the sale will expire
expire_date = Sale.expire_date
#: the sale status
status = Sale.status
#: the flag that indicates if the sale is completely paid
paid = Sale.paid
#: the sale surcharge value
surcharge_value = Sale.surcharge_value
#: the sale discount value
discount_value = Sale.discount_value
#: the |branch| where this |sale| was sold
branch_id = Sale.branch_id
#: the if of the |client| table
client_id = Client.id
#: the salesperson name
salesperson_name = Coalesce(Person_SalesPerson.name, u'')
#: the |sale| salesperson id
salesperson_id = SalesPerson.id
#: the |sale| client name
client_name = Coalesce(Person_Client.name, u'')
#: the |sale| client fancy name
client_fancy_name = Company_Client.fancy_name
#: name of the |branch| this |sale| was sold
branch_name = Coalesce(NullIf(Company_Branch.fancy_name, u''), Person_Branch.name)
# Summaries
v_ipi = Coalesce(Field('_sale_item', 'v_ipi'), 0)
#: the sum of all items in the sale
_subtotal = Coalesce(Field('_sale_item', 'subtotal'), 0) + v_ipi
#: the items total quantity for the sale
total_quantity = Coalesce(Field('_sale_item', 'total_quantity'), 0)
#: the subtotal - discount + charge
_total = Coalesce(Field('_sale_item', 'subtotal'), 0) - \
Sale.discount_value + Sale.surcharge_value + v_ipi
tables = [
Sale,
LeftJoin(SaleItemSummary, Field('_sale_item', 'sale_id') == Sale.id),
LeftJoin(Branch, Sale.branch_id == Branch.id),
LeftJoin(Client, Sale.client_id == Client.id),
LeftJoin(SalesPerson, Sale.salesperson_id == SalesPerson.id),
LeftJoin(Invoice, Sale.invoice_id == Invoice.id),
LeftJoin(Person_Branch, Branch.person_id == Person_Branch.id),
LeftJoin(Company_Branch, Company_Branch.person_id == Person_Branch.id),
LeftJoin(Person_Client, Client.person_id == Person_Client.id),
LeftJoin(Company_Client, Company_Client.person_id == Person_Client.id),
LeftJoin(Person_SalesPerson, SalesPerson.person_id == Person_SalesPerson.id),
LeftJoin(SaleToken, SaleToken.sale_id == Sale.id),
]
@classmethod
def post_search_callback(cls, sresults):
select = sresults.get_select_expr(Count(1), Sum(cls._total))
return ('count', 'sum'), select
#
# Class methods
#
@classmethod
def find_by_branch(cls, store, branch):
if branch:
return store.find(cls, Sale.branch == branch)
return store.find(cls)
#
# Properties
#
@property
def token_str(self):
return self.token.description if self.token else ''
@property
def returned_sales(self):
return self.store.find(ReturnedSale, sale_id=self.id)
@property
def return_total(self):
store = self.store
returned_items = store.find(ReturnedSaleItemsView, Sale.id == self.id)
return currency(returned_items.sum(ReturnedSaleItemsView.total) or 0)
#
# Public API
#
def can_return(self):
return self.sale.can_return()
def can_confirm(self):
return self.sale.can_confirm()
def can_cancel(self):
return self.sale.can_cancel()
def can_edit(self):
return self.sale.can_edit()
@property
def subtotal(self):
# The editor requires the model to be a currency, but _subtotal is a
# decimal. So we need to convert it
return currency(self._subtotal)
@property
def total(self):
return currency(self._total)
@property
def open_date_as_string(self):
return self.open_date.strftime("%x")
@property
def status_name(self):
return Sale.get_status_name(self.status)
[docs]class ReturnedSaleView(Viewable):
"""Stores general informatios about returned sales."""
Person_Branch = ClassAlias(Person, 'person_branch')
Person_Client = ClassAlias(Person, 'person_client')
Person_SalesPerson = ClassAlias(Person, 'person_sales_person')
Person_LoginUser = ClassAlias(Person, 'person_login_user')
sale = Sale
client = Client
branch = Branch
returned_sale = ReturnedSale
returned_item = ReturnedSaleItem
# Sale
sale_id = Sale.id
# Returned Sale
id = ReturnedSaleItem.id
identifier = ReturnedSale.identifier
identifier_str = Cast(ReturnedSale.identifier, 'text')
invoice_number = Invoice.invoice_number
return_date = ReturnedSale.return_date
reason = ReturnedSale.reason
responsible_id = ReturnedSale.responsible_id
branch_id = ReturnedSale.branch_id
new_sale_id = ReturnedSale.new_sale_id
# Returned Sale Item
price = ReturnedSaleItem.price
quantity = ReturnedSaleItem.quantity
total = ReturnedSaleItem.price * ReturnedSaleItem.quantity
# Sellable
product_name = Sellable.description
# Person
salesperson_name = Person_SalesPerson.name
client_name = Person_Client.name
responsible_name = Person_LoginUser.name
# Branch
branch_name = Coalesce(NullIf(Company.fancy_name, u''), Person_Branch.name)
# Client
client_id = Client.id
tables = [
ReturnedSale,
Join(Sale, Sale.id == ReturnedSale.sale_id),
Join(ReturnedSaleItem,
ReturnedSaleItem.returned_sale_id == ReturnedSale.id),
Join(Sellable, Sellable.id == ReturnedSaleItem.sellable_id),
Join(Branch, ReturnedSale.branch_id == Branch.id),
Join(Person_Branch, Branch.person_id == Person_Branch.id),
Join(Company, Company.person_id == Person_Branch.id),
Join(SalesPerson, Sale.salesperson_id == SalesPerson.id),
Join(Person_SalesPerson,
SalesPerson.person_id == Person_SalesPerson.id),
Join(LoginUser, LoginUser.id == ReturnedSale.responsible_id),
Join(Person_LoginUser, Person_LoginUser.id == LoginUser.person_id),
LeftJoin(Invoice, Sale.invoice_id == Invoice.id),
LeftJoin(Client, Sale.client_id == Client.id),
LeftJoin(Person_Client, Client.person_id == Person_Client.id),
]
def get_children_items(self):
query = And(ReturnedSaleView.client_id == self.client_id,
ReturnedSaleItem.id.is_in([child.id
for child in self.returned_item.children_items]))
return self.store.find(ReturnedSaleView, query)
class SalePaymentMethodView(SaleView):
# If a sale has more than one payment, it will appear as much times in the
# search. Must always be used with select(distinct=True).
tables = SaleView.tables[:]
tables.append(LeftJoin(Payment, Sale.group_id == Payment.group_id))
#
# Class Methods
#
@classmethod
def find_by_payment_method(cls, store, method):
if method:
results = store.find(cls, Payment.method == method)
else:
results = store.find(cls)
results.config(distinct=True)
return results
class SoldSellableView(Viewable):
Person_Client = ClassAlias(Person, 'person_client')
Person_SalesPerson = ClassAlias(Person, 'person_sales_person')
sale_item = SaleItem
# Sellable
id = Sellable.id
code = Sellable.code
description = Sellable.description
# SaleItem
sale_item_id = SaleItem.id
# Client
client_id = Sale.client_id
client_name = Person_Client.name
# Aggregates
total_quantity = Sum(SaleItem.quantity)
subtotal = Sum(SaleItem.quantity * SaleItem.price)
group_by = [id, code, description, client_id, client_name, sale_item]
tables = [
Sellable,
LeftJoin(SaleItem, SaleItem.sellable_id == Sellable.id),
LeftJoin(Sale, Sale.id == SaleItem.sale_id),
LeftJoin(Client, Sale.client_id == Client.id),
LeftJoin(SalesPerson, Sale.salesperson_id == SalesPerson.id),
LeftJoin(Person_Client, Client.person_id == Person_Client.id),
LeftJoin(Person_SalesPerson, SalesPerson.person_id == Person_SalesPerson.id),
LeftJoin(InvoiceItemIpi, InvoiceItemIpi.id == SaleItem.ipi_info_id),
]
class SoldServicesView(SoldSellableView):
estimated_fix_date = SaleItem.estimated_fix_date
group_by = SoldSellableView.group_by[:]
group_by.append(estimated_fix_date)
tables = SoldSellableView.tables[:]
tables.append(Join(Service, Sellable.id == Service.id))
class SoldProductsView(SoldSellableView):
value = SaleItem.price
quantity = SaleItem.quantity
total_value = SaleItem.quantity * SaleItem.price
sale_date = Sale.open_date
tables = SoldSellableView.tables[:]
tables.append(Join(Product, Sellable.id == Product.id))
group_by = SoldSellableView.group_by[:]
group_by.append(sale_date)
def get_children_items(self):
query = And(SoldProductsView.client_id == self.client_id,
SaleItem.id.is_in([child.id for child in self.sale_item.children_items]))
return self.store.find(SoldProductsView, query)
# FIXME: This needs some more work, as currently, this viewable is:
# * Not filtering the paiments correctly given a date.
# * Not ignoring payments from returned sales
# Get the total amount already paid in a sale and group it by sales person
_PaidSale = Select(columns=[Sale.salesperson_id,
Alias(Sum(Payment.paid_value), 'paid_value')],
tables=[Sale, LeftJoin(Payment,
Payment.group_id == Sale.group_id)],
group_by=[Sale.salesperson_id])
PaidSale = Alias(_PaidSale, '_paid_sale')
class SalesPersonSalesView(Viewable):
id = SalesPerson.id
name = Person.name
# aggregates
total_amount = Sum(Sale.total_amount)
total_quantity = Sum(Field('_sale_item', 'total_quantity'))
total_sales = Count(Sale.id)
#paid_value = Field('_paid_sale', 'paid_value')
group_by = [id, name]
tables = [
SalesPerson,
LeftJoin(Sale, Sale.salesperson_id == SalesPerson.id),
LeftJoin(SaleItemSummary, Field('_sale_item', 'sale_id') == Sale.id),
LeftJoin(Person, Person.id == SalesPerson.person_id),
#LeftJoin(PaidSale, Field('_paid_sale', 'salesperson_id') == SalesPerson.id),
]
clause = Sale.status == Sale.STATUS_CONFIRMED
@classmethod
def find_by_date(cls, store, date):
if date:
if isinstance(date, tuple):
date_query = And(Date(Sale.confirm_date) >= date[0],
Date(Sale.confirm_date) <= date[1])
else:
date_query = Date(Sale.confirm_date) == date
results = store.find(cls, date_query)
else:
results = store.find(cls)
results.config(distinct=True)
return results
class ClientsWithSaleView(Viewable):
main_address = Address
city_location = CityLocation
id = Person.id
person_name = Person.name
phone = Person.phone_number
email = Person.email
cpf = Individual.cpf
birth_date = Individual.birth_date
cnpj = Company.cnpj
category = ClientCategory.name
sales = Count(Distinct(Sale.id))
sale_items = Sum(SaleItem.quantity)
total_amount = Sum(SaleItem.price * SaleItem.quantity)
last_purchase = Max(Sale.confirm_date)
tables = [
Person,
Join(Client, Person.id == Client.person_id),
LeftJoin(ClientCategory, ClientCategory.id == Client.category_id),
LeftJoin(Individual, Individual.person_id == Person.id),
LeftJoin(Company, Company.person_id == Person.id),
Join(Sale, Client.id == Sale.client_id),
Join(SaleItem, SaleItem.sale_id == Sale.id),
Join(Sellable, Sellable.id == SaleItem.sellable_id),
LeftJoin(SellableCategory, SellableCategory.id == Sellable.category_id),
LeftJoin(Address,
And(Address.person_id == Person.id,
Eq(Address.is_main_address, True))),
LeftJoin(CityLocation, Address.city_location_id == CityLocation.id),
]
group_by = [id, Individual.id, Company.id, ClientCategory.id,
Address.id, CityLocation.id]
clause = Sale.status == Sale.STATUS_CONFIRMED
#
# Public API
#
@property
def address_string(self):
return self.main_address.get_address_string()
@property
def details_string(self):
return self.main_address.get_details_string()
@property
def cnpj_or_cpf(self):
return self.cnpj or self.cpf
class SoldItemsByClient(Viewable):
product = Product
sellable = Sellable
id = Concat(Sellable.id, Person.name)
# Sellable
code = Sellable.code
description = Sellable.description
sellable_category = SellableCategory.description
# Client
client_name = Person.name
email = Person.email
phone_number = Person.phone_number
# Aggregates
base_price = Avg(SaleItem.base_price)
quantity = Sum(SaleItem.quantity)
price = Avg(SaleItem.price)
total = Sum(SaleItem.quantity * SaleItem.price)
tables = [
Sellable,
Join(SellableCategory, SellableCategory.id == Sellable.category_id),
Join(Product, Product.id == Sellable.id),
Join(SaleItem, SaleItem.sellable_id == Sellable.id),
Join(Sale, SaleItem.sale_id == Sale.id),
LeftJoin(Client, Client.id == Sale.client_id),
LeftJoin(Person, Person.id == Client.person_id),
]
clause = Or(Sale.status == Sale.STATUS_CONFIRMED,
Sale.status == Sale.STATUS_ORDERED)
group_by = [id, Person.id, Product, sellable_category, Sellable.id]