Source code for stoqlib.domain.sellable

# -*- coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4

##
## Copyright (C) 2005-2013 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 something that can be sold, such a |product|
or a |service|.

* :class:`Sellable` contains the description, price, cost, barcode etc.
* :class:`SellableCategory` provides a way to group sellables together, to be
  able to consistently tax, markup and calculate |commission|.
* :class:`SellableTaxConstant` contains the tax constant sent to an ECF printer.
* :class:`SellableUnit` contains the unit.
* :class:`ClientCategoryPrice` provides a price for |clients| in a |clientcategory|.
"""

# pylint: enable=E1101

from decimal import Decimal

from kiwi.currency import currency
from stoqdrivers.enum import TaxType, UnitType
from storm.expr import And, Or, In, Eq
from storm.references import Reference, ReferenceSet
from zope.interface import implementer

from stoqlib.database.properties import (BoolCol, DateTimeCol, EnumCol,
                                         IdCol, IntCol, PercentCol,
                                         PriceCol, UnicodeCol)
from stoqlib.domain.base import Domain
from stoqlib.domain.events import (CategoryCreateEvent, CategoryEditEvent,
                                   SellableCheckTaxesEvent)
from stoqlib.domain.interfaces import IDescribable
from stoqlib.domain.image import Image
from stoqlib.exceptions import SellableError, TaxError
from stoqlib.lib.defaults import quantize
from stoqlib.lib.dateutils import localnow
from stoqlib.lib.parameters import sysparam
from stoqlib.lib.translation import stoqlib_gettext
from stoqlib.lib.validators import is_date_in_interval

_ = stoqlib_gettext

# pyflakes: Sellable.has_image requires that Image is imported at least once
Image  # pylint: disable=W0104

#
# Base Domain Classes
#


@implementer(IDescribable)
[docs]class SellableUnit(Domain): """ The unit of a |sellable|. For instance: ``Kg`` (kilo), ``l`` (liter) and ``h`` (hour) When selling a sellable in a |sale| the quantity of a |saleitem| will be entered in this unit. See also: `schema <http://doc.stoq.com.br/schema/tables/sellable_unit.html>`__ """ __storm_table__ = 'sellable_unit' #: The values on the list are enums used to fill # ``'unit_index'`` column above. That list is useful for many things, # e.g. See if the user can delete the unit. It should not be possible # to delete a primitive one. SYSTEM_PRIMITIVES = [UnitType.WEIGHT, UnitType.METERS, UnitType.LITERS] #: The unit description description = UnicodeCol() # FIXME: Using an int cast on UnitType because # SQLObject doesn't recognize it's type. #: This column defines if this object represents a custom product unit #: (created by the user through the product editor) or a *native unit*, #: like ``Km``, ``Lt`` and ``pc``. #: #: This data is used mainly to interact with stoqdrivers, since when adding #: an item in a coupon we need to know if its unit must be specified as #: a description (using ``CUSTOM_PM`` constant) or as an index (using UNIT_*). #: Also, this is directly related to the DeviceSettings editor. unit_index = IntCol(default=int(UnitType.CUSTOM)) #: If the unit allows to be represented in fractions. #: e.g. We can have 1 car, 2 cars, but not 1/2 car. allow_fraction = BoolCol(default=True) # IDescribable def get_description(self): return self.description
@implementer(IDescribable)
[docs]class SellableTaxConstant(Domain): """A tax constant tied to a sellable See also: `schema <http://doc.stoq.com.br/schema/tables/sellable_tax_constant.html>`__ """ __storm_table__ = 'sellable_tax_constant' #: description of this constant description = UnicodeCol() #: a TaxType constant, used by ECF tax_type = IntCol() #: the percentage value of the tax tax_value = PercentCol(default=None) _mapping = { int(TaxType.NONE): u'TAX_NONE', # Não tributado - ICMS int(TaxType.EXEMPTION): u'TAX_EXEMPTION', # Isento - ICMS int(TaxType.SUBSTITUTION): u'TAX_SUBSTITUTION', # Substituição tributária - ICMS int(TaxType.SERVICE): u'TAX_SERVICE', # ISS }
[docs] def get_value(self): """ :returns: the value to pass to ECF """ return SellableTaxConstant._mapping.get( self.tax_type, self.tax_value)
@classmethod
[docs] def get_by_type(cls, tax_type, store): """Fetch the tax constant for tax_type :param tax_type: the tax constant to fetch :param store: a store :returns: a |sellabletaxconstant| or ``None`` if none is found """ return store.find(SellableTaxConstant, tax_type=int(tax_type)).one()
# IDescribable def get_description(self): return self.description
# pylint: disable=E1101 @implementer(IDescribable)
[docs]class SellableCategory(Domain): """ A Sellable category. A way to group several |sellables| together, like "Shoes", "Consumer goods", "Services". A category can define markup, tax and commission, the values of the category will only be used when the sellable itself lacks a value. Sellable categories can be grouped recursively. See also: `schema <http://doc.stoq.com.br/schema/tables/sellable_category.html>`__ """ __storm_table__ = 'sellable_category' #: The category description description = UnicodeCol() #: Define the suggested markup when calculating the sellable's price. suggested_markup = PercentCol(default=0) #: A percentage comission suggested for all the sales which products #: belongs to this category. salesperson_commission = PercentCol(default=0) category_id = IdCol(default=None) #: base category of this category, ``None`` for base categories themselves category = Reference(category_id, 'SellableCategory.id') tax_constant_id = IdCol(default=None) #: the |sellabletaxconstant| for this sellable category tax_constant = Reference(tax_constant_id, 'SellableTaxConstant.id') #: the children of this category children = ReferenceSet('id', 'SellableCategory.category_id') # # Properties # @property def full_description(self): """The full description of the category, including its parents, for instance: u"Clothes:Shoes:Black Shoe 14 SL" """ descriptions = [self.description] parent = self.category while parent: descriptions.append(parent.description) parent = parent.category return u':'.join(reversed(descriptions)) # # Public API #
[docs] def get_children_recursively(self): """Return all the children from this category, recursively This will return all children recursively, e.g.:: A / \ B C / \ D E In this example, calling this from A will return ``set([B, C, D, E])`` """ children = set(self.children) if not len(children): # Base case for the leafs return set() for child in list(children): children |= child.get_children_recursively() return children
[docs] def get_commission(self): """Returns the commission for this category. If it's unset, return the value of the base category, if any :returns: the commission """ if self.category: return (self.salesperson_commission or self.category.get_commission()) return self.salesperson_commission
[docs] def get_markup(self): """Returns the markup for this category. If it's unset, return the value of the base category, if any :returns: the markup """ if self.category: # Compare to None as markup can be '0' if self.suggested_markup is not None: return self.suggested_markup return self.category.get_markup() return self.suggested_markup
[docs] def get_tax_constant(self): """Returns the tax constant for this category. If it's unset, return the value of the base category, if any :returns: the tax constant """ if self.category: return self.tax_constant or self.category.get_tax_constant() return self.tax_constant
# # IDescribable # def get_description(self): return self.description # # Classmethods # @classmethod
[docs] def get_base_categories(cls, store): """Returns all available base categories :param store: a store :returns: categories """ return store.find(cls, category_id=None)
# # Domain hooks # def on_create(self): CategoryCreateEvent.emit(self) def on_update(self): CategoryEditEvent.emit(self)
# pylint: enable=E1101
[docs]class ClientCategoryPrice(Domain): """A table that stores special prices for |clients| based on their |clientcategory|. See also: `schema <http://doc.stoq.com.br/schema/tables/client_category_price.html>`__ """ __storm_table__ = 'client_category_price' sellable_id = IdCol() #: The |sellable| that has a special price sellable = Reference(sellable_id, 'Sellable.id') category_id = IdCol() #: The |clientcategory| that has the special price category = Reference(category_id, 'ClientCategory.id') #: The price for this (|sellable|, |clientcategory|) price = PriceCol(default=0) #: The max discount that may be applied. max_discount = PercentCol(default=0) @property def markup(self): if self.sellable.cost == 0: return Decimal(0) return ((self.price / self.sellable.cost) - 1) * 100 @markup.setter def markup(self, markup): self.price = self.sellable._get_price_by_markup(markup) @property def category_name(self): return self.category.name
[docs] def remove(self): """Removes this client category price from the database.""" self.store.remove(self)
def _validate_code(sellable, attr, code): if sellable.check_code_exists(code): raise SellableError( _(u"The sellable code %r already exists") % (code, )) return code def _validate_barcode(sellable, attr, barcode): if sellable.check_barcode_exists(barcode): raise SellableError( _(u"The sellable barcode %r already exists") % (barcode, )) return barcode @implementer(IDescribable)
[docs]class Sellable(Domain): """ Sellable information of a certain item such a |product| or a |service|. See also: `schema <http://doc.stoq.com.br/schema/tables/sellable.html>`__ """ __storm_table__ = 'sellable' #: the sellable is available and can be used on a |purchase|/|sale| STATUS_AVAILABLE = u'available' #: the sellable is closed, that is, it still exists for references, #: but it should not be possible to create a |purchase|/|sale| with it STATUS_CLOSED = u'closed' statuses = {STATUS_AVAILABLE: _(u'Available'), STATUS_CLOSED: _(u'Closed')} #: a code used internally by the shop to reference this sellable. #: It is usually not printed and displayed to |clients|, barcode is for that. #: It may be used as an shorter alternative to the barcode. code = UnicodeCol(default=u'', validator=_validate_code) #: barcode, mostly for products, usually printed and attached to the #: package. barcode = UnicodeCol(default=u'', validator=_validate_barcode) #: status the sellable is in status = EnumCol(allow_none=False, default=STATUS_AVAILABLE) #: cost of the sellable, this is not tied to a specific |supplier|, #: which may have a different cost. This can also be the production cost of #: manufactured item by the company. cost = PriceCol(default=0) #: price of sellable, how much the |client| paid. base_price = PriceCol(default=0) #: the last time the cost was updated cost_last_updated = DateTimeCol(default_factory=localnow) #: the last time the price was updated price_last_updated = DateTimeCol(default_factory=localnow) #: full description of sellable description = UnicodeCol(default=u'') #: maximum discount allowed max_discount = PercentCol(default=0) #: commission to pay after selling this sellable commission = PercentCol(default=0) #: notes for the sellable notes = UnicodeCol(default=u'') unit_id = IdCol(default=None) #: the |sellableunit|, quantities of this sellable are in this unit. unit = Reference(unit_id, 'SellableUnit.id') category_id = IdCol(default=None) #: a reference to category table category = Reference(category_id, 'SellableCategory.id') tax_constant_id = IdCol(default=None) #: the |sellabletaxconstant|, this controls how this sellable is taxed tax_constant = Reference(tax_constant_id, 'SellableTaxConstant.id') #: the |product| for this sellable or ``None`` product = Reference('id', 'Product.id', on_remote=True) #: the |service| for this sellable or ``None`` service = Reference('id', 'Service.id', on_remote=True) #: the |storable| for this |product|'s sellable product_storable = Reference('id', 'Storable.id', on_remote=True) default_sale_cfop_id = IdCol(default=None) #: the default |cfop| that will be used when selling this sellable default_sale_cfop = Reference(default_sale_cfop_id, 'CfopData.id') #: A special price used when we have a "on sale" state, this #: can be used for promotions on_sale_price = PriceCol(default=0) #: When the promotional/special price starts to apply on_sale_start_date = DateTimeCol(default=None) #: When the promotional/special price ends on_sale_end_date = DateTimeCol(default=None) #: This sellable's images images = ReferenceSet('id', 'Image.sellable_id') def __init__(self, store=None, category=None, cost=None, commission=None, description=None, price=None): """Creates a new sellable :param store: a store :param category: category of this sellable :param cost: the cost, defaults to 0 :param commission: commission for this sellable :param description: readable description of the sellable :param price: the price, defaults to 0 """ Domain.__init__(self, store=store) if category: if commission is None: commission = category.get_commission() if price is None and cost is not None: markup = category.get_markup() price = self._get_price_by_markup(markup, cost=cost) self.category = category self.commission = commission or currency(0) self.cost = cost or currency(0) self.description = description self.price = price or currency(0) # # Helper methods # def _get_price_by_markup(self, markup, cost=None): if cost is None: cost = self.cost return currency(quantize(cost + (cost * (markup / currency(100))))) # # Properties # @property def status_str(self): """The sellable status as a string""" return self.statuses[self.status] @property def unit_description(self): """Returns the description of the |sellableunit| of this sellable :returns: the unit description or an empty string if no |sellableunit| was set. :rtype: unicode """ return self.unit and self.unit.description or u"" @property def image(self): """This sellable's main image.""" # FIXME: Should we use .first() here? What will happen if there are # more than one image with "is_main" flag set to True? There's no way # to prevent that in the database return self.images.find(is_main=True).one() @property def markup(self): """Markup, the opposite of discount, a value added on top of the sale. It's calculated as:: ((cost/price)-1)*100 """ if self.cost == 0: return Decimal(0) return ((self.price / self.cost) - 1) * 100 @markup.setter def markup(self, markup): self.price = self._get_price_by_markup(markup) @property def price(self): if self.is_on_sale(): return self.on_sale_price else: return self.base_price @price.setter def price(self, price): if price < 0: # Just a precaution for gui validation fails. price = 0 if self.is_on_sale(): self.on_sale_price = price else: self.base_price = price # # Accessors #
[docs] def is_available(self): """Whether the sellable is available and can be sold. :returns: ``True`` if the item can be sold, ``False`` otherwise. """ # FIXME: Perhaps this should be done elsewhere. Johan 2008-09-26 if sysparam.compare_object('DELIVERY_SERVICE', self.service): return True return self.status == self.STATUS_AVAILABLE
[docs] def set_available(self): """Mark the sellable as available Being available means that it can be ordered or sold. :raises: :exc:`ValueError`: if the sellable is already available """ if self.is_available(): raise ValueError('This sellable is already available') self.status = self.STATUS_AVAILABLE
[docs] def is_closed(self): """Whether the sellable is closed or not. :returns: ``True`` if closed, ``False`` otherwise. """ return self.status == Sellable.STATUS_CLOSED
[docs] def close(self): """Mark the sellable as closed. After the sellable is closed, this will call the close method of the service or product related to this sellable. :raises: :exc:`ValueError`: if the sellable is already closed """ if self.is_closed(): raise ValueError('This sellable is already closed') assert self.can_close() self.status = Sellable.STATUS_CLOSED obj = self.service or self.product obj.close()
[docs] def can_remove(self): """Whether we can delete this sellable from the database. ``False`` if the product/service was used in some cases below:: - Sold or received - The |product| is in a |purchase| """ if self.product and not self.product.can_remove(): return False if self.service and not self.service.can_remove(): return False return super(Sellable, self).can_remove( skip=[('product', 'id'), ('service', 'id'), ('image', 'sellable_id'), ('client_category_price', 'sellable_id')])
[docs] def can_close(self): """Whether we can close this sellable. :returns: ``True`` if the product has no stock left or the service is not required by the system (i.e. Delivery service is required). ``False`` otherwise. """ obj = self.service or self.product return obj.can_close()
def get_commission(self): return self.commission
[docs] def get_suggested_markup(self): """Returns the suggested markup for the sellable :returns: suggested markup :rtype: decimal """ return self.category and self.category.get_markup()
[docs] def get_category_description(self): """Returns the description of this sellables category If it's unset, return the constant from the category, if any :returns: sellable category description or an empty string if no |sellablecategory| was set. :rtype: unicode """ category = self.category return category and category.description or u""
[docs] def get_tax_constant(self): """Returns the |sellabletaxconstant| for this sellable. If it's unset, return the constant from the category, if any :returns: the |sellabletaxconstant| or ``None`` if unset """ if self.tax_constant: return self.tax_constant if self.category: return self.category.get_tax_constant()
[docs] def get_category_prices(self): """Returns all client category prices associated with this sellable. :returns: the client category prices """ return self.store.find(ClientCategoryPrice, sellable=self)
[docs] def get_category_price_info(self, category): """Returns the :class:`ClientCategoryPrice` information for the given :class:`ClientCategory` and this |sellable|. :returns: the :class:`ClientCategoryPrice` or ``None`` """ info = self.store.find(ClientCategoryPrice, sellable=self, category=category).one() return info
[docs] def get_price_for_category(self, category): """Given the |clientcategory|, returns the price for that category or the default sellable price. :param category: a |clientcategory| :returns: The value that should be used as a price for this sellable. """ info = self.get_category_price_info(category) if info: return info.price return self.price
def get_maximum_discount(self, category=None, user=None): user_discount = user.profile.max_discount if user else 0 if category is not None: info = self.get_category_price_info(category) or self else: info = self return max(user_discount, info.max_discount)
[docs] def check_code_exists(self, code): """Check if there is another sellable with the same code. :returns: ``True`` if we already have a sellable with the given code ``False`` otherwise. """ return self.check_unique_value_exists(Sellable.code, code)
[docs] def check_barcode_exists(self, barcode): """Check if there is another sellable with the same barcode. :returns: ``True`` if we already have a sellable with the given barcode ``False`` otherwise. """ return self.check_unique_value_exists(Sellable.barcode, barcode)
[docs] def check_taxes_validity(self): """Check if icms taxes are valid. This check is done because some icms taxes (such as CSOSN 101) have a 'valid until' field on it. If these taxes has expired, we cannot sell the sellable. Check this method using assert inside a try clause. :raises: :exc:`TaxError` if there are any issues with the sellable taxes. """ icms_template = self.product and self.product.icms_template SellableCheckTaxesEvent.emit(self) if not icms_template: return elif not icms_template.p_cred_sn: return elif not icms_template.is_p_cred_sn_valid(): # Translators: ICMS tax rate credit = Alíquota de crédito do ICMS raise TaxError(_("You cannot sell this item before updating " "the 'ICMS tax rate credit' field on '%s' " "Tax Class.\n" "If you don't know what this means, contact " "the system administrator.") % icms_template.product_tax_template.name)
[docs] def is_on_sale(self): """Check if the price is currently on sale. :return: ``True`` if it is on sale, ``False`` otherwise """ if not self.on_sale_price: return False return is_date_in_interval( localnow(), self.on_sale_start_date, self.on_sale_end_date)
[docs] def is_valid_quantity(self, new_quantity): """Whether the new quantity is valid for this sellable or not. If the new quantity is fractioned, check on this sellable unit if it allows fractioned quantities. If not, this new quantity cannot be used. Note that, if the sellable lacks a unit, we will not allow fractions either. :returns: ``True`` if new quantity is Ok, ``False`` otherwise. """ if self.unit and not self.unit.allow_fraction: return not bool(new_quantity % 1) return True
[docs] def is_valid_price(self, newprice, category=None, user=None, extra_discount=None): """Checks if *newprice* is valid for this sellable Returns a dict indicating whether the new price is a valid price as allowed by the discount by the user, by the category or by the sellable maximum discount :param newprice: The new price that we are trying to sell this sellable for :param category: Optionally define a |clientcategory| that we will get the price info from :param user: The user role may allow a different discount percentage. :param extra_discount: some extra discount for the sellable to be considered for the min_price :returns: A dict with the following keys: * is_valid: ``True`` if the price is valid, else ``False`` * min_price: The minimum price for this sellable. * max_discount: The maximum discount for this sellable. """ if category is not None: info = self.get_category_price_info(category) or self else: info = self max_discount = self.get_maximum_discount(category=category, user=user) min_price = info.price * (1 - max_discount / 100) if extra_discount is not None: # The extra discount can be greater than the min_price, and # a negative min_price doesn't make sense min_price = max(currency(0), min_price - extra_discount) return { 'is_valid': newprice >= min_price, 'min_price': min_price, 'max_discount': max_discount, }
[docs] def copy_sellable(self, target=None): """This method copies self to another sellable If the |sellable| target is None, a new sellable is created. :param target: The |sellable| target for the copy returns: a |sellable| identical to self """ if target is None: target = Sellable(store=self.store) props = ['base_price', 'category_id', 'cost', 'max_discount', 'commission', 'notes', 'unit_id', 'tax_constant_id', 'default_sale_cfop_id', 'on_sale_price', 'on_sale_start_date', 'on_sale_end_date'] for prop in props: value = getattr(self, prop) setattr(target, prop, value) return target
# # IDescribable implementation # def get_description(self, full_description=False): desc = self.description if full_description and self.get_category_description(): desc = u"[%s] %s" % (self.get_category_description(), desc) return desc # # Domain hooks # def on_update(self): obj = self.product or self.service obj.on_update() def on_object_changed(self, attr, old_value, value): if attr == 'cost': self.cost_last_updated = localnow() if (self.product and sysparam.get_bool('UPDATE_PRODUCT_COST_ON_COMPONENT_UPDATE')): self.product.update_production_cost(value) elif attr == 'base_price': self.price_last_updated = localnow() # # Classmethods #
[docs] def remove(self): """ Remove this sellable. This will also remove the |product| or |sellable| and |categoryprice| """ assert self.can_remove() # Remove category price before delete the sellable. category_prices = self.get_category_prices() for category_price in category_prices: category_price.remove() for image in self.images: self.store.remove(image) if self.product: self.product.remove() elif self.service: self.service.remove() self.store.remove(self)
@classmethod
[docs] def get_available_sellables_query(cls, store): """Get the sellables that are available and can be sold. For instance, this will filter out the internal sellable used by a |delivery|. This is similar to `.get_available_sellables`, but it returns a query instead of the actual results. :param store: a store :returns: a query expression """ delivery = sysparam.get_object(store, 'DELIVERY_SERVICE') return And(cls.id != delivery.sellable.id, cls.status == cls.STATUS_AVAILABLE)
@classmethod
[docs] def get_available_sellables(cls, store): """Get the sellables that are available and can be sold. For instance, this will filter out the internal sellable used by a |delivery|. :param store: a store :returns: a resultset with the available sellables """ query = cls.get_available_sellables_query(store) return store.find(cls, query)
@classmethod
[docs] def get_unblocked_sellables_query(cls, store, storable=False, supplier=None, consigned=False): """Helper method for get_unblocked_sellables When supplier is not ```None``, you should use this query only with Viewables that join with supplier, like ProductFullStockSupplierView. :param store: a store :param storable: if ``True``, we should filter only the sellables that are also a |storable|. :param supplier: |supplier| to filter on or ``None`` :param consigned: if the sellables are consigned :returns: a query expression """ from stoqlib.domain.product import Product, ProductSupplierInfo query = And(cls.get_available_sellables_query(store), cls.id == Product.id, Product.consignment == consigned) if storable: from stoqlib.domain.product import Storable query = And(query, Sellable.id == Product.id, Storable.id == Product.id) if supplier: query = And(query, Sellable.id == Product.id, Product.id == ProductSupplierInfo.product_id, ProductSupplierInfo.supplier_id == supplier.id) return query
@classmethod
[docs] def get_unblocked_sellables(cls, store, storable=False, supplier=None, consigned=False): """ Returns unblocked sellable objects, which means the available sellables plus the sold ones. :param store: a store :param storable: if `True`, only return sellables that also are |storable| :param supplier: a |supplier| or ``None``, if set limit the returned object to this |supplier| :rtype: queryset of sellables """ query = cls.get_unblocked_sellables_query(store, storable, supplier, consigned) return store.find(cls, query)
@classmethod
[docs] def get_unblocked_by_categories_query(cls, store, categories, include_uncategorized=True): """Returns the available sellables by a list of categories. :param store: a store :param categories: a list of SellableCategory instances :param include_uncategorized: whether or not include the sellables without a category :rtype: generator of sellables """ queries = [] if len(categories): queries.append(In(Sellable.category_id, [c.id for c in categories])) if include_uncategorized: queries.append(Eq(Sellable.category_id, None)) query = cls.get_unblocked_sellables_query(store) return And(query, Or(*queries))