Source code for stoqlib.gui.slaves.workorderslave

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

##
## Copyright (C) 2013 Async Open Source <http://www.async.com.br>
## All rights reserved
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU Lesser General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
## GNU Lesser General Public License for more details.
##
## You should have received a copy of the GNU Lesser General Public License
## along with this program; if not, write to the Free Software
## Foundation, Inc., or visit: http://www.gnu.org/.
##
## Author(s): Stoq Team <stoq-devel@async.com.br>
##
##

import collections
import datetime
import decimal

import gtk

from kiwi import ValueUnset
from kiwi.currency import currency
from kiwi.datatypes import ValidationError
from kiwi.python import Settable
from kiwi.ui.forms import PriceField, NumericField
from kiwi.ui.objectlist import Column
import pango
from storm.expr import And, Eq, Or

from stoqlib.api import api
from stoqlib.database.expr import Field
from stoqlib.domain.person import Employee
from stoqlib.domain.sellable import Sellable
from stoqlib.domain.views import SellableFullStockView
from stoqlib.domain.workorder import (WorkOrder, WorkOrderItem,
                                      WorkOrderHistoryView)
from stoqlib.gui.base.dialogs import run_dialog
from stoqlib.gui.dialogs.batchselectiondialog import BatchDecreaseSelectionDialog
from stoqlib.gui.dialogs.credentialsdialog import CredentialsDialog
from stoqlib.gui.editors.baseeditor import BaseEditorSlave, BaseEditor
from stoqlib.gui.editors.noteeditor import NoteEditor
from stoqlib.gui.wizards.abstractwizard import SellableItemSlave
from stoqlib.lib.dateutils import localtoday
from stoqlib.lib.decorators import cached_property
from stoqlib.lib.defaults import QUANTITY_PRECISION, MAX_INT
from stoqlib.lib.formatters import format_quantity, format_sellable_description
from stoqlib.lib.translation import stoqlib_gettext

_ = stoqlib_gettext


class _WorkOrderItemBatchSelectionDialog(BatchDecreaseSelectionDialog):
    # When indicating the batches to reserve items bellow, make sure the user
    # doesn't select more batches than he selected to reserve.
    validate_max_quantity = True


class _WorkOrderItemEditor(BaseEditor):
    model_name = _(u'Work order item')
    model_type = WorkOrderItem
    confirm_widgets = ['price', 'quantity', 'quantity_reserved']

    @cached_property()
    def fields(self):
        return collections.OrderedDict(
            price=PriceField(_(u'Price'), proxy=True, mandatory=True),
            quantity=NumericField(_(u'Quantity'), proxy=True, mandatory=True),
            quantity_reserved=NumericField(_(u'Reserved quantity')),
        )

    def __init__(self, store, model, visual_mode=False):
        self._original_quantity_decreased = model.quantity_decreased
        self.manager = None
        BaseEditor.__init__(self, store, model, visual_mode=visual_mode)
        self.price.set_icon_activatable(gtk.ENTRY_ICON_PRIMARY,
                                        activatable=True)

    #
    #  BaseEditor
    #

    def setup_proxies(self):
        unit = self.model.sellable.unit
        digits = QUANTITY_PRECISION if unit and unit.allow_fraction else 0
        for widget in [self.quantity, self.quantity_reserved]:
            widget.set_digits(digits)

        self.quantity.set_range(1, MAX_INT)
        # If there's a sale, we can't change it's quantity, but we can
        # reserve/return_to_stock them. On the other hand, if there's no sale,
        # the quantity_reserved must be in sync with quantity
        # *Only products with stock control can be reserved
        storable = self.model.sellable.product_storable
        if self.model.order.sale_id is not None and storable:
            self.price.set_sensitive(False)
            self.quantity.set_sensitive(False)
            self.quantity_reserved.set_range(0, self.model.quantity)
        else:
            self.quantity_reserved.set_range(0, MAX_INT)
            self.quantity_reserved.set_visible(False)
            self.fields['quantity_reserved'].label_widget.set_visible(False)

        # We need to add quantity_reserved to a proxy or else it's validate
        # method won't do anything
        self.add_proxy(
            Settable(quantity_reserved=self.model.quantity_decreased),
            ['quantity_reserved'])

    def on_confirm(self):
        diff = (self.quantity_reserved.read() -
                self._original_quantity_decreased)

        if diff == 0:
            return
        elif diff < 0:
            self.model.return_to_stock(-diff)
            return

        storable = self.model.sellable.product_storable
        # This can only happen for diff > 0. If the product is marked to
        # control batches, no decreased should have been made without
        # specifying a batch on the item
        if storable and storable.is_batch and self.model.batch is None:
            # The only way self.model.batch is None is that this item
            # was created on a sale quote and thus it has a sale_item
            sale_item = self.model.sale_item

            batches = run_dialog(
                _WorkOrderItemBatchSelectionDialog, self, self.store,
                model=storable, quantity=diff)
            if not batches:
                return

            for s_item in [sale_item] + sale_item.set_batches(batches):
                wo_item = WorkOrderItem.get_from_sale_item(self.store,
                                                           s_item)
                if wo_item.batch is not None:
                    wo_item.reserve(wo_item.quantity)
        elif storable:
            self.model.reserve(diff)

    #
    #  Private
    #

    def _validate_quantity(self, value):
        storable = self.model.sellable.product_storable
        if storable is None:
            return

        if self.model.batch is not None:
            balance = self.model.batch.get_balance_for_branch(
                self.model.order.branch)
        else:
            balance = storable.get_balance_for_branch(self.model.order.branch)

        if value > self._original_quantity_decreased + balance:
            return ValidationError(
                _(u"This quantity is not available in stock"))

    #
    #  Callbacks
    #

    def on_price__validate(self, widget, value):
        if value <= 0:
            return ValidationError(_(u"The price must be greater than 0"))

        sellable = self.model.sellable
        self.manager = self.manager or api.get_current_user(self.store)

        # FIXME: Because of the design of the editor, the client
        # could not be set yet.
        category = self.model.order.client and self.model.order.client.category
        valid_data = sellable.is_valid_price(value, category, self.manager)
        if not valid_data['is_valid']:
            return ValidationError(
                (_(u'Max discount for this product is %.2f%%.') %
                 valid_data['max_discount']))

    def on_price__icon_press(self, entry, icon_pos, event):
        if icon_pos != gtk.ENTRY_ICON_PRIMARY:
            return

        # Ask for the credentials of a different user that can possibly allow a
        # bigger discount.
        self.manager = run_dialog(CredentialsDialog, self, self.store)
        if self.manager:
            self.price.validate(force=True)

    def on_quantity__content_changed(self, entry):
        # Check if 'quantity' widget is valid, before update the 'quantity_reserved'.
        # We need make this, because the 'validate' signal of 'quantity_reserved'
        # is not emitted when force the update of that widget
        if self.quantity.validate() is not ValueUnset:
            self.quantity_reserved.update(entry.read())

    def on_quantity__validate(self, entry, value):
        if value <= 0:
            return ValidationError("The quantity must be greater than 0")

        return self._validate_quantity(value)

    def on_quantity_reserved__validate(self, widget, value):
        return self._validate_quantity(value)


class _WorkOrderItemSlave(SellableItemSlave):
    model_type = WorkOrder
    summary_label_text = '<b>%s</b>' % api.escape(_("Total:"))
    sellable_view = SellableFullStockView
    item_editor = _WorkOrderItemEditor
    validate_stock = True
    validate_price = True
    value_column = 'price'
    batch_selection_dialog = BatchDecreaseSelectionDialog

    def __init__(self, store, parent, model=None, visual_mode=False):
        super(_WorkOrderItemSlave, self).__init__(store, parent, model=model,
                                                  visual_mode=visual_mode)

        # If the workorder already has a sale, we cannot add items directly
        # to the work order, but must use the sale editor to do so.
        self.hide_add_button()
        if model.sale_id:
            self.hide_del_button()
            self.hide_item_addition_toolbar()
            self.slave.set_message(
                _(u"This order is related to a sale. Edit the sale if you "
                  u"need to change the items"))

        # If the os is not on it's original branch, don't allow
        # the user to edit it (the edit is used to change quantity or
        # reserve/return_to_stock them)
        if model.branch_id != model.current_branch_id:
            self.hide_del_button()
            self.hide_item_addition_toolbar()
            self.hide_edit_button()

    #
    #  SellableItemSlave
    #

    def get_columns(self, editable=True):
        return [
            Column('sellable.code', title=_(u'Code'),
                   data_type=str, visible=False),
            Column('sellable.barcode', title=_(u'Barcode'),
                   data_type=str, visible=False),
            Column('sellable.description', title=_(u'Description'),
                   data_type=str, expand=True,
                   format_func=self._format_description, format_func_data=True),
            Column('price', title=_(u'Price'),
                   data_type=currency),
            Column('quantity', title=_(u'Quantity'),
                   data_type=decimal.Decimal, format_func=format_quantity),
            Column('quantity_decreased', title=_(u'Consumed quantity'),
                   data_type=decimal.Decimal, format_func=format_quantity),
            Column('total', title=_(u'Total'),
                   data_type=currency),
        ]

    def get_remaining_quantity(self, sellable, batch=None):
        # The original get_remaining_quantity will take items of the same
        # sellable on the list here and discount them from the balance. We
        # can't allow that since, unlike other SellableItemSlave subclasses,
        # the stock is decreased as soon as the item is added.
        storable = sellable.product_storable
        if storable:
            return storable.get_balance_for_branch(self.model.branch)
        else:
            return None

    def get_saved_items(self):
        return self.model.order_items

    def get_order_item(self, sellable, price, quantity, batch=None, parent=None):
        item = self.model.add_sellable(sellable, price=price,
                                       quantity=quantity, batch=batch)
        # Storable items added here are consumed at the same time
        storable = item.sellable.product_storable
        if storable:
            item.reserve(quantity)
        return item

    def get_sellable_view_query(self):
        return (self.sellable_view,
                # FIXME: How to do this using sellable_view.find_by_branch ?
                And(Or(Field('_stock_summary', 'branch_id') == self.model.branch.id,
                       Eq(Field('_stock_summary', 'branch_id'), None)),
                    Sellable.get_available_sellables_query(self.store)))

    def get_batch_items(self):
        # FIXME: Since the item will have it's stock synchronized above
        # (on sellable_selected) and thus having it's stock decreased,
        # we can't pass anything here. Find a better way to do this
        return []

    #
    #  Private
    #

    def _format_description(self, item, data):
        return format_sellable_description(item.sellable, item.batch)


[docs]class WorkOrderOpeningSlave(BaseEditorSlave): gladefile = 'WorkOrderOpeningSlave' model_type = WorkOrder proxy_widgets = [ 'defect_reported', 'open_date', ] # # BaseEditorSlave #
[docs] def setup_proxies(self): # Set sensitivity before adding the proxy, otherwise, the open date will # be changed. if not api.sysparam.get_bool('ALLOW_OUTDATED_OPERATIONS'): self.open_date.set_sensitive(False) self.add_proxy(self.model, self.proxy_widgets)
[docs]class WorkOrderQuoteSlave(BaseEditorSlave): gladefile = 'WorkOrderQuoteSlave' model_type = WorkOrder proxy_widgets = [ 'defect_detected', 'quote_responsible', 'description', 'estimated_cost', 'estimated_finish', 'estimated_hours', 'estimated_start', ] #: If we should show an entry for the description #: (allowing it to be set or changed). show_description_entry = False # # BaseEditorSlave #
[docs] def setup_proxies(self): self._new_model = False self._fill_quote_responsible_combo() if not self.show_description_entry: self.description.hide() self.description_lbl.hide() self.add_proxy(self.model, self.proxy_widgets)
[docs] def on_attach(self, editor): self._new_model = not editor.edit_mode
# # Private # def _fill_quote_responsible_combo(self): employees = Employee.get_active_employees(self.store) self.quote_responsible.prefill(api.for_person_combo(employees)) # # Callbacks #
[docs] def on_estimated_start__validate(self, widget, value): if (self._new_model and value < localtoday() and not api.sysparam.get_bool('ALLOW_OUTDATED_OPERATIONS')): return ValidationError(u"The start date cannot be on the past") self.estimated_finish.validate(force=True)
[docs] def on_estimated_finish__validate(self, widget, value): if (self._new_model and value < localtoday() and not api.sysparam.get_bool('ALLOW_OUTDATED_OPERATIONS')): return ValidationError(u"The end date cannot be on the past") estimated_start = self.estimated_start.read() if estimated_start and value < estimated_start: return ValidationError( _(u"Finished date needs to be after start date"))
[docs]class WorkOrderExecutionSlave(BaseEditorSlave): gladefile = 'WorkOrderExecutionSlave' model_type = WorkOrder proxy_widgets = [ 'execution_responsible', ] # # BaseEditorSlave # def __init__(self, parent, *args, **kwargs): self.parent = parent BaseEditorSlave.__init__(self, *args, **kwargs)
[docs] def setup_proxies(self): self._fill_execution_responsible_combo() self.proxy = self.add_proxy(self.model, self.proxy_widgets)
[docs] def setup_slaves(self): self.sellable_item_slave = _WorkOrderItemSlave( self.store, self.parent, self.model, visual_mode=self.visual_mode) self.attach_slave('sellable_item_holder', self.sellable_item_slave)
# # Private # def _fill_execution_responsible_combo(self): employees = Employee.get_active_employees(self.store) self.execution_responsible.prefill(api.for_person_combo(employees))
[docs]class WorkOrderHistorySlave(BaseEditorSlave): """Slave responsible to show the history of a |workorder|""" gladefile = 'WorkOrderHistorySlave' model_type = WorkOrder # # Public API #
[docs] def update_items(self): """Update the items on the list Useful when a history is created when using this slave and we want it to show here at the same time. """ self.details_list.add_list( WorkOrderHistoryView.find_by_work_order(self.store, self.model))
# # BaseEditorSlave #
[docs] def setup_proxies(self): self.details_btn.set_sensitive(False) # TODO: Show a tooltip for each row displaying the reason self.details_list.set_columns([ Column('date', _(u"Date"), data_type=datetime.datetime, sorted=True), Column('user_name', _(u"Who"), data_type=str, expand=True, ellipsize=pango.ELLIPSIZE_END), Column('what', _(u"What"), data_type=str, expand=True), Column('old_value', _(u"Old value"), data_type=str, visible=False), Column('new_value', _(u"New value"), data_type=str), Column('notes', _(u"Notes"), data_type=str, format_func=self._format_notes, ellipsize=pango.ELLIPSIZE_END)]) self.update_items()
# # Private # def _format_notes(self, notes): return notes.split('\n')[0] def _show_details(self, item): parent = self.get_toplevel().get_toplevel() # XXX: The window here is not decorated on gnome-shell, and because of # the visual_mode it gets no buttons. What to do? run_dialog(NoteEditor, parent, self.store, model=item, attr_name='notes', title=_(u"Notes"), visual_mode=True) # # Callbacks #
[docs] def on_details_list__row_activated(self, details_list, item): if self.details_btn.get_sensitive(): self._show_details(item)
[docs] def on_details_list__selection_changed(self, details_list, item): self.details_btn.set_sensitive(bool(item and item.notes))
[docs] def on_details_btn__clicked(self, button): selected = self.details_list.get_selected() self._show_details(selected)