# -*- coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4
##
## Copyright (C) 2010 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>
##
"""
This module contains classes for working with loans.
The main class is :class:`Loan` which can hold a
set of :class:`LoanItem`.
"""
# pylint: enable=E1101
from decimal import Decimal
from kiwi.currency import currency
from kiwi.python import Settable
from storm.references import Reference, ReferenceSet
from zope.interface import implementer
from stoqlib.database.expr import Round
from stoqlib.database.properties import (UnicodeCol, DateTimeCol, PriceCol,
QuantityCol, IdentifierCol,
IdCol, EnumCol)
from stoqlib.domain.base import Domain
from stoqlib.domain.events import StockOperationConfirmedEvent
from stoqlib.domain.fiscal import Invoice
from stoqlib.domain.interfaces import IContainer, IInvoice, IInvoiceItem
from stoqlib.domain.product import StockTransactionHistory
from stoqlib.domain.taxes import check_tax_info_presence
from stoqlib.exceptions import DatabaseInconsistency
from stoqlib.lib.dateutils import localnow
from stoqlib.lib.defaults import DECIMAL_PRECISION, quantize
from stoqlib.lib.translation import stoqlib_gettext
_ = stoqlib_gettext
@implementer(IInvoiceItem)
[docs]class LoanItem(Domain):
"""An item in a :class:`loan <Loan>`
Note that when changing :obj:`~.quantity`, :obj:`~.return_quantity`
or :obj:`~.sale_quantity` you will need to call :meth:`.sync_stock`
to synchronize the stock (increase or decrease it).
Also note that objects of this type should never be created manually, only
by calling :meth:`Loan.add_sellable`
See also:
`schema <http://doc.stoq.com.br/schema/tables/loan_item.html>`__
"""
__storm_table__ = 'loan_item'
#: The total quantity that was loaned. The product stock for this
#: will be decreased when the loan stock is synchonized
quantity = QuantityCol()
#: The loadned quantity that was sold. Will increase stock so
#: it's decreased correctly when the
#: :class:`sale <stoqlib.domain.sale.Sale>` is confirmed
sale_quantity = QuantityCol(default=Decimal(0))
#: The loaned quantity that was returned. Will increase stock
return_quantity = QuantityCol(default=Decimal(0))
#: price to use for this :obj:`~.sellable` when creating
#: a :class:`sale <stoqlib.domain.sale.Sale>`
price = PriceCol()
#: original price of a sellable
base_price = PriceCol()
sellable_id = IdCol(allow_none=False)
#: :class:`sellable <stoqlib.domain.sellable.Sellable>` that is loaned
#: cannot be *None*
sellable = Reference(sellable_id, 'Sellable.id')
batch_id = IdCol()
#: If the sellable is a storable, the |batch| that it was returned in
batch = Reference(batch_id, 'StorableBatch.id')
loan_id = IdCol()
#: :class:`loan <Loan>` this item belongs to
loan = Reference(loan_id, 'Loan.id')
icms_info_id = IdCol()
#: the :class:`stoqlib.domain.taxes.InvoiceItemIcms` tax for *self*
icms_info = Reference(icms_info_id, 'InvoiceItemIcms.id')
ipi_info_id = IdCol()
#: the :class:`stoqlib.domain.taxes.InvoiceItemIpi` tax for *self*
ipi_info = Reference(ipi_info_id, 'InvoiceItemIpi.id')
pis_info_id = IdCol()
#: the :class:`stoqlib.domain.taxes.InvoiceItemPis` tax for *self*
pis_info = Reference(pis_info_id, 'InvoiceItemPis.id')
cofins_info_id = IdCol()
#: the :class:`stoqlib.domain.taxes.InvoiceItemCofins` tax for *self*
cofins_info = Reference(cofins_info_id, 'InvoiceItemCofins.id')
def __init__(self, *args, **kwargs):
# stores the total quantity that was loaned before synching stock
self._original_quantity = 0
# stores the loaned quantity that was returned before synching stock
self._original_return_quantity = self.return_quantity
check_tax_info_presence(kwargs, kwargs.get('store'))
super(LoanItem, self).__init__(*args, **kwargs)
product = self.sellable.product
if product:
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)
def __storm_loaded__(self):
super(LoanItem, self).__storm_loaded__()
self._original_quantity = self.quantity
self._original_return_quantity = self.return_quantity
@property
def branch(self):
return self.loan.branch
@property
def storable(self):
return self.sellable.product_storable
#
# IInvoiceItem implementation
#
@property
def parent(self):
return self.loan
@property
def item_discount(self):
if self.price < self.base_price:
return self.base_price - self.price
return Decimal('0')
@property
def cfop_code(self):
return u'5917'
[docs] def sync_stock(self):
"""Synchronizes the stock, increasing/decreasing it accordingly.
Using the stored values when this object is created/loaded, compute how
much we should increase or decrease the stock quantity.
When setting :obj:`~.quantity`, :obj:`~.return_quantity`
or :obj:`~.sale_quantity` be sure to call this to properly
synchronize the stock (increase or decrease it). That counts
for object creation too.
"""
loaned = self._original_quantity - self.quantity
returned = self.return_quantity - self._original_return_quantity
diff_quantity = loaned + returned
if diff_quantity > 0:
self.storable.increase_stock(diff_quantity, self.branch,
StockTransactionHistory.TYPE_RETURNED_LOAN,
self.id, batch=self.batch)
elif diff_quantity < 0:
diff_quantity = - diff_quantity
self.storable.decrease_stock(diff_quantity, self.branch,
StockTransactionHistory.TYPE_LOANED,
self.id, batch=self.batch)
# Reset the values used to calculate the stock quantity, just like
# when the object as loaded from the database again.
self._original_quantity = self.quantity
self._original_return_quantity = self.return_quantity
[docs] def get_remaining_quantity(self):
"""The remaining quantity that wasn't returned/sold yet
This is the same as
:obj:`.quantity` - :obj:`.sale_quantity` - :obj:`.return_quantity`
"""
return self.quantity - self.sale_quantity - self.return_quantity
def get_quantity_unit_string(self):
return u"%s %s" % (self.quantity,
self.sellable.unit_description)
def get_total(self):
return currency(self.price * self.quantity)
[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
"""
self.price = quantize(self.base_price * (1 - discount / 100))
@implementer(IContainer)
@implementer(IInvoice)
[docs]class Loan(Domain):
"""
A loan is a collection of |sellable| that is being loaned
to a |client|, the items are expected to be either be
returned to stock or sold via a |sale|.
A loan that can hold a set of :class:`loan items <LoanItem>`
See also:
`schema <http://doc.stoq.com.br/schema/tables/loan.html>`__
`manual <http://doc.stoq.com.br/manual/loan.html>`__
"""
__storm_table__ = 'loan'
#: The request for a loan has been added to the system,
#: we know which of the items the client wishes to loan,
#: it's not defined if the client has actually picked up
#: the items.
STATUS_OPEN = u'open'
#: All the products or other sellable items have been
#: returned and are available in stock.
STATUS_CLOSED = u'closed'
#: The loan is cancelled and all the products or other sellable items have
#: been returned and are available in stock.
STATUS_CANCELLED = u'cancelled'
# FIXME: This is missing a few states,
# STATUS_LOANED: stock is completely synchronized
statuses = {STATUS_OPEN: _(u'Opened'),
STATUS_CLOSED: _(u'Closed'),
STATUS_CANCELLED: _(u'Cancelled')}
#: 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 loan
status = EnumCol(allow_none=False, default=STATUS_OPEN)
#: notes related to this loan.
notes = UnicodeCol(default=u'')
#: date loan was opened
open_date = DateTimeCol(default_factory=localnow)
#: date loan was closed
close_date = DateTimeCol(default=None)
#: loan expires on this date, we expect the items to
#: to be returned by this date
expire_date = DateTimeCol(default=None)
#: the date the loan was cancelled
cancel_date = DateTimeCol(default=None)
removed_by = UnicodeCol(default=u'')
#: the reason the loan was cancelled
cancel_reason = UnicodeCol()
#: branch where the loan was done
branch_id = IdCol()
branch = Reference(branch_id, 'Branch.id')
#: :class:`user <stoqlib.domain.person.LoginUser>` of the system
#: that made the loan
# FIXME: Should probably be a SalesPerson, we can find the
# LoginUser via te.user_id
responsible_id = IdCol()
responsible = Reference(responsible_id, 'LoginUser.id')
#: client that loaned the items
client_id = IdCol(default=None)
client = Reference(client_id, 'Client.id')
client_category_id = IdCol(default=None)
#: the |clientcategory| used for price determination.
client_category = Reference(client_category_id, 'ClientCategory.id')
#: a list of all items loaned in this loan
loaned_items = ReferenceSet('id', 'LoanItem.loan_id')
#: |payments| generated by this loan
payments = None
#: |transporter| used in loan
transporter = None
invoice_id = IdCol()
#: The |invoice| generated by the loan
invoice = Reference(invoice_id, 'Invoice.id')
#: The responsible for cancelling the loan. At the moment, the
#: |loginuser| that cancelled the loan
cancel_responsible_id = IdCol()
cancel_responsible = Reference(cancel_responsible_id, 'LoginUser.id')
def __init__(self, store=None, **kwargs):
kwargs['invoice'] = Invoice(store=store, invoice_type=Invoice.TYPE_OUT)
super(Loan, self).__init__(store=store, **kwargs)
#
# Classmethods
#
@classmethod
def get_status_name(cls, status):
if not status in cls.statuses:
raise DatabaseInconsistency(_("Invalid status %d") % status)
return cls.statuses[status]
#
# IContainer implementation
#
def add_item(self, loan_item):
assert not loan_item.loan
loan_item.loan = self
def get_items(self):
return self.store.find(LoanItem, loan=self)
def remove_item(self, loan_item):
loan_item.loan = None
self.store.maybe_remove(loan_item)
#
# IInvoice implementation
#
@property
def comments(self):
return [Settable(comment=self.notes)]
@property
def discount_value(self):
discount = currency(0)
for item in self.get_items():
if item.price > item.sellable.base_price:
continue
discount += item.sellable.base_price - item.price
return discount
@property
def invoice_subtotal(self):
return self.get_sale_base_subtotal()
@property
def invoice_total(self):
return self.get_total_amount()
@property
def recipient(self):
return self.client.person
@property
def operation_nature(self):
# TODO: Save the operation nature in new loan table field.
return _(u"Loan")
#
# Public API
#
[docs] def add_sellable(self, sellable, quantity=1, price=None, batch=None):
"""Adds a new sellable item to a loan
: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 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.
"""
self.validate_batch(batch, sellable=sellable)
price = price or sellable.price
base_price = sellable.price
return LoanItem(store=self.store,
quantity=quantity,
loan=self,
sellable=sellable,
batch=batch,
price=price,
base_price=base_price)
[docs] def get_available_discount_for_items(self, user=None, exclude_item=None):
"""Get available discount for items in this loan
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.sellable.base_price:
continue
used_discount += item.sellable.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 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)
item = None
candidate = None
for item in self.get_items():
item.set_discount(discount)
new_total += item.price * item.quantity
if item.quantity == 1:
candidate = item
# 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, preferable
# to one with a quantity of 1 since, for instance, applying +0,1 to an
# item with a quantity of 4 would make it's total +0,4 (+0,3 extra than
# we are trying to adjust here).
discount_value = (self.get_sale_base_subtotal() * discount) / 100
diff = new_total - self.get_sale_base_subtotal() + discount_value
if diff:
item = candidate or item
item.price -= diff
#
# Accessors
#
[docs] def get_total_amount(self):
"""
Fetches the total value of the loan, that is to be paid by
the client.
It can be calculated as::
Sale total = Sum(product and service prices) + surcharge +
interest - discount
:returns: the total value
"""
return currency(self.get_items().sum(
Round(LoanItem.price * LoanItem.quantity,
DECIMAL_PRECISION)) or 0)
def get_client_name(self):
if self.client:
return self.client.person.name
return u''
def get_branch_name(self):
if self.branch:
return self.branch.get_description()
return u''
def get_responsible_name(self):
return self.responsible.person.name
#
# Public API
#
[docs] def sync_stock(self):
"""Synchronizes the stock of *self*'s :class:`loan items <LoanItem>`
Just a shortcut to call :meth:`LoanItem.sync_stock` of all of
*self*'s :class:`loan items <LoanItem>` instead of having
to do that one by one.
"""
for loan_item in self.get_items():
# No need to sync stock for products that dont need.
if not loan_item.sellable.product.manage_stock:
continue
loan_item.sync_stock()
[docs] def can_close(self):
"""Checks if the loan can be closed. A loan can be closed if it is
opened and all the items have been returned or sold.
:returns: True if the loan can be closed, False otherwise.
"""
if self.status != Loan.STATUS_OPEN:
return False
for item in self.get_items():
if item.sale_quantity + item.return_quantity != item.quantity:
return False
return True
[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
"""
subtotal = self.get_items().sum(LoanItem.quantity *
LoanItem.base_price)
return currency(subtotal)
[docs] def close(self):
"""Closes the loan. At this point, all the loan items have been
returned to stock or sold."""
assert self.can_close()
self.close_date = localnow()
self.status = Loan.STATUS_CLOSED
def confirm(self):
# Save the operation nature and branch in Invoice table.
self.invoice.operation_nature = self.operation_nature
self.invoice.branch = self.branch
# Since there is no status change here and the event requires
# the parameter, we use None
old_status = None
StockOperationConfirmedEvent.emit(self, old_status)