Esempio n. 1
0
class PurchaseOrder(IdentifiableDomain):
    """Purchase and order definition."""

    __storm_table__ = 'purchase_order'

    ORDER_QUOTING = u'quoting'
    ORDER_PENDING = u'pending'
    ORDER_CONFIRMED = u'confirmed'
    ORDER_CONSIGNED = u'consigned'
    ORDER_CANCELLED = u'cancelled'
    ORDER_CLOSED = u'closed'

    statuses = collections.OrderedDict([
        (ORDER_QUOTING, _(u'Quoting')),
        (ORDER_PENDING, _(u'Pending')),
        (ORDER_CONFIRMED, _(u'Confirmed')),
        (ORDER_CONSIGNED, _(u'Consigned')),
        (ORDER_CANCELLED, _(u'Cancelled')),
        (ORDER_CLOSED, _(u'Closed')),
    ])

    FREIGHT_FOB = u'fob'
    FREIGHT_CIF = u'cif'

    freight_types = {FREIGHT_FOB: _(u'FOB'), FREIGHT_CIF: _(u'CIF')}

    #: 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 = EnumCol(allow_none=False, default=ORDER_QUOTING)
    open_date = DateTimeCol(default_factory=localnow, allow_none=False)
    quote_deadline = DateTimeCol(default=None)
    expected_receival_date = DateTimeCol(default_factory=localnow)
    expected_pay_date = DateTimeCol(default_factory=localnow)
    # XXX This column is not being used anywhere
    receival_date = DateTimeCol(default=None)
    confirm_date = DateTimeCol(default=None)
    notes = UnicodeCol(default=u'')
    salesperson_name = UnicodeCol(default=u'')
    freight_type = EnumCol(allow_none=False, default=FREIGHT_FOB)
    expected_freight = PriceCol(default=0)

    surcharge_value = PriceCol(default=0)
    discount_value = PriceCol(default=0)

    consigned = BoolCol(default=False)
    supplier_id = IdCol()
    supplier = Reference(supplier_id, 'Supplier.id')
    branch_id = IdCol()
    branch = Reference(branch_id, 'Branch.id')

    station_id = IdCol(allow_none=False)
    #: The station this object was created at
    station = Reference(station_id, 'BranchStation.id')
    transporter_id = IdCol(default=None)
    transporter = Reference(transporter_id, 'Transporter.id')
    responsible_id = IdCol()
    responsible = Reference(responsible_id, 'LoginUser.id')
    group_id = IdCol()
    group = Reference(group_id, 'PaymentGroup.id')

    #: Indicates if the order is from a work order
    work_order_id = IdCol()
    work_order = Reference(work_order_id, 'WorkOrder.id')

    #
    # IContainer Implementation
    #

    def get_items(self, with_children=True):
        """Get the items of the purchase order

        :param with_children: indicate if we should fetch children_items or not
        """
        query = PurchaseItem.order == self
        if not with_children:
            query = And(query, Eq(PurchaseItem.parent_item_id, None))
        return self.store.find(PurchaseItem, query)

    def remove_item(self, item):
        if item.order is not self:
            raise ValueError(
                _(u'Argument item must have an order attribute '
                  'associated with the current purchase instance'))
        item.order = None
        self.store.maybe_remove(item)

    def add_item(self,
                 sellable,
                 quantity=Decimal(1),
                 parent=None,
                 cost=None,
                 icms_st_value=0,
                 ipi_value=0):
        """Add a sellable to this purchase.

        If the sellable is part of a package (parent is not None), then the actual cost
        and quantity will be calculated based on how many items of this component is on
        the package.

        :param sellable: the sellable being added
        :param quantity: How many units of this sellable we are adding
        :param cost: The price being paid for this sellable
        :param parent: The parent of this sellable, incase of a package
        """
        if cost is None:
            cost = sellable.cost

        if parent:
            component = parent.sellable.product.get_component(sellable)
            cost = cost / component.quantity
            quantity = quantity * component.quantity
        else:
            if sellable.product.is_package:
                # If this is a package, the cost will be calculated and updated by the
                # compoents of the package
                cost = Decimal('0')

        store = self.store
        return PurchaseItem(store=store,
                            order=self,
                            sellable=sellable,
                            quantity=quantity,
                            cost=cost,
                            parent_item=parent,
                            icms_st_value=icms_st_value,
                            ipi_value=ipi_value)

    #
    # Properties
    #

    @property
    def discount_percentage(self):
        """Discount by percentage.
        Note that percentage must be added as an absolute value not as a
        factor like 1.05 = 5 % of surcharge
        The correct form is 'percentage = 3' for a discount of 3 %"""
        discount_value = self.discount_value
        if not discount_value:
            return currency(0)
        subtotal = self.purchase_subtotal
        assert subtotal > 0, (u'the subtotal should not be zero '
                              u'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):
        """Surcharge by percentage.
        Note that surcharge must be added as an absolute value not as a
        factor like 0.97 = 3 % of discount.
        The correct form is 'percentage = 3' for a surcharge of 3 %"""
        surcharge_value = self.surcharge_value
        if not surcharge_value:
            return currency(0)
        subtotal = self.purchase_subtotal
        assert subtotal > 0, (u'the subtotal should not be zero '
                              u'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)

    @property
    def payments(self):
        """Returns all valid payments for this purchase

        This will return a list of valid payments for this purchase, that
        is, all payments on the payment group that were not cancelled.
        If you need to get the cancelled too, use self.group.payments. If this
        purchase does not have a payment group, return a empty list.

        :returns: a list of |payment|
        """
        return self.group.get_valid_payments() if self.group else []

    #
    # Private
    #

    def _get_percentage_value(self, percentage):
        if not percentage:
            return currency(0)
        subtotal = self.purchase_subtotal
        percentage = Decimal(percentage)
        return subtotal * (percentage / 100)

    def _payback_paid_payments(self):
        paid_value = self.group.get_total_paid()

        # If we didn't pay anything yet, there is no need to create a payback.
        if not paid_value:
            return

        money = PaymentMethod.get_by_name(self.store, u'money')
        payment = money.create_payment(
            self.branch,
            self.station,
            Payment.TYPE_IN,
            self.group,
            paid_value,
            description=_('%s Money Returned for Purchase %s') %
            ('1/1', self.identifier))
        payment.set_pending()
        payment.pay()

    #
    # Public API
    #

    def is_paid(self):
        if not self.group:
            return False

        for payment in self.payments:
            if not payment.is_paid():
                return False
        return True

    def can_cancel(self):
        """Find out if it's possible to cancel the order

        :returns: True if it's possible to cancel the order, otherwise False
        """
        # FIXME: Canceling partial orders disabled until we fix bug 3282
        for item in self.get_items():
            if item.has_partial_received():
                return False
        return self.status in [
            self.ORDER_QUOTING, self.ORDER_PENDING, self.ORDER_CONFIRMED
        ]

    def can_close(self):
        """Find out if it's possible to close the order

        :returns: True if it's possible to close the order, otherwise False
        """

        # Consigned orders can be closed only after being confirmed
        if self.status == self.ORDER_CONSIGNED:
            return False

        for item in self.get_items():
            if not item.has_been_received():
                return False
        return True

    def confirm(self, responsible: LoginUser, confirm_date=None):
        """Confirms the purchase order

        :param confirm_data: optional, datetime
        """
        if confirm_date is None:
            confirm_date = TransactionTimestamp()

        if self.status not in [
                PurchaseOrder.ORDER_PENDING, PurchaseOrder.ORDER_CONSIGNED
        ]:
            fmt = _(u'Invalid order status, it should be '
                    u'ORDER_PENDING or ORDER_CONSIGNED, got %s')
            raise ValueError(fmt % (self.status_str, ))

        # In consigned purchases there is no payments at this point.
        if self.status != PurchaseOrder.ORDER_CONSIGNED and self.group:
            for payment in self.payments:
                payment.set_pending()

        if self.supplier and self.group:
            self.group.recipient = self.supplier.person

        self.responsible = responsible
        self.status = PurchaseOrder.ORDER_CONFIRMED
        self.confirm_date = confirm_date

        Event.log(
            self.store, Event.TYPE_ORDER,
            _(u"Order %s, total value %2.2f, supplier '%s' "
              u"is now confirmed") %
            (self.identifier, self.purchase_total, self.supplier.person.name))

    def set_consigned(self, responsible: LoginUser):
        if self.status != PurchaseOrder.ORDER_PENDING:
            raise ValueError(
                _(u'Invalid order status, it should be '
                  u'ORDER_PENDING, got %s') % (self.status_str, ))

        self.responsible = responsible
        self.status = PurchaseOrder.ORDER_CONSIGNED

    def close(self):
        """Closes the purchase order
        """
        if self.status != PurchaseOrder.ORDER_CONFIRMED:
            raise ValueError(
                _(u'Invalid status, it should be confirmed '
                  u'got %s instead') % self.status_str)
        self.status = self.ORDER_CLOSED

        Event.log(
            self.store, Event.TYPE_ORDER,
            _(u"Order %s, total value %2.2f, supplier '%s' "
              u"is now closed") %
            (self.identifier, self.purchase_total, self.supplier.person.name))

    def cancel(self):
        """Cancels the purchase order
        """
        assert self.can_cancel()

        # we have to cancel the payments too
        self._payback_paid_payments()
        self.group.cancel()

        self.status = self.ORDER_CANCELLED

    def receive_item(self, item, quantity_to_receive):
        if not item in self.get_pending_items():
            raise StoqlibError(
                _(u'This item is not pending, hence '
                  u'cannot be received'))
        quantity = item.quantity - item.quantity_received
        if quantity < quantity_to_receive:
            raise StoqlibError(
                _(u'The quantity that you want to receive '
                  u'is greater than the total quantity of '
                  u'this item %r') % item)
        self.increase_quantity_received(item, quantity_to_receive)

    def increase_quantity_received(self, purchase_item, quantity_received):
        sellable = purchase_item.sellable
        items = [
            item for item in self.get_items()
            if item.sellable.id == sellable.id
        ]
        qty = len(items)
        if not qty:
            raise ValueError(
                _(u'There is no purchase item for '
                  u'sellable %r') % sellable)

        purchase_item.quantity_received += quantity_received

    def update_products_cost(self):
        """Update purchase's items cost

        Update the costs of all products on this purchase
        to the costs specified in the order.
        """
        for item in self.get_items():
            # Since the only way the item have ipi_value is through importer of
            # a xml from stoqlink, and the cost will be always without ipi
            item.sellable.cost = item.cost + item.unit_ipi_value
            product = item.sellable.product
            product_supplier = product.get_product_supplier_info(
                self.supplier, self.branch)
            product_supplier.base_cost = item.cost + item.unit_ipi_value

    @property
    def status_str(self):
        return PurchaseOrder.translate_status(self.status)

    @property
    def freight_type_name(self):
        if not self.freight_type in self.freight_types.keys():
            raise DatabaseInconsistency(
                _(u'Invalid freight_type, got %d') % self.freight_type)
        return self.freight_types[self.freight_type]

    @property
    def branch_name(self):
        return self.branch.get_description()

    @property
    def supplier_name(self):
        return self.supplier.get_description()

    @property
    def transporter_name(self):
        if not self.transporter:
            return u""
        return self.transporter.get_description()

    @property
    def responsible_name(self):
        return self.responsible and self.responsible.get_description() or ''

    @property
    def purchase_subtotal(self):
        """Get the subtotal of the purchase.
        The sum of all the items cost * items quantity
        """
        return currency(
            self.get_items().sum(PurchaseItem.cost * PurchaseItem.quantity)
            or 0)

    @property
    def purchase_total(self):
        subtotal = self.purchase_subtotal
        total = subtotal - self.discount_value + self.surcharge_value
        if total < 0:
            raise ValueError(_(u'Purchase total can not be lesser than zero'))
        # XXX: Since the purchase_total value must have two digits
        # (at the moment) we need to format the value to a 2-digit number and
        # then convert it to currency data type, because the subtotal value
        # may return a 3-or-more-digit value, depending on COST_PRECISION_DIGITS
        # parameters.
        return currency(get_formatted_price(total))

    @property
    def received_total(self):
        """Like {purchase_subtotal} but only takes into account the
        received items
        """
        return currency(self.get_items().sum(
            PurchaseItem.cost * PurchaseItem.quantity_received) or 0)

    def get_remaining_total(self):
        """The total value to be paid for the items not received yet
        """
        return self.purchase_total - self.received_total

    def get_pending_items(self, with_children=True):
        """
        Returns a sequence of all items which we haven't received yet.
        """
        return self.get_items(with_children=with_children).find(
            PurchaseItem.quantity_received < PurchaseItem.quantity)

    def get_partially_received_items(self):
        """
        Returns a sequence of all items which are partially received.
        """
        return self.get_items().find(PurchaseItem.quantity_received > 0)

    def get_open_date_as_string(self):
        return self.open_date and self.open_date.strftime("%x") or u""

    def get_quote_deadline_as_string(self):
        return self.quote_deadline and self.quote_deadline.strftime(
            "%x") or u""

    def get_receiving_orders(self):
        """Returns all ReceivingOrder related to this purchase order
        """
        from stoqlib.domain.receiving import PurchaseReceivingMap, ReceivingOrder
        tables = [PurchaseReceivingMap, ReceivingOrder]
        query = And(PurchaseReceivingMap.purchase_id == self.id,
                    PurchaseReceivingMap.receiving_id == ReceivingOrder.id)
        return self.store.using(*tables).find(ReceivingOrder, query)

    def get_data_for_labels(self):
        """ This function returns some necessary data to print the purchase's
        items labels
        """
        for purchase_item in self.get_items():
            sellable = purchase_item.sellable
            label_data = Settable(barcode=sellable.barcode,
                                  code=sellable.code,
                                  description=sellable.description,
                                  price=sellable.price,
                                  sellable=sellable,
                                  quantity=purchase_item.quantity)
            yield label_data

    def has_batch_item(self):
        """Fetch the storables from this purchase order and returns ``True`` if
        any of them is a batch storable.

        :returns: ``True`` if this purchase order has batch items, ``False`` if
        it doesn't.
        """
        return not self.store.find(
            Storable,
            And(self.id == PurchaseOrder.id, PurchaseOrder.id
                == PurchaseItem.order_id, PurchaseItem.sellable_id
                == Sellable.id, Sellable.id == Storable.id,
                Eq(Storable.is_batch, True))).is_empty()

    def create_receiving_order(self, station: BranchStation):
        from stoqlib.domain.receiving import ReceivingOrder
        receiving = ReceivingOrder(self.store,
                                   branch=self.branch,
                                   station=station)
        receiving.add_purchase(self)
        for item in self.get_items():
            receiving.add_purchase_item(item,
                                        quantity=item.quantity,
                                        ipi_value=item.ipi_value)

        return receiving

    #
    # Classmethods
    #

    @classmethod
    def translate_status(cls, status):
        if not status in cls.statuses:
            raise DatabaseInconsistency(
                _(u'Got an unexpected status value: '
                  u'%s') % status)
        return cls.statuses[status]

    @classmethod
    def find_by_work_order(cls, store, work_order):
        return store.find(PurchaseOrder, work_order=work_order)
Esempio n. 2
0
class PurchaseItem(Domain):
    """This class stores information of the purchased items.
    """

    __storm_table__ = 'purchase_item'

    quantity = QuantityCol(default=1)
    quantity_received = QuantityCol(default=0)
    quantity_sold = QuantityCol(default=0)
    quantity_returned = QuantityCol(default=0)

    #: the cost which helps the purchaser to define the
    #: main cost of a certain product.
    base_cost = PriceCol()

    cost = PriceCol()

    #: The ICMS ST value for the product purchased
    icms_st_value = PriceCol(default=0)

    #: The IPI value for the product purchased
    ipi_value = PriceCol(default=0)

    expected_receival_date = DateTimeCol(default=None)

    sellable_id = IdCol()

    #: the |sellable|
    sellable = Reference(sellable_id, 'Sellable.id')

    order_id = IdCol()

    #: the |purchase|
    order = Reference(order_id, 'PurchaseOrder.id')

    parent_item_id = IdCol()
    parent_item = Reference(parent_item_id, 'PurchaseItem.id')

    children_items = ReferenceSet('id', 'PurchaseItem.parent_item_id')

    def __init__(self, store=None, **kw):
        if not 'sellable' in kw:
            raise TypeError('You must provide a sellable argument')
        if not 'order' in kw:
            raise TypeError('You must provide a order argument')

        # FIXME: Avoding shadowing sellable.cost
        kw['base_cost'] = kw['sellable'].cost

        if not 'cost' in kw:
            kw['cost'] = kw['sellable'].cost

        Domain.__init__(self, store=store, **kw)

    #
    # Properties
    #

    @property
    def unit_ipi_value(self):
        """Calculate the ipi for each unit of the item"""
        return currency(quantize(self.ipi_value / self.quantity))

    #
    # Accessors
    #

    def get_total(self):
        return currency((self.quantity * self.cost) + self.ipi_value)

    def get_total_sold(self):
        return currency(self.quantity_sold * self.cost)

    def get_received_total(self):
        return currency((self.quantity_received * self.cost) + self.ipi_value)

    def has_been_received(self):
        return self.quantity_received >= self.quantity

    def has_partial_received(self):
        return self.quantity_received > 0

    def get_pending_quantity(self):
        return self.quantity - self.quantity_received

    def get_quantity_as_string(self):
        unit = self.sellable.unit
        return u"%s %s" % (format_quantity(
            self.quantity), unit and unit.description or u"")

    def get_quantity_received_as_string(self):
        unit = self.sellable.unit
        return u"%s %s" % (format_quantity(
            self.quantity_received), unit and unit.description or u"")

    @classmethod
    def get_ordered_quantity(cls, store, sellable):
        """Returns the quantity already ordered of a given sellable.

        :param store: a store
        :param sellable: the sellable we want to know the quantity ordered.
        :returns: the quantity already ordered of a given sellable or zero if
          no quantity have been ordered.
        """
        query = And(PurchaseItem.sellable_id == sellable.id,
                    PurchaseOrder.id == PurchaseItem.order_id,
                    PurchaseOrder.status == PurchaseOrder.ORDER_CONFIRMED)
        ordered_items = store.find(PurchaseItem, query)
        return ordered_items.sum(PurchaseItem.quantity) or Decimal(0)

    def return_consignment(self, user: LoginUser, quantity):
        """
        Return this as a consignment item

        :param quantity: the quantity to return
        """
        storable = self.sellable.product_storable
        assert storable
        storable.decrease_stock(
            quantity=quantity,
            branch=self.order.branch,
            type=StockTransactionHistory.TYPE_CONSIGNMENT_RETURNED,
            object_id=self.id,
            user=user)

    def get_component_quantity(self, parent):
        """Get the quantity of a component.

        :param parent: the |purchase_item| parent_item of self
        :returns: the quantity of the component
        """
        for component in parent.sellable.product.get_components():
            if self.sellable.product == component.component:
                return component.quantity
Esempio n. 3
0
class InvoiceItemCofins(BaseCOFINS):
    """Invoice of COFINS tax."""

    __storm_table__ = 'invoice_item_cofins'

    COFINS_NAO_CUMULATIVO_PADRAO = Decimal('7.6')

    #: Value of COFINS tax
    v_cofins = PriceCol(default=0)

    #: Value of the COFINS tax calculation basis.
    v_bc = PriceCol(default=None)

    #: Quantity sold
    q_bc_prod = QuantityCol(default=None)

    #
    # Public API
    #

    def set_initial_values(self, invoice_item):
        self.update_values(invoice_item)

    def update_values(self, invoice_item):
        self.q_bc_prod = invoice_item.quantity

        # When the CST is contained in the list the calculation is not performed
        # because the taxpayer is exempt.
        if self.cst in [4, 5, 6, 7, 8, 9]:
            return

        # When the branch is Simples Nacional (CRT 1 or 2) and the cofins is 99,
        # the values should be zero
        if self.cst == 99 and invoice_item.parent.branch.crt in [1, 2]:
            self.v_bc = 0
            self.p_cofins = 0
            self.v_cofins = 0
            return

        cost = self._get_item_cost(invoice_item)
        if self.p_cofins == self.COFINS_NAO_CUMULATIVO_PADRAO:
            # Regime de incidencia não cumulativa
            self.v_bc = quantize(invoice_item.quantity *
                                 (invoice_item.price - cost))
        else:
            # Regime de incidencia cumulativa
            self.v_bc = quantize(invoice_item.quantity * invoice_item.price)

        if self.p_cofins is not None:
            self.v_cofins = quantize(self.v_bc * self.p_cofins / 100)

    @classmethod
    def get_tax_template(cls, invoice_item):
        default_cofins = sysparam.get_object(
            invoice_item.store, 'DEFAULT_PRODUCT_COFINS_TEMPLATE')
        # FIXME: Allow use COFINS templates in services
        if invoice_item.sellable.service:
            return default_cofins

        return (invoice_item.sellable.product.get_cofins_template(
            invoice_item.parent.branch) or default_cofins)

    #
    # Private API
    #

    def _get_item_cost(self, invoice_item):
        from stoqlib.domain.sale import SaleItem

        if isinstance(invoice_item, SaleItem):
            return invoice_item.average_cost

        return 0
Esempio n. 4
0
class Payment(Domain):
    """Payment, a transfer of money between a |branch| and |client| or a
    |supplier|.

    Payments between:

    * a client and a branch are :obj:`.TYPE_IN`, has a |sale| associated.
    * branch and a supplier are :obj:`.TYPE_OUT`, has a |purchase| associated.

    Payments are sometimes referred to as *installments*.

    Sales and purchase orders can be accessed via the
    :obj:`payment group <.group>`

    +-------------------------+-------------------------+
    | **Status**              | **Can be set to**       |
    +-------------------------+-------------------------+
    | :obj:`STATUS_PREVIEW`   | :obj:`STATUS_PENDING`   |
    +-------------------------+-------------------------+
    | :obj:`STATUS_PENDING`   | :obj:`STATUS_PAID`,     |
    |                         | :obj:`STATUS_CANCELLED` |
    +-------------------------+-------------------------+
    | :obj:`STATUS_PAID`      | :obj:`STATUS_PENDING`,  |
    |                         | :obj:`STATUS_CANCELLED` |
    +-------------------------+-------------------------+
    | :obj:`STATUS_CANCELLED` | None                    |
    +-------------------------+-------------------------+

    .. graphviz::

       digraph status {
         STATUS_PREVIEW -> STATUS_PENDING;
         STATUS_PENDING -> STATUS_PAID;
         STATUS_PENDING -> STATUS_CANCELLED;
         STATUS_PAID -> STATUS_PENDING;
         STATUS_PAID -> STATUS_CANCELLED;
       }

    Simple sale workflow:

    * Creating a sale, status is set to :obj:`STATUS_PREVIEW`
    * Confirming the sale, status is set to :obj:`STATUS_PENDING`
    * Paying the installment, status is set to :obj:`STATUS_PAID`
    * Cancelling the payment, status is set to :obj:`STATUS_CANCELLED`

    See also:
    `schema <http://doc.stoq.com.br/schema/tables/payment.html>`__
    """

    __storm_table__ = 'payment'

    #: incoming to the company, accounts receivable, payment from
    #: a |client| to a |branch|
    TYPE_IN = u'in'

    #: outgoing from the company, accounts payable, a payment from
    #: |branch| to a |supplier|
    TYPE_OUT = u'out'

    #: payment group this payment belongs to hasn't been confirmed,
    # should normally be filtered when showing a payment list
    STATUS_PREVIEW = u'preview'

    #: payment group has been confirmed and the payment has not been received
    STATUS_PENDING = u'pending'

    #: the payment has been received
    STATUS_PAID = u'paid'

    # FIXME: Remove these two
    #: Unused.
    STATUS_REVIEWING = u'reviewing'

    #: Unused.
    STATUS_CONFIRMED = u'confirmed'

    #: payment was cancelled, for instance the payments of the group was changed, or
    #: the group was cancelled.
    STATUS_CANCELLED = u'cancelled'

    statuses = {STATUS_PREVIEW: _(u'Preview'),
                STATUS_PENDING: _(u'To Pay'),
                STATUS_PAID: _(u'Paid'),
                STATUS_REVIEWING: _(u'Reviewing'),
                STATUS_CONFIRMED: _(u'Confirmed'),
                STATUS_CANCELLED: _(u'Cancelled')}

    #: type of payment :obj:`.TYPE_IN` or :obj:`.TYPE_OUT`
    payment_type = EnumCol(allow_none=False, default=TYPE_IN)

    #: 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, see |payment| for more information.
    status = EnumCol(allow_none=False, default=STATUS_PREVIEW)

    #: description payment, usually something like "1/3 Money for Sale 1234"
    description = UnicodeCol(default=None)

    # FIXME: use TransactionTimestamp() instead to avoid server/client date
    #        inconsistencies

    #: when this payment was opened
    open_date = DateTimeCol(default_factory=localnow)

    #: when this payment is due
    due_date = DateTimeCol()

    #: when this payment was paid
    paid_date = DateTimeCol(default=None)

    #: when this payment was cancelled
    cancel_date = DateTimeCol(default=None)

    # FIXME: Figure out when and why this differs from value
    #: base value
    base_value = PriceCol(default=None)

    #: value of the payment
    value = PriceCol()

    #: the actual amount that was paid, including penalties, interest, discount etc.
    paid_value = PriceCol(default=None)

    #: interest of this payment
    interest = PriceCol(default=0)

    #: discount, an absolute value with the difference between the
    #: sales price and :obj:`.value`
    discount = PriceCol(default=0)

    #: penalty of the payment
    penalty = PriceCol(default=0)

    # FIXME: Figure out what this is used for
    #: number of the payment
    payment_number = UnicodeCol(default=None)

    branch_id = IdCol(allow_none=False)

    #: |branch| associated with this payment.
    #: For a :obj:`.TYPE_IN` payment, this is the branch that will receive
    #: the money. For a :obj:`.TYPE_IN` payment, this is the branch that
    #: will make the payment
    branch = Reference(branch_id, 'Branch.id')

    method_id = IdCol()

    #: |paymentmethod| for this payment
    #: payment
    method = Reference(method_id, 'PaymentMethod.id')

    group_id = IdCol()

    #: |paymentgroup| for this payment
    group = Reference(group_id, 'PaymentGroup.id')

    category_id = IdCol()

    #: |paymentcategory| this payment belongs to, can be None
    category = Reference(category_id, 'PaymentCategory.id')

    #: list of :class:`comments <stoqlib.domain.payment.comments.PaymentComment>` for
    #: this payment
    comments = ReferenceSet('id', 'PaymentComment.payment_id')

    #: :class:`check data <stoqlib.domain.payment.method.CheckData>` for
    #: this payment
    check_data = Reference('id', 'CheckData.payment_id', on_remote=True)

    #: |accounttransaction| for this payment
    transaction = Reference('id', 'AccountTransaction.payment_id', on_remote=True)

    #: indicates if a bill has been received. They are usually delivered by
    #: mail before the due date. This is not indicating whether the payment has
    #: been paid, just that the receiver has notified the payer somehow.
    bill_received = BoolCol(default=False)

    attachment_id = IdCol()

    #: |attachment| for this payment
    attachment = Reference(attachment_id, 'Attachment.id')

    def __init__(self, store=None, **kw):
        if not 'value' in kw:
            raise TypeError('You must provide a value argument')
        if not 'base_value' in kw or not kw['base_value']:
            kw['base_value'] = kw['value']
        Domain.__init__(self, store=store, **kw)

    def _check_status(self, status, operation_name):
        fmt = 'Invalid status for %s operation: %s'
        assert self.status == status, (
            fmt % (operation_name,
                   self.statuses[self.status]))

    #
    # ORMObject hooks
    #

    def delete(self):
        # First call hooks, do this first so the hook
        # have access to everything it needs
        self.method.operation.payment_delete(self)
        # FIXME: BUG 5581 check if it is really safe to remove the payment
        # when using with synced databases
        self.store.remove(self)

    @classmethod
    def create_repeated(cls, store, payment, repeat_type, start_date, end_date):
        """Create a set of repeated payments.
        Given a type of interval (*repeat_type*), a start date and an end_date,
        this creates a list of payments for that interval.

        Note, this will also update the description of the payment that's passed
        in.
        :param store: a store
        :param payment: the payment to repeat
        :param repeat_type: the kind of repetition (weekly, monthly etc)
        :param start_date: the date to start this repetition
        :param end_date: the date to end this repetition
        :returns: a list of repeated payments
        """
        dates = create_date_interval(interval_type=repeat_type,
                                     start_date=start_date,
                                     end_date=end_date)
        n_dates = dates.count()
        if n_dates == 1:
            raise AssertionError
        description = payment.description
        payment.description = u'1/%d %s' % (n_dates, description)
        payment.due_date = dates[0]

        payments = []
        for i, date in enumerate(dates[1:]):
            p = Payment(open_date=payment.open_date,
                        branch=payment.branch,
                        payment_type=payment.payment_type,
                        status=payment.status,
                        description=u'%d/%d %s' % (i + 2, n_dates,
                                                   description),
                        value=payment.value,
                        base_value=payment.base_value,
                        due_date=date,
                        method=payment.method,
                        group=payment.group,
                        category=payment.category,
                        store=store)
            payments.append(p)
        return payments

    #
    # Properties
    #

    @property
    def comments_number(self):
        """The number of |paymentcomments| for this payment"""
        return self.comments.count()

    @property
    def bank_account_number(self):
        """For check payments, the :class:`bank account <BankAccount>` number"""
        # This is used by test_payment_method, and is a convenience
        # property, ideally we should move it to payment operation
        # somehow
        if self.method.method_name == u'check':
            data = self.method.operation.get_check_data_by_payment(self)
            bank_account = data.bank_account
            if bank_account:
                return bank_account.bank_number

    @property
    def installment_number(self):
        payments = self.group.get_valid_payments().order_by(
            Payment.identifier)
        for i, payment in enumerate(payments):
            if self == payment:
                return i + 1

    @property
    def status_str(self):
        """The :obj:`Payment.status` as a translated string"""
        if not self.status in self.statuses:
            raise DatabaseInconsistency('Invalid status for Payment '
                                        'instance, got %d' % self.status)
        return self.statuses[self.status]

    def get_days_late(self):
        """For due payments, the number of days late this payment is

        :returns: the number of days late
        """
        if self.status == Payment.STATUS_PAID:
            return 0

        days_late = localtoday().date() - self.due_date.date()
        if days_late.days < 0:
            return 0

        return days_late.days

    def set_pending(self):
        """Set a :obj:`.STATUS_PREVIEW` payment as :obj:`.STATUS_PENDING`.
        This also means that this is valid payment and its owner
        actually can charge it
        """
        self._check_status(self.STATUS_PREVIEW, u'set_pending')
        self.status = self.STATUS_PENDING

    def set_not_paid(self, change_entry):
        """Set a :obj:`.STATUS_PAID` payment as :obj:`.STATUS_PENDING`.
        This requires clearing paid_date and paid_value

        :param change_entry: a :class:`PaymentChangeHistory` object,
          that will hold the changes information
        """
        self._check_status(self.STATUS_PAID, u'set_not_paid')

        if self.transaction:
            self.transaction.create_reverse()

        change_entry.last_status = self.STATUS_PAID
        change_entry.new_status = self.STATUS_PENDING

        sale = self.group and self.group.sale

        if sale and sale.can_set_not_paid():
            sale.set_not_paid()
        self.status = self.STATUS_PENDING
        self.paid_date = None
        self.paid_value = None

    def pay(self, paid_date=None, paid_value=None,
            source_account=None, destination_account=None,
            account_transaction_number=None):
        """Pay the current payment set its status as :obj:`.STATUS_PAID`

        If this payment belongs to a sale, and all other payments from the sale
        are paid then the sale will be set as paid.
        """
        if self.status != Payment.STATUS_PENDING:
            raise ValueError(_(u"This payment is already paid."))
        self._check_status(self.STATUS_PENDING, u'pay')

        paid_value = paid_value or (self.value - self.discount +
                                    self.interest)
        self.paid_value = paid_value
        self.paid_date = paid_date or TransactionTimestamp()
        self.status = self.STATUS_PAID

        if (self.is_separate_payment() or
            self.method.operation.create_transaction()):
            AccountTransaction.create_from_payment(
                self,
                code=account_transaction_number,
                source_account=source_account,
                destination_account=destination_account)

        sale = self.group and self.group.sale
        if sale:
            sale.create_commission(self)

            # When paying payments of a sale, check if the other payments are
            # paid. If they are, this means you can change the sale status to
            # paid as well.
            if sale.can_set_paid():
                sale.set_paid()

        if self.value == self.paid_value:
            msg = _(u"{method} payment with value {value:.2f} was paid").format(
                method=self.method.method_name,
                value=self.value)
        else:
            msg = _(u"{method} payment with value original value "
                    u"{original_value:.2f} was paid with value "
                    u"{value:.2f}").format(method=self.method.method_name,
                                           original_value=self.value,
                                           value=self.paid_value)
        Event.log(self.store, Event.TYPE_PAYMENT, msg.capitalize())

    def cancel(self, change_entry=None):
        """Cancel the payment, set it's status to :obj:`.STATUS_CANCELLED`
        """
        # TODO Check for till entries here and call cancel_till_entry if
        # it's possible. Bug 2598
        if not self.can_cancel():
            raise StoqlibError(_(u"Invalid status for cancel operation, "
                                 u"got %s") % self.status_str)

        if self.transaction:
            self.transaction.create_reverse()

        old_status = self.status
        self.status = self.STATUS_CANCELLED
        self.cancel_date = TransactionTimestamp()

        if change_entry is not None:
            change_entry.last_status = old_status
            change_entry.new_status = self.status

        msg = _(u"{method} payment with value {value:.2f} was cancelled").format(
            method=self.method.method_name,
            value=self.value)
        Event.log(self.store, Event.TYPE_PAYMENT, msg.capitalize())

    def change_due_date(self, new_due_date):
        """Changes the payment due date.
        :param new_due_date: The new due date for the payment.
        :rtype: datetime.date
        """
        if self.status in [Payment.STATUS_PAID, Payment.STATUS_CANCELLED]:
            raise StoqlibError(_(u"Invalid status for change_due_date operation, "
                                 u"got %s") % self.status_str)
        self.due_date = new_due_date

    def update_value(self, new_value):
        """Update the payment value.

        """
        self.value = new_value

    def can_cancel(self):
        return self.status in (Payment.STATUS_PREVIEW, Payment.STATUS_PENDING,
                               Payment.STATUS_PAID)

    def get_payable_value(self):
        """Returns the calculated payment value with the daily interest.

        Note that the payment group daily_interest must be between 0 and 100.

        :returns: the payable value
        """
        if self.status in [self.STATUS_PREVIEW, self.STATUS_CANCELLED]:
            return self.value
        if self.status in [self.STATUS_PAID, self.STATUS_REVIEWING,
                           self.STATUS_CONFIRMED]:
            return self.paid_value

        return self.value + self.get_interest()

    def get_penalty(self, date=None):
        """Calculate the penalty in an absolute value

        :param date: date of payment
        :returns: penalty
        :rtype: :class:`kiwi.currency.currency`
        """
        if date is None:
            date = localtoday().date()
        elif date < self.open_date.date():
            raise ValueError(_(u"Date can not be less then open date"))
        elif date > localtoday().date():
            raise ValueError(_(u"Date can not be greather then future date"))
        if not self.method.penalty:
            return currency(0)

        # Don't add penalty if we pay in time!
        if self.due_date.date() >= date:
            return currency(0)

        return currency(self.method.penalty / 100 * self.value)

    def get_interest(self, date=None, pay_penalty=True):
        """Calculate the interest in an absolute value

        :param date: date of payment
        :returns: interest
        :rtype: :class:`kiwi.currency.currency`
        """
        if date is None:
            date = localtoday().date()
        elif date < self.open_date.date():
            raise ValueError(_(u"Date can not be less then open date"))
        elif date > localtoday().date():
            raise ValueError(_(u"Date can not be greather then future date"))

        if not self.method.daily_interest:
            return currency(0)

        days = (date - self.due_date.date()).days
        if days <= 0:
            return currency(0)

        base_value = self.value + (pay_penalty and self.get_penalty(date=date))

        return currency(days * self.method.daily_interest / 100 * base_value)

    def has_commission(self):
        """Check if this |payment| already has a |commission|"""
        from stoqlib.domain.commission import Commission
        return self.store.find(Commission,
                               payment=self).any()

    def is_paid(self):
        """Check if the payment is paid.

        :returns: ``True`` if the payment is paid
        """
        return self.status == Payment.STATUS_PAID

    def is_pending(self):
        """Check if the payment is pending.

        :returns: ``True`` if the payment is pending
        """
        return self.status == Payment.STATUS_PENDING

    def is_preview(self):
        """Check if the payment is in preview state

        :returns: ``True`` if the payment is paid
        """
        return self.status == Payment.STATUS_PREVIEW

    def is_cancelled(self):
        """Check if the payment was cancelled.

        :returns: ``True`` if the payment was cancelled
        """
        return self.status == Payment.STATUS_CANCELLED

    def get_paid_date_string(self):
        """Get a paid date string

        :returns: the paid date string or PAID DATE if the payment isn't paid
        """
        if self.paid_date:
            return self.paid_date.date().strftime('%x')
        return _(u'NOT PAID')

    def get_open_date_string(self):
        """Get a open date string

        :returns: the open date string or empty string
        """
        if self.open_date:
            return self.open_date.date().strftime('%x')
        return u""

    def is_inpayment(self):
        """Find out if a payment is :obj:`incoming <.TYPE_IN>`

        :returns: ``True`` if it's incoming
        """
        return self.payment_type == self.TYPE_IN

    def is_outpayment(self):
        """Find out if a payment is :obj:`outgoing <.TYPE_OUT>`

        :returns: ``True`` if it's outgoing
        """
        return self.payment_type == self.TYPE_OUT

    def is_separate_payment(self):
        """Find out if this payment is created separately from a
        sale, purchase or renegotiation
        :returns: ``True`` if it's separate.
        """

        # FIXME: This is a hack, we should rather store a flag
        #        in the database that tells us how the payment was
        #        created.
        group = self.group
        if not group:
            # Should never happen
            return False

        if group.sale:
            return False
        elif group.purchase:
            return False
        elif group._renegotiation:
            return False

        return True

    def is_of_method(self, method_name):
        """Find out if the payment was made with a certain method

        :returns: ``True`` if it's a payment of that method
        """
        return self.method.method_name == method_name
Esempio n. 5
0
class InvoiceItemIcms(BaseICMS):
    __storm_table__ = 'invoice_item_icms'
    v_bc = PriceCol(default=None)
    v_icms = PriceCol(default=None)

    v_bc_st = PriceCol(default=None)
    v_icms_st = PriceCol(default=None)

    # Fundo de Combate à Pobreza
    v_fcp = PriceCol(default=None)
    v_fcp_st = PriceCol(default=None)
    v_fcp_st_ret = PriceCol(default=None)

    # Alíquota suportada pelo Consumidor Final (FCP + ICMS)
    p_st = PercentCol(default=None)

    # Simples Nacional
    v_cred_icms_sn = PriceCol(default=None)

    v_bc_st_ret = PriceCol(default=None)
    v_icms_st_ret = PriceCol(default=None)

    def _calc_cred_icms_sn(self, invoice_item):
        if self.p_cred_sn >= 0:
            self.v_cred_icms_sn = invoice_item.get_total(
            ) * self.p_cred_sn / 100

    def _calc_st(self, invoice_item):
        self.v_bc_st = quantize(invoice_item.price * invoice_item.quantity)

        if self.bc_st_include_ipi and invoice_item.ipi_info:
            self.v_bc_st += invoice_item.ipi_info.v_ipi

        if self.p_red_bc_st is not None:
            self.v_bc_st -= self.v_bc_st * self.p_red_bc_st / 100
        if self.p_mva_st is not None:
            self.v_bc_st += self.v_bc_st * self.p_mva_st / 100

        if self.v_bc_st is not None and self.p_icms_st is not None:
            self.v_icms_st = self.v_bc_st * self.p_icms_st / 100
        if self.v_icms is not None and self.v_icms_st is not None:
            self.v_icms_st -= self.v_icms

        if self.v_bc_st is not None and self.p_fcp_st is not None:
            self.v_fcp_st = self.v_bc_st * self.p_fcp_st / 100
        if self.v_fcp is not None and self.v_fcp_st is not None:
            self.v_fcp_st -= self.v_fcp

    def _calc_normal(self, invoice_item):
        self.v_bc = quantize(invoice_item.price * invoice_item.quantity)

        if self.bc_include_ipi and invoice_item.ipi_info:
            self.v_bc += invoice_item.ipi_info.v_ipi

        if self.p_red_bc is not None:
            self.v_bc -= self.v_bc * self.p_red_bc / 100

        if self.p_icms is not None and self.v_bc is not None:
            self.v_icms = self.v_bc * self.p_icms / 100

        if self.p_fcp is not None and self.v_bc is not None:
            self.v_fcp = self.v_bc * self.p_fcp / 100

    def _update_normal(self, invoice_item):
        """Atualiza os dados de acordo com os calculos do Regime Tributário
        Normal (Não simples)
        """
        if self.cst == 0:
            self.p_red_bc = Decimal(0)
            self._calc_normal(invoice_item)

        elif self.cst == 10:
            self.p_red_bc = Decimal(0)
            self._calc_normal(invoice_item)
            self._calc_st(invoice_item)

        elif self.cst == 20:
            self._calc_normal(invoice_item)

        elif self.cst == 30:
            self.v_icms = 0
            self.v_bc = 0

            self._calc_st(invoice_item)

        elif self.cst in (40, 41, 50):
            self.v_icms = 0
            self.v_bc = 0

        elif self.cst == 51:
            self._calc_normal(invoice_item)

        elif self.cst == 60:
            self.v_bc_st_ret = 0
            self.v_icms_st_ret = 0
            self.v_fcp_st_ret = 0
            if self.p_fcp_st is not None and self.p_icms_st is not None:
                self.p_st = self.p_fcp_st + self.p_icms_st

        elif self.cst in (70, 90):
            self._calc_normal(invoice_item)
            self._calc_st(invoice_item)

    def _update_simples(self, invoice_item):
        if self.csosn in [300, 400, 500]:
            self.v_bc_st_ret = 0
            self.v_icms_st_ret = 0
            self.v_fcp_st_ret = 0
            if self.p_fcp_st is not None and self.p_icms_st is not None:
                self.p_st = self.p_fcp_st + self.p_icms_st

        if self.csosn in [101, 201]:
            if self.p_cred_sn is None:
                self.p_cred_sn = Decimal(0)
            self._calc_cred_icms_sn(invoice_item)

        if self.csosn in [201, 202, 203]:
            self._calc_st(invoice_item)

        if self.csosn == 900:
            if self.p_cred_sn is None:
                self.p_cred_sn = Decimal(0)
            self._calc_cred_icms_sn(invoice_item)
            self._calc_normal(invoice_item)
            self._calc_st(invoice_item)

    def update_values(self, invoice_item):
        branch = invoice_item.parent.branch

        # Simples nacional
        if branch.crt in [1, 2]:
            self._update_simples(invoice_item)
        else:
            self._update_normal(invoice_item)

    @classmethod
    def get_tax_template(cls, invoice_item):
        return invoice_item.sellable.product.get_icms_template(
            invoice_item.parent.branch)
Esempio n. 6
0
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'

    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

    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)

    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 - Decimal(discount) / 100))
Esempio n. 7
0
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 = collections.OrderedDict([
        (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
    #

    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

    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

    def is_closed(self):
        """Whether the sellable is closed or not.

        :returns: ``True`` if closed, ``False`` otherwise.
        """
        return self.status == Sellable.STATUS_CLOSED

    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()

    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')])

    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

    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()

    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""

    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()

    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)

    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

    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 Decimal(max(user_discount, info.max_discount))

    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)

    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)

    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)

    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)

    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

    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,
        }

    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:
                self.product.update_product_cost(value)
        elif attr == 'base_price':
            self.price_last_updated = localnow()

    #
    # Classmethods
    #

    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
    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
    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
    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
    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
    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))
Esempio n. 8
0
class FiscalBookEntry(Domain):

    __storm_table__ = 'fiscal_book_entry'

    (TYPE_PRODUCT, TYPE_SERVICE, TYPE_INVENTORY) = range(3)

    date = DateTimeCol(default_factory=localnow)
    is_reversal = BoolCol(default=False)
    invoice_number = IntCol()
    cfop_id = IdCol()
    cfop = Reference(cfop_id, 'CfopData.id')
    branch_id = IdCol()
    branch = Reference(branch_id, 'Branch.id')
    drawee_id = IdCol(default=None)
    drawee = Reference(drawee_id, 'Person.id')
    payment_group_id = IdCol(default=None)
    payment_group = Reference(payment_group_id, 'PaymentGroup.id')
    iss_value = PriceCol(default=None)
    icms_value = PriceCol(default=None)
    ipi_value = PriceCol(default=None)
    entry_type = IntCol(default=None)

    @classmethod
    def has_entry_by_payment_group(cls, store, payment_group, entry_type):
        return bool(
            cls.get_entry_by_payment_group(store, payment_group, entry_type))

    @classmethod
    def get_entry_by_payment_group(cls, store, payment_group, entry_type):
        return store.find(cls,
                          payment_group=payment_group,
                          is_reversal=False,
                          entry_type=entry_type).one()

    @classmethod
    def _create_fiscal_entry(cls,
                             store,
                             entry_type,
                             group,
                             cfop,
                             invoice_number,
                             iss_value=0,
                             icms_value=0,
                             ipi_value=0):
        return FiscalBookEntry(entry_type=entry_type,
                               iss_value=iss_value,
                               ipi_value=ipi_value,
                               icms_value=icms_value,
                               invoice_number=invoice_number,
                               cfop=cfop,
                               drawee=group.recipient,
                               branch=get_current_branch(store),
                               date=TransactionTimestamp(),
                               payment_group=group,
                               store=store)

    @classmethod
    def create_product_entry(cls,
                             store,
                             group,
                             cfop,
                             invoice_number,
                             value,
                             ipi_value=0):
        """Creates a new product entry in the fiscal book

        :param store: a store
        :param group: payment group
        :type  group: :class:`PaymentGroup`
        :param cfop: cfop for the entry
        :type  cfop: :class:`CfopData`
        :param invoice_number: payment invoice number
        :param value: value of the payment
        :param ipi_value: ipi value of the payment
        :returns: a fiscal book entry
        :rtype: :class:`FiscalBookEntry`
        """
        return cls._create_fiscal_entry(
            store,
            FiscalBookEntry.TYPE_PRODUCT,
            group,
            cfop,
            invoice_number,
            icms_value=value,
            ipi_value=ipi_value,
        )

    @classmethod
    def create_service_entry(cls, store, group, cfop, invoice_number, value):
        """Creates a new service entry in the fiscal book

        :param store: a store
        :param group: payment group
        :type  group: :class:`PaymentGroup`
        :param cfop: cfop for the entry
        :type  cfop: :class:`CfopData`
        :param invoice_number: payment invoice number
        :param value: value of the payment
        :returns: a fiscal book entry
        :rtype: :class:`FiscalBookEntry`
        """
        return cls._create_fiscal_entry(
            store,
            FiscalBookEntry.TYPE_SERVICE,
            group,
            cfop,
            invoice_number,
            iss_value=value,
        )

    def reverse_entry(self,
                      invoice_number,
                      iss_value=None,
                      icms_value=None,
                      ipi_value=None):
        store = self.store
        icms_value = icms_value if icms_value is not None else self.icms_value
        iss_value = iss_value if iss_value is not None else self.iss_value
        ipi_value = ipi_value if ipi_value is not None else self.ipi_value

        return FiscalBookEntry(
            entry_type=self.entry_type,
            iss_value=iss_value,
            icms_value=icms_value,
            ipi_value=ipi_value,
            cfop_id=sysparam.get_object_id('DEFAULT_SALES_CFOP'),
            branch=self.branch,
            invoice_number=invoice_number,
            drawee=self.drawee,
            is_reversal=True,
            payment_group=self.payment_group,
            store=store)
Esempio n. 9
0
class StockDecreaseItem(Domain):
    """An item in a stock decrease object.

    Note that objects of this type should not be created manually, only by
    calling :meth:`StockDecrease.add_sellable`
    """

    __storm_table__ = 'stock_decrease_item'

    stock_decrease_id = IdCol(default=None)

    #: The stock decrease this item belongs to
    stock_decrease = Reference(stock_decrease_id, 'StockDecrease.id')

    sellable_id = IdCol()

    #: the |sellable| for this decrease
    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')

    #: the cost of the |sellable| on the moment this decrease was created
    cost = PriceCol(default=0)

    #: the quantity decreased for this item
    quantity = QuantityCol()

    #: 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')

    item_discount = Decimal('0')

    def __init__(self, store=None, sellable=None, **kwargs):
        if sellable is None:
            raise TypeError('You must provide a sellable argument')
        check_tax_info_presence(kwargs, store)

        super(StockDecreaseItem, self).__init__(store=store,
                                                sellable=sellable,
                                                **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)

    #
    # Properties
    #

    @property
    def total_cost(self):
        return currency(self.cost * self.quantity)

    #
    # IInvoiceItem implementation
    #

    @property
    def parent(self):
        return self.stock_decrease

    @property
    def base_price(self):
        return self.cost

    @property
    def price(self):
        return self.cost

    @property
    def nfe_cfop_code(self):
        cfop = self.stock_decrease.cfop.code
        return cfop.replace('.', '')

    #
    # Public API
    #

    def decrease(self, branch):
        # FIXME: We should not be receiving a branch here. We should be using
        # self.stock_decrease.branch for that.
        assert branch

        storable = self.sellable.product_storable
        if storable:
            storable.decrease_stock(
                self.quantity,
                branch,
                StockTransactionHistory.TYPE_STOCK_DECREASE,
                self.id,
                cost_center=self.stock_decrease.cost_center,
                batch=self.batch)

    #
    # Accessors
    #

    def get_total(self):
        return currency(self.cost * self.quantity)

    def get_quantity_unit_string(self):
        unit = self.sellable.unit_description
        if unit:
            return u"%s %s" % (self.quantity, unit)
        return unicode(self.quantity)

    def get_description(self):
        return self.sellable.get_description()