# -*- coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4
##
## Copyright (C) 2007-2008 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 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>
##
# FIXME: Refactor this module and put it somewhere else (on POS app maybe?)
import datetime
import logging
import serial
import sys
import traceback
import glib
import gobject
import gtk
from kiwi.utils import gsignal
from stoqdrivers.exceptions import (DriverError, CouponOpenError,
OutofPaperError, PrinterOfflineError)
from zope.interface import implementer
from stoqlib.api import api
from stoqlib.domain.events import (CardPaymentReceiptPrepareEvent,
CardPaymentReceiptPrintedEvent,
GerencialReportPrintEvent,
GerencialReportCancelEvent,
CancelPendingPaymentsEvent,
HasPendingReduceZ, HasOpenCouponEvent)
from stoqlib.domain.interfaces import IContainer
from stoqlib.domain.till import Till
from stoqlib.drivers.cheque import print_cheques_for_payment_group
from stoqlib.exceptions import DeviceError, TillError, ReportError
from stoqlib.gui.base.dialogs import run_dialog
from stoqlib.gui.editors.tilleditor import (TillOpeningEditor,
TillClosingEditor,
TillVerifyEditor)
from stoqlib.gui.events import CouponCreatedEvent
from stoqlib.lib.dateutils import localnow
from stoqlib.lib.formatters import get_formatted_price
from stoqlib.lib.message import warning, yesno
from stoqlib.lib.parameters import sysparam
from stoqlib.lib.pluginmanager import get_plugin_manager
from stoqlib.lib.translation import stoqlib_gettext
from stoqlib.gui.utils.printing import print_report
from stoqlib.gui.wizards.salewizard import ConfirmSaleWizard
from stoqlib.reporting.boleto import BillReport
from stoqlib.reporting.booklet import BookletReport
_ = stoqlib_gettext
log = logging.getLogger(__name__)
(CLOSE_TILL_NONE,
CLOSE_TILL_DB,
CLOSE_TILL_ECF,
CLOSE_TILL_BOTH) = range(4)
def _flush_interface():
""" Sometimes we need to 'flush' interface, so that the dialog has some
time to disaperar before we send a blocking command to the printer
"""
while gtk.events_pending():
gtk.main_iteration()
# FIXME: Maybe this should be a singleton
[docs]class FiscalPrinterHelper(gobject.GObject):
"""
Signals:
* *till-status-changed* (bool, bool): Emitted when the status of
the till has changed. it can be open/closed, and while open/closed,
it can be blocked
* *ecf-changed* (bool): Emitted fater the check_till method is called,
indicating if a ecf printer is present and functional.
"""
# Closed, Blocked
gsignal('till-status-changed', bool, bool)
# has_ecf
gsignal('ecf-changed', bool)
def __init__(self, store, parent):
""" Creates a new FiscalPrinterHelper object
:param store: a store
:param parent: a gtk.Window subclass or None
"""
gobject.GObject.__init__(self)
self.store = store
self._parent = parent
self._previous_day = False
self._midnight_check_id = None
[docs] def open_till(self):
"""Opens the till
"""
try:
current_till = Till.get_current(self.store)
except TillError as e:
warning(str(e))
return False
if current_till is not None:
warning(_("You already have a till operation opened. "
"Close the current Till and open another one."))
return False
store = api.new_store()
try:
model = run_dialog(TillOpeningEditor, self._parent, store)
except TillError as e:
warning(str(e))
model = None
retval = store.confirm(model)
store.close()
if retval:
self._till_status_changed(closed=False, blocked=False)
return retval
[docs] def close_till(self, close_db=True, close_ecf=True):
"""Closes the till
There are 3 possibilities for parameters combination:
* *total close*: Both *close_db* and *close_ecf* are ``True``.
The till on both will be closed.
* *partial close*: Both *close_db* and *close_ecf* are ``False``.
It's more like a till verification. The actual user will do it
to check and maybe remove money from till, leaving it ready
for the next one. Note that this will not emit
'till-status-changed' event, since the till will not
really close.
* *fix conflicting status*: *close_db* and *close_ecf* are
different. Use this only if you need to fix a conflicting
status, like if the DB is open but the ECF is closed, or
the other way around.
:param close_db: If the till in the DB should be closed
:param close_ecf: If the till in the ECF should be closed
:returns: True if the till was closed, otherwise False
"""
is_partial = not close_db and not close_ecf
if not is_partial and not self._previous_day:
if not yesno(_("You can only close the till once per day. "
"You won't be able to make any more sales today.\n\n"
"Close the till?"),
gtk.RESPONSE_NO, _("Close Till"), _("Not now")):
return
elif not is_partial:
# When closing from a previous day, close only what is needed.
close_db = self._close_db
close_ecf = self._close_ecf
if close_db:
till = Till.get_last_opened(self.store)
assert till
store = api.new_store()
editor_class = TillVerifyEditor if is_partial else TillClosingEditor
model = run_dialog(editor_class, self._parent, store,
previous_day=self._previous_day, close_db=close_db,
close_ecf=close_ecf)
if not model:
store.confirm(model)
store.close()
return
# TillClosingEditor closes the till
retval = store.confirm(model)
store.close()
if retval and not is_partial:
self._till_status_changed(closed=True, blocked=False)
return retval
[docs] def verify_till(self):
"""Verifies the till
This is just a shortcut for calling :obj:`.close_till` passing
close_db/close_ecf = ``False``. See it's doc for more info.
"""
return self.close_till(close_db=False, close_ecf=False)
[docs] def needs_closing(self):
"""Checks if the last opened till was closed and asks the
user if he wants to close it
:returns:
- CLOSE_TILL_BOTH if both DB and ECF needs closing.
- CLOSE_TILL_DB if only DB needs closing.
- CLOSE_TILL_ECF if only ECF needs closing.
- CLOSE_TILL_NONE if both ECF and DB are consistent (they may be
closed, or open for the current day)
"""
ecf_needs_closing = HasPendingReduceZ.emit()
last_till = Till.get_last(self.store)
if last_till:
db_needs_closing = last_till.needs_closing()
else:
db_needs_closing = False
if db_needs_closing and ecf_needs_closing:
return CLOSE_TILL_BOTH
elif db_needs_closing and not ecf_needs_closing:
return CLOSE_TILL_DB
elif ecf_needs_closing and not db_needs_closing:
return CLOSE_TILL_ECF
else:
return CLOSE_TILL_NONE
[docs] def create_coupon(self, sale=None):
""" Creates a new fiscal coupon
:param sale: The |sale| to which we are creating a coupon
:returns: a new coupon
"""
if sysparam.get_bool('DEMO_MODE'):
branch = api.get_current_branch(self.store)
company = branch.person.company
if company and company.cnpj not in ['24.198.774/7322-35',
'66.873.574/0001-82']:
# FIXME: Find a better description for the warning bellow.
warning(_("You are not allowed to sell in branches not "
"created by the demonstration mode"))
coupon = FiscalCoupon(self._parent)
try:
CouponCreatedEvent.emit(coupon, sale)
except Exception as e:
warning(_("It wasn't possible to open the coupon"), str(e))
coupon = None
return coupon
[docs] def setup_midnight_check(self):
"""Check the till after the day changes.
If Stoq is open, the day changes, and the user tries to
confirm a sale (or do any other fiscal operation), an
error will happen.
This method will call check_till that will eventually,
disable fiscal related interface.
"""
# Avoid setting this up more than once
if self._midnight_check_id is not None:
return
now = localnow()
tomorrow = now + datetime.timedelta(1)
# Get the delta between now and tomorrow (midnight)
midnight = tomorrow.replace(hour=0, minute=0, second=0)
delta = midnight - now
# Call check_till at the first seconds of the next day.
self._midnight_check_id = glib.timeout_add_seconds(
delta.seconds, self.check_till, True)
def _till_status_changed(self, closed, blocked):
self.emit('till-status-changed', closed, blocked)
def _check_needs_closing(self):
needs_closing = self.needs_closing()
# DB and ECF are ok
if needs_closing is CLOSE_TILL_NONE:
self._previous_day = False
# We still need to check if the till is open or closed.
till = Till.get_current(self.store)
self._till_status_changed(closed=not till, blocked=False)
return True
close_db = needs_closing in (CLOSE_TILL_DB, CLOSE_TILL_BOTH)
close_ecf = needs_closing in (CLOSE_TILL_ECF, CLOSE_TILL_BOTH)
# DB or ECF is open from a previous day
self._till_status_changed(closed=False, blocked=True)
self._previous_day = True
# Save this statuses in case the user chooses not to close now.
self._close_db = close_db
self._close_ecf = close_ecf
manager = get_plugin_manager()
if close_db and (close_ecf or not manager.is_active('ecf')):
msg = _("You need to close the till from the previous day before "
"creating a new order.\n\nClose the Till?")
elif close_db and not close_ecf:
msg = _("The till in Stoq is opened, but in ECF "
"is closed.\n\nClose the till in Stoq?")
elif close_ecf and not close_db:
msg = _("The till in stoq is closed, but in ECF "
"is opened.\n\nClose the till in ECF?")
if yesno(msg, gtk.RESPONSE_NO, _("Close Till"), _("Not now")):
return self.close_till(close_db, close_ecf)
return False
[docs] def check_open_coupon(self):
try:
HasOpenCouponEvent.emit()
return True
except (DeviceError, DriverError, serial.SerialException) as e:
warning(str(e))
self.emit('ecf-changed', False)
return False
[docs] def check_till(self, reset_midnight_check=False):
try:
self._check_needs_closing()
self.emit('ecf-changed', True)
except (DeviceError, DriverError) as e:
warning(str(e))
self.emit('ecf-changed', False)
if reset_midnight_check:
glib.source_remove(self._midnight_check_id)
self._midnight_check_id = None
self.setup_midnight_check()
[docs] def run_initial_checks(self):
"""This will check:
1) If printer has open coupon, cancel it
2) If printer has pending reduce Z, offer to close the till
If the first check fails, the second one will not happen
"""
if not self.check_open_coupon():
return
self.check_till()
@implementer(IContainer)
[docs]class FiscalCoupon(gobject.GObject):
""" This class is used just to allow us cancel an item with base in a
Sellable object. Currently, services can't be added, and they
are just ignored -- be aware, if a coupon with only services is
emitted, it will not be opened in fact, but just ignored.
"""
#: emitted when the coupon should be opened. The return value should be the
#: client's document if any was provided when the coupon was opened.
gsignal('open', retval=str)
gsignal('identify-customer', object)
gsignal('customer-identified', retval=bool)
gsignal('add-item', object, retval=int)
gsignal('remove-item', object)
gsignal('add-payments', object)
gsignal('totalize', object)
gsignal('close', object, retval=int)
gsignal('cancel')
gsignal('get-coo', retval=int)
gsignal('get-supports-duplicate-receipt', retval=bool)
# coo, payment, value, text
gsignal('print-payment-receipt', int, object, object, str)
gsignal('cancel-payment-receipt')
def __init__(self, parent):
gobject.GObject.__init__(self)
self._coo = None
self._parent = parent
self._current_document = None
self._item_ids = {}
[docs] def emit(self, signal, *args):
sys.last_value = None
# This is evil, set/restore the excepthook
oldhook = sys.excepthook
sys.excepthook = lambda *x: None
retval = gobject.GObject.emit(self, signal, *args)
sys.excepthook = oldhook
if sys.last_value is not None:
# import traceback
# print 'Exception caught in signal emission for %s::%s:' % (
# gobject.type_name(self), signal)
# traceback.print_exception(sys.last_type, sys.last_value,
# sys.last_traceback)
raise sys.last_value # pylint: disable=E0702
return retval
#
# IContainer implementation
#
[docs] def add_item(self, sale_item):
""" Adds an item to fiscal coupon
:param sale_item: a sale item
:returns: id of the sale_item.:
0 >= if it was added successfully
-1 if an error happend
0 if added but not printed (free deliveries)
"""
log.info("adding sale item %r to coupon" % (sale_item, ))
item_id = self.emit('add-item', sale_item)
ids = self._item_ids.setdefault(sale_item, [])
ids.append(item_id)
return item_id
[docs] def get_items(self):
return list(self._item_ids.keys())
[docs] def remove_item(self, sale_item):
if sale_item.price < 0:
return
for item_id in self._item_ids.pop(sale_item):
log.info("removing sale item %r from coupon" % (sale_item, ))
try:
self.emit('remove-item', item_id)
except DriverError:
return False
return True
#
# Fiscal coupon related functions
#
[docs] def identify_customer(self, person):
self.emit('identify-customer', person)
[docs] def is_customer_identified(self):
return self.emit('customer-identified')
[docs] def open(self):
while True:
log.info("opening coupon")
try:
self._current_document = self.emit('open')
break
except CouponOpenError:
if not self.cancel():
return False
except OutofPaperError:
if not yesno(
_("The fiscal printer has run out of paper.\nAdd more paper "
"before continuing."),
gtk.RESPONSE_YES, _("Resume"), _("Confirm later")):
return False
return self.open()
except PrinterOfflineError:
if not yesno(
(_(u"The fiscal printer is offline, turn it on and try "
"again")),
gtk.RESPONSE_YES, _(u"Resume"), _(u"Confirm later")):
return False
return self.open()
except (DriverError, DeviceError) as e:
warning(_(u"It is not possible to emit the coupon"),
str(e))
return False
self._coo = self.emit('get-coo')
self.cancelled = False
self.totalized = False
self.coupon_closed = False
self.payments_setup = False
return True
[docs] def confirm(self, sale, store, savepoint=None, subtotal=None):
"""Confirms a |sale| on fiscalprinter and database
If the sale is confirmed, the store will be committed for you.
There's no need for the callsite to call store.confirm().
If the sale is not confirmed, for instance the user cancelled the
sale or there was a problem with the fiscal printer, then the
store will be rolled back.
:param sale: the |sale| to be confirmed
:param trans: a store
:param savepoint: if specified, a database savepoint name that
will be used to rollback to if the sale was not confirmed.
:param subtotal: the total value of all the items in the sale
"""
# Actually, we are confirming the sale here, so the sale
# confirmation process will be available to others applications
# like Till and not only to the POS.
payments_total = sale.group.get_total_confirmed_value()
sale_total = sale.get_total_sale_amount()
payment = get_formatted_price(payments_total)
amount = get_formatted_price(sale_total)
msg = _(u"Payment value (%s) is greater than sale's total (%s). "
"Do you want to confirm it anyway?") % (payment, amount)
if (sale_total < payments_total and not
yesno(msg, gtk.RESPONSE_NO, _(u"Confirm Sale"), _(u"Don't Confirm"))):
return False
model = run_dialog(ConfirmSaleWizard, self._parent, store, sale,
subtotal=subtotal, total_paid=payments_total,
current_document=self._current_document)
if not model:
CancelPendingPaymentsEvent.emit()
store.rollback(name=savepoint, close=False)
return False
if sale.client and not self.is_customer_identified():
self.identify_customer(sale.client.person)
try:
if not self.totalize(sale):
store.rollback(name=savepoint, close=False)
return False
if not self.setup_payments(sale):
store.rollback(name=savepoint, close=False)
return False
if not self.close(sale, store):
store.rollback(name=savepoint, close=False)
return False
if not self.print_receipts(sale):
store.rollback(name=savepoint, close=False)
return False
# FIXME: This used to be done inside sale.confirm. Maybe it would
# be better to do a proper error handling
till = Till.get_current(store)
assert till
sale.confirm(till=till)
# Only finish the transaction after everything passed above.
store.confirm(model)
except Exception as e:
traceback.print_exception(*sys.exc_info())
warning(_("An error happened while trying to confirm the sale. "
"Cancelling the coupon now..."), str(e))
self.cancel()
store.rollback(name=savepoint, close=False)
return False
print_cheques_for_payment_group(store, sale.group)
# Try to print only after the transaction is commited, to prevent
# losing data if something fails while printing
group = sale.group
booklets = list(group.get_payments_by_method_name(u'store_credit'))
bills = list(group.get_payments_by_method_name(u'bill'))
if (booklets and
yesno(_("Do you want to print the booklets for this sale?"),
gtk.RESPONSE_YES, _("Print booklets"), _("Don't print"))):
try:
print_report(BookletReport, booklets)
except ReportError:
warning(_("Could not print booklets"))
if (bills and BillReport.check_printable(bills) and
yesno(_("Do you want to print the bills for this sale?"),
gtk.RESPONSE_YES, _("Print bills"), _("Don't print"))):
try:
print_report(BillReport, bills)
except ReportError:
# TRANSLATORS: bills here refers to "boletos" in pt_BR
warning(_("Could not print bills"))
return True
[docs] def add_sale_items(self, sale):
subtotal = 0
for sale_item in sale.get_items(with_children=False):
sellable = sale_item.sellable
if (sellable.service or
(sellable.product and not sellable.product.is_package)):
# Do not add the package item in the coupon
self.add_item(sale_item)
subtotal += sale_item.get_total()
for child in sale_item.children_items:
self.add_item(child)
subtotal += child.get_total()
return subtotal
[docs] def print_receipts(self, sale):
# supports_duplicate = self.emit('get-supports-duplicate-receipt')
# Vamos sempre imprimir sempre de uma vez, para simplificar
supports_duplicate = False
log.info('Printing payment receipts')
# Merge card payments by nsu
card_payments = {}
for payment in sale.payments:
if payment.method.method_name != 'card':
continue
operation = payment.method.operation
card_data = operation.get_card_data_by_payment(payment)
card_payments.setdefault(card_data.nsu, [])
card_payments[card_data.nsu].append(payment)
any_failed = False
for nsu, payment_list in card_payments.items():
receipt = CardPaymentReceiptPrepareEvent.emit(nsu, supports_duplicate)
if receipt is None:
continue
value = sum([p.value for p in payment_list])
# This is BS, but if any receipt failed to print, we must print
# the remaining ones in Gerencial Rports
if any_failed:
retval = self.reprint_payment_receipt(receipt)
else:
retval = self.print_payment_receipt(payment_list[0], value, receipt)
while not retval:
if not yesno(_(u"An error occurred while trying to print. "
u"Would you like to try again?"),
gtk.RESPONSE_YES,
_("Try again"), _(u"Don't try again")):
CancelPendingPaymentsEvent.emit()
try:
GerencialReportCancelEvent.emit()
except (DriverError, DeviceError) as details:
log.info('Error canceling last receipt: %s' %
details)
warning(_(u"It wasn't possible to cancel "
u"the last receipt"))
return False
any_failed = True
_flush_interface()
retval = self.reprint_payment_receipt(receipt,
close_previous=True)
# Only confirm payments receipt printed if *all* receipts wore
# printed.
for nsu in card_payments.keys():
CardPaymentReceiptPrintedEvent.emit(nsu)
return True
[docs] def totalize(self, sale):
# XXX: Remove this when bug #2827 is fixed.
if not self._item_ids:
return True
if self.totalized:
return True
log.info('Totalizing coupon')
while True:
try:
self.emit('totalize', sale)
self.totalized = True
return True
except (DriverError, DeviceError) as details:
log.info("It is not possible to totalize the coupon: %s"
% str(details))
if not yesno(_(u"An error occurred while trying to print. "
u"Would you like to try again?"),
gtk.RESPONSE_YES,
_("Try again"), _(u"Don't try again")):
CancelPendingPaymentsEvent.emit()
return False
_flush_interface()
[docs] def cancel(self):
if self.cancelled:
return True
log.info('Canceling coupon')
while True:
try:
self.emit('cancel')
self.cancelled = True
break
except (DriverError, DeviceError) as details:
log.info("Error canceling coupon: %s" % str(details))
if not yesno(_(u"An error occurred while trying to cancel the "
u"the coupon. Would you like to try again?"),
gtk.RESPONSE_YES,
_("Try again"), _(u"Don't try again")):
return False
_flush_interface()
return True
# FIXME: Rename to add_payment_group(group)
[docs] def setup_payments(self, sale):
""" Add the payments defined in the sale to the coupon. Note that this
function must be called after all the payments has been created.
"""
# XXX: Remove this when bug #2827 is fixed.
if not self._item_ids:
return True
if self.payments_setup:
return True
log.info('Adding payments to the coupon')
while True:
try:
self.emit('add-payments', sale)
self.payments_setup = True
return True
except (DriverError, DeviceError) as details:
log.info("It is not possible to add payments to the coupon: %s"
% str(details))
if not yesno(_(u"An error occurred while trying to print. "
u"Would you like to try again?"),
gtk.RESPONSE_YES,
_("Try again"), _(u"Don't try again")):
CancelPendingPaymentsEvent.emit()
return False
_flush_interface()
[docs] def close(self, sale, store):
# XXX: Remove this when bug #2827 is fixed.
if not self._item_ids:
return True
if self.coupon_closed:
return True
log.info('Closing coupon')
while True:
try:
coupon_id = self.emit('close', sale)
sale.coupon_id = coupon_id
self.coupon_closed = True
return True
except (DeviceError, DriverError) as details:
log.info("It is not possible to close the coupon: %s"
% str(details))
if not yesno(_(u"An error occurred while trying to print. "
u"Would you like to try again?"),
gtk.RESPONSE_YES,
_("Try again"), _(u"Don't try again")):
CancelPendingPaymentsEvent.emit()
return False
_flush_interface()
[docs] def print_payment_receipt(self, payment, value, receipt):
"""Print the receipt for the payment.
This must be called after the coupon is closed.
"""
try:
self.emit('print-payment-receipt', self._coo, payment, value, receipt)
return True
except (DriverError, DeviceError) as details:
log.info("Error printing payment receipt: %s"
% str(details))
return False
[docs] def reprint_payment_receipt(self, receipt, close_previous=False):
"""Re-Print the receipt for the payment.
"""
try:
GerencialReportPrintEvent.emit(receipt, close_previous)
return True
except (DriverError, DeviceError) as details:
log.info("Error printing gerencial report: %s"
% str(details))
return False