# -*- coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4
##
## Copyright (C) 2012 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>
##
# pylint: enable=E1101
from decimal import Decimal
import collections
from kiwi.currency import currency
from storm.references import Reference, ReferenceSet
from storm.expr import And, Join, Eq
from zope.interface import implementer
from stoqlib.api import api
from stoqlib.database.properties import (UnicodeCol, DateTimeCol,
PriceCol, QuantityCol, IdentifierCol,
IdCol, EnumCol)
from stoqlib.database.runtime import get_current_branch
from stoqlib.domain.base import Domain
from stoqlib.domain.events import StockOperationConfirmedEvent
from stoqlib.domain.fiscal import Invoice, FiscalBookEntry
from stoqlib.domain.interfaces import IContainer, IInvoiceItem, IInvoice
from stoqlib.domain.payment.method import PaymentMethod
from stoqlib.domain.payment.payment import Payment
from stoqlib.domain.product import StockTransactionHistory
from stoqlib.domain.taxes import check_tax_info_presence
from stoqlib.lib.dateutils import localnow
from stoqlib.lib.parameters import sysparam
from stoqlib.lib.translation import stoqlib_gettext
_ = stoqlib_gettext
@implementer(IInvoiceItem)
[docs]class ReturnedSaleItem(Domain):
"""An item of a :class:`returned sale <ReturnedSale>`
Note that objects of this type should never be created manually, only by
calling :meth:`Sale.create_sale_return_adapter`
"""
__storm_table__ = 'returned_sale_item'
#: the returned quantity
quantity = QuantityCol(default=0)
#: The price which this :obj:`.sale_item` was sold.
#: When creating this object, if *price* is not passed to the
#: contructor, it defaults to :obj:`.sale_item.price` or
#: :obj:`.sellable.price`
price = PriceCol()
sale_item_id = IdCol(default=None)
#: the returned |saleitem|
sale_item = Reference(sale_item_id, 'SaleItem.id')
sellable_id = IdCol()
#: The returned |sellable|
#: Note that if :obj:`.sale_item` != ``None``, this is the same as
#: :obj:`.sale_item.sellable`
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')
returned_sale_id = IdCol()
#: the |returnedsale| which this item belongs
returned_sale = Reference(returned_sale_id, 'ReturnedSale.id')
#: 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 fo *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 fo *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 fo *self*
cofins_info = Reference(cofins_info_id, 'InvoiceItemCofins.id')
item_discount = Decimal('0')
parent_item_id = IdCol()
parent_item = Reference(parent_item_id, 'ReturnedSaleItem.id')
children_items = ReferenceSet('id', 'ReturnedSaleItem.parent_item_id')
def __init__(self, store=None, **kwargs):
# TODO: Add batch logic here. (get if from sale_item or check if was
# passed togheter with sellable)
sale_item = kwargs.get('sale_item')
sellable = kwargs.get('sellable')
if not sale_item and not sellable:
raise ValueError(
"A sale_item or a sellable is mandatory to create this object")
elif sale_item and sellable and sale_item.sellable != sellable:
raise ValueError(
"sellable must be the same as sale_item.sellable")
elif sale_item and not sellable:
sellable = sale_item.sellable
kwargs['sellable'] = sellable
if not 'price' in kwargs:
# sale_item.price takes priority over sellable.price
kwargs['price'] = sale_item.price if sale_item else sellable.price
check_tax_info_presence(kwargs, store)
super(ReturnedSaleItem, self).__init__(store=store, **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)
@property
def total(self):
"""The total being returned
This is the same as :obj:`.price` * :obj:`.quantity`
"""
return self.price * self.quantity
#
# IInvoiceItem implementation
#
@property
def base_price(self):
return self.price
@property
def parent(self):
return self.returned_sale
@property
def cfop_code(self):
return u'1202'
#
# Public API
#
def get_total(self):
return self.total
[docs] def return_(self, branch):
"""Do the real return of this item
When calling this, the real return will happen, that is,
if :obj:`.sellable` is a |product|, it's stock will be
increased on *branch*.
"""
storable = self.sellable.product_storable
if storable:
storable.increase_stock(self.quantity, branch,
StockTransactionHistory.TYPE_RETURNED_SALE,
self.id, batch=self.batch)
if self.sale_item:
self.sale_item.quantity_decreased -= self.quantity
[docs] def undo(self):
"""Undo this item return.
This is the oposite of the return, ie, the item will be removed back
from stock and the sale item decreased quantity will be restored.
"""
storable = self.sellable.product_storable
if storable:
storable.decrease_stock(self.quantity, self.returned_sale.branch,
StockTransactionHistory.TYPE_UNDO_RETURNED_SALE,
self.id, batch=self.batch)
if self.sale_item:
self.sale_item.quantity_decreased += self.quantity
[docs] def maybe_remove(self):
"""Will eventualy remove the object from database"""
for child in self.children_items:
# Make sure to remove children before remove itself
if child.can_remove():
self.store.remove(child)
if self.can_remove():
self.store.remove(self)
[docs] def can_remove(self):
"""Check if the ReturnedSaleItem can be removed from database
If the item is a package, check if all of its children are being
returned
"""
product = self.sellable.product
if product and product.is_package and not bool(self.quantity):
return not any(bool(child.quantity) for child in self.children_items)
return not bool(self.quantity)
def get_component_quantity(self, parent):
for component in parent.sellable.product.get_components():
if self.sellable.product == component.component:
return component.quantity
@implementer(IContainer)
@implementer(IInvoice)
[docs]class ReturnedSale(Domain):
"""Holds information about a returned |sale|.
This can be:
* *trade*, a |client| is returning the |sale| and buying something
new with that credit. In that case the returning sale is :obj:`.sale` and the
replacement |sale| is in :obj:`.new_sale`.
* *return sale* or *devolution*, a |client| is returning the |sale|
without making a new |sale|.
Normally the old sale which is returned is :obj:`.sale`, however it
might be ``None`` in some situations for example, if the |sale| was done
at a different |branch| that hasn't been synchronized or is using another
system.
"""
__storm_table__ = 'returned_sale'
#: This returned sale was received on another branch, but is not yet
#: confirmed. A product goes back to stock only after confirmation
STATUS_PENDING = u'pending'
#: This return was confirmed, meaning the product stock was increased.
STATUS_CONFIRMED = u'confirmed'
#: This returned sale was canceled, ie, The product stock is decreased back
#: and the original sale still have the products.
STATUS_CANCELLED = 'cancelled'
statuses = collections.OrderedDict([
(STATUS_PENDING, _(u'Pending')),
(STATUS_CONFIRMED, _(u'Confirmed')),
(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 returned sale
status = EnumCol(default=STATUS_PENDING)
#: the date this return was done
return_date = DateTimeCol(default_factory=localnow)
#: the date that the |returned sale| with the status pending was received
confirm_date = DateTimeCol(default=None)
# When this returned sale was undone
undo_date = DateTimeCol(default=None)
#: the reason why this return was made
reason = UnicodeCol(default=u'')
#: The reason this returned sale was undone
undo_reason = UnicodeCol(default=u'')
sale_id = IdCol(default=None)
#: the |sale| we're returning
sale = Reference(sale_id, 'Sale.id')
new_sale_id = IdCol(default=None)
#: if not ``None``, :obj:`.sale` was traded for this |sale|
new_sale = Reference(new_sale_id, 'Sale.id')
responsible_id = IdCol()
#: the |loginuser| responsible for doing this return
responsible = Reference(responsible_id, 'LoginUser.id')
confirm_responsible_id = IdCol()
#: the |loginuser| responsible for receiving the pending return
confirm_responsible = Reference(confirm_responsible_id, 'LoginUser.id')
undo_responsible_id = IdCol()
#: the |loginuser| responsible for undoing this returned sale.
undo_responsible = Reference(undo_responsible_id, 'LoginUser.id')
branch_id = IdCol()
#: the |branch| in which this return happened
branch = Reference(branch_id, 'Branch.id')
#: a list of all items returned in this return
returned_items = ReferenceSet('id', 'ReturnedSaleItem.returned_sale_id')
#: |payments| generated by this returned sale
payments = None
#: |transporter| used in returned sale
transporter = None
invoice_id = IdCol()
#: The |invoice| generated by the returned sale
invoice = Reference(invoice_id, 'Invoice.id')
def __init__(self, store=None, **kwargs):
kwargs['invoice'] = Invoice(store=store, invoice_type=Invoice.TYPE_IN)
super(ReturnedSale, self).__init__(store=store, **kwargs)
@property
def group(self):
"""|paymentgroup| for this return sale.
Can return:
* For a *trade*, use the |paymentgroup| from
the replacement |sale|.
* For a *devolution*, use the |paymentgroup| from
the returned |sale|.
"""
if self.new_sale:
return self.new_sale.group
if self.sale:
return self.sale.group
return None
@property
def client(self):
"""The |client| of this return
Note that this is the same as :obj:`.sale.client`
"""
return self.sale and self.sale.client
@property
def sale_total(self):
"""The current total amount of the |sale|.
This is calculated by getting the
:attr:`total amount <stoqlib.domain.sale.Sale.total_amount>` of the
returned sale and subtracting the sum of :obj:`.returned_total` of
all existing returns for the same sale.
"""
if not self.sale:
return currency(0)
# TODO: Filter by status
returned = self.store.find(ReturnedSale, sale=self.sale)
# This will sum the total already returned for this sale,
# excluiding *self* within the same store
returned_total = sum([returned_sale.returned_total for returned_sale in
returned if returned_sale != self])
return currency(self.sale.total_amount - returned_total)
@property
def paid_total(self):
"""The total paid for this sale
Note that this is the same as
:meth:`stoqlib.domain.sale.Sale.get_total_paid`
"""
if not self.sale:
return currency(0)
return self.sale.get_total_paid()
@property
def returned_total(self):
"""The total being returned on this return
This is done by summing the :attr:`ReturnedSaleItem.total` of
all of this :obj:`returned items <.returned_items>`
"""
return currency(sum([item.total for item in self.returned_items]))
@property
def total_amount(self):
"""The total amount for this return
See :meth:`.return_` for details of how this is used.
"""
return currency(self.sale_total -
self.paid_total -
self.returned_total)
@property
def total_amount_abs(self):
"""The absolute total amount for this return
This is the same as abs(:attr:`.total_amount`). Useful for
displaying it on a gui, just changing it's label to show if
it's 'overpaid' or 'missing'.
"""
return currency(abs(self.total_amount))
#
# IContainer implementation
#
def add_item(self, returned_item):
assert not returned_item.returned_sale
returned_item.returned_sale = self
def get_items(self):
return self.returned_items
def remove_item(self, item):
item.returned_sale = None
self.store.maybe_remove(item)
#
# IInvoice implementation
#
@property
def comments(self):
return self.reason
@property
def discount_value(self):
return currency(0)
@property
def invoice_subtotal(self):
return self.returned_total
@property
def invoice_total(self):
return self.returned_total
@property
def recipient(self):
if self.sale and self.sale.client:
return self.sale.client.person
elif self.new_sale and self.new_sale.client:
return self.new_sale.client.person
return None
@property
def operation_nature(self):
# TODO: Save the operation nature in new returned_sale table field.
return _(u"Sale Return")
#
# Public API
#
@classmethod
[docs] def get_pending_returned_sales(cls, store, branch):
"""Returns a list of pending |returned_sale|
:param store: a store
:param branch: the |branch| where the sale was made
"""
from stoqlib.domain.sale import Sale
tables = [cls, Join(Sale, cls.sale_id == Sale.id)]
# We want the returned_sale which sale was made on the branch
# So we are comparing Sale.branch with |branch| to build the query
return store.using(*tables).find(cls, And(cls.status == cls.STATUS_PENDING,
Sale.branch == branch))
def is_pending(self):
return self.status == ReturnedSale.STATUS_PENDING
def is_undone(self):
return self.status == ReturnedSale.STATUS_CANCELLED
def can_undo(self):
return self.status == ReturnedSale.STATUS_CONFIRMED
[docs] def return_(self, method_name=u'money', login_user=None):
"""Do the return of this returned sale.
:param unicode method_name: The name of the payment method that will be
used to create this payment.
If :attr:`.total_amount` is:
* > 0, the client is returning more than it paid, we will create
a |payment| with that value so the |client| can be reversed.
* == 0, the |client| is returning the same amount that needs to be paid,
so existing payments will be cancelled and the |client| doesn't
owe anything to us.
* < 0, than the payments need to be readjusted before calling this.
.. seealso: :meth:`stoqlib.domain.sale.Sale.return_` as that will be
called after that payment logic is done.
"""
assert self.sale and self.sale.can_return()
self._clean_not_used_items()
payment = None
if self.total_amount == 0:
# The client does not owe anything to us
self.group.cancel()
elif self.total_amount < 0:
# The user has paid more than it's returning
for payment in self.group.get_pending_payments():
if payment.is_inpayment():
# We are returning money to client, that means he doesn't owe
# us anything, we do now. Cancel pending payments
payment.cancel()
method = PaymentMethod.get_by_name(self.store, method_name)
description = _(u'%s returned for sale %s') % (method.description,
self.sale.identifier)
payment = method.create_payment(Payment.TYPE_OUT,
payment_group=self.group,
branch=self.branch,
value=self.total_amount_abs,
description=description)
payment.set_pending()
if method_name == u'credit':
payment.pay()
# FIXME: For now, we are not reverting the comission as there is a
# lot of things to consider. See bug 5215 for information about it.
self._revert_fiscal_entry()
self.sale.return_(self)
# Save operation_nature and branch in Invoice table.
self.invoice.operation_nature = self.operation_nature
self.invoice.branch = self.branch
if self.sale.branch == self.branch:
self.confirm(login_user)
[docs] def trade(self):
"""Do a trade for this return
Almost the same as :meth:`.return_`, but unlike it, this won't
generate reversed payments to the client. Instead, it'll
generate an inpayment using :obj:`.returned_total` value,
so it can be used as an "already paid quantity" on :obj:`.new_sale`.
"""
assert self.new_sale
if self.sale:
assert self.sale.can_return()
self._clean_not_used_items()
store = self.store
group = self.group
method = PaymentMethod.get_by_name(store, u'trade')
description = _(u'Traded items for sale %s') % (
self.new_sale.identifier, )
value = self.returned_total
value_as_discount = sysparam.get_bool('USE_TRADE_AS_DISCOUNT')
if value_as_discount:
self.new_sale.discount_value = self.returned_total
else:
payment = method.create_payment(Payment.TYPE_IN, group, self.branch, value,
description=description)
payment.set_pending()
payment.pay()
self._revert_fiscal_entry()
login_user = api.get_current_user(self.store)
if self.sale:
self.sale.return_(self)
if self.sale.branch == self.branch:
self.confirm(login_user)
else:
# When trade items without a registered sale, confirm the
# new returned sale.
self.confirm(login_user)
[docs] def remove(self):
"""Remove this return and it's items from the database"""
# XXX: Why do we remove this object from the database
# We must remove children_items before we remove its parent_item
for item in self.returned_items.find(Eq(ReturnedSaleItem.parent_item_id, None)):
[self.remove_item(child) for child in getattr(item, 'children_items')]
self.remove_item(item)
self.store.remove(self)
[docs] def confirm(self, login_user):
"""Receive the returned_sale_items from a pending |returned_sale|
:param user: the |login_user| that received the pending returned sale
"""
assert self.status == self.STATUS_PENDING
self._return_items()
old_status = self.status
self.status = self.STATUS_CONFIRMED
self.confirm_responsible = login_user
self.confirm_date = localnow()
StockOperationConfirmedEvent.emit(self, old_status)
[docs] def undo(self, reason):
"""Undo this returned sale.
This includes removing the returned items from stock again (updating the
quantity decreased on the sale).
:param reason: The reason for this operation.
"""
assert self.can_undo()
for item in self.get_items():
item.undo()
# We now need to create a new in payment for the total amount of this
# returned sale.
method_name = self._guess_payment_method()
method = PaymentMethod.get_by_name(self.store, method_name)
description = _(u'%s return undone for sale %s') % (
method.description, self.sale.identifier)
payment = method.create_payment(Payment.TYPE_IN,
payment_group=self.group,
branch=self.branch,
value=self.returned_total,
description=description)
payment.set_pending()
payment.pay()
self.status = self.STATUS_CANCELLED
self.cancel_date = localnow()
self.undo_reason = reason
# if the sale status is returned, we must reset it to confirmed (only
# confirmed sales can be returned)
if self.sale.is_returned():
self.sale.set_not_returned()
#
# Private
#
def _guess_payment_method(self):
"""Guesses the payment method used in this returned sale.
"""
value = self.returned_total
# Now look for the out payment, ie, the payment that we possibly created
# for the returned value.
payments = list(self.sale.payments.find(payment_type=Payment.TYPE_OUT,
value=value))
if len(payments) == 1:
# There is only one payment that matches our criteria, we can trust it
# is the one we are looking for.
method = payments[0].method.method_name
elif len(payments) == 0:
# This means that the returned sale didn't endup creating any return
# payment for the client. Let's just create a money payment then
method = u'money'
else:
# This means that we found more than one return payment for this
# value. This probably means that the user has returned multiple
# items in different returns.
methods = set(payment.method.method_name for payment in payments)
if len(methods) == 1:
# All returns were using the same method. Lets use that one them
method = methods.pop()
else:
# The previous returns used different methods, let's pick money
method = u'money'
return method
def _return_items(self):
# We must have at least one item to return
assert self.returned_items.count()
# FIXME
branch = get_current_branch(self.store)
for item in self.returned_items:
item.return_(branch)
def _get_returned_percentage(self):
return Decimal(self.returned_total / self.sale.total_amount)
def _clean_not_used_items(self):
query = Eq(ReturnedSaleItem.parent_item_id, None)
for item in self.returned_items.find(query):
item.maybe_remove()
def _revert_fiscal_entry(self):
entry = self.store.find(FiscalBookEntry,
payment_group=self.group,
is_reversal=False).one()
if not entry:
return
# FIXME: Instead of doing a partial reversion of fiscal entries,
# we should be reverting the exact tax for each returned item.
returned_percentage = self._get_returned_percentage()
entry.reverse_entry(
self.invoice.invoice_number,
icms_value=entry.icms_value * returned_percentage,
iss_value=entry.iss_value * returned_percentage,
ipi_value=entry.ipi_value * returned_percentage)