28
28
PertoPay 2023 driver implementation.
32
from decimal import Decimal
35
from kiwi.python import Settable
36
from serial import PARITY_EVEN
37
from zope.interface import implements
39
from stoqdrivers.serialbase import SerialBase
40
from stoqdrivers.interfaces import (ICouponPrinter,
42
from stoqdrivers.printers.cheque import (BaseChequePrinter,
44
from stoqdrivers.printers.base import BaseDriverConstants
45
from stoqdrivers.enum import PaymentMethodType, TaxType, UnitType
46
from stoqdrivers.exceptions import (
47
DriverError, PendingReduceZ, CommandParametersError, CommandError,
48
ReadXError, OutofPaperError, CouponTotalizeError, PaymentAdditionError,
49
CancelItemError, CouponOpenError, InvalidState, PendingReadX,
50
CloseCouponError, CouponNotOpenError)
51
from stoqdrivers.printers.capabilities import Capability
31
from stoqdrivers.printers.fiscnet.FiscNetECF import FiscNetChequePrinter
52
32
from stoqdrivers.translation import stoqdrivers_gettext
54
34
_ = stoqdrivers_gettext
57
[FLAG_INTERVENCAO_TECNICA,
67
FLAG_DOCUMENTO_ABERTO,
71
FLAG_MFD_ESGOTADA] = _status_flags = [2**n for n in range(15)]
74
FLAG_INTERVENCAO_TECNICA: 'FLAG_INTERVENCAO_TECNICA',
75
FLAG_SEM_MFD: 'FLAG_SEM_MFD',
76
FLAG_RAM_NOK: 'FLAG_RAM_NOK',
77
FLAG_RELOGIO_NOK: 'FLAG_RELOGIO_NOK',
78
FLAG_SEM_MF: 'FLAG_SEM_MF',
79
FLAG_DIA_FECHADO: 'FLAG_DIA_FECHADO',
80
FLAG_DIA_ABERTO: 'FLAG_DIA_ABERTO',
81
FLAG_Z_PENDENTE: 'FLAG_Z_PENDENTE',
82
FLAG_SEM_PAPEL: 'FLAG_SEM_PAPEL',
83
FLAG_MECANISM_NOK: 'FLAG_MECANISM_NOK',
84
FLAG_DOCUMENTO_ABERTO: 'FLAG_DOCUMENTO_ABERTO',
85
FLAG_INSCRICOES_OK: 'FLAG_INSCRICOES_OK',
86
FLAG_CLICHE_OK: 'FLAG_CLICHE_OK',
87
FLAG_EM_LINHA: 'FLAG_EM_LINHA',
88
FLAG_MFD_ESGOTADA: 'FLAG_MFD_ESGOTADA',
92
class Pay2023Constants(BaseDriverConstants):
94
UnitType.WEIGHT: 'km',
95
UnitType.LITERS: 'lt',
96
UnitType.METERS: 'm ',
98
PaymentMethodType.MONEY: '-2',
99
PaymentMethodType.CHECK: '2',
100
# PaymentMethodType.MONEY: '-2',
101
# PaymentMethodType.CHECK: '0',
102
# PaymentMethodType.BOLETO: '1',
103
# PaymentMethodType.CREDIT_CARD: '2',
104
# PaymentMethodType.DEBIT_CARD: '3',
105
# PaymentMethodType.FINANCIAL: '4',
106
# PaymentMethodType.GIFT_CERTIFICATE: '5,
109
_RETVAL_TOKEN_RE = re.compile(r"^\s*([^=\s;]+)")
110
_RETVAL_QUOTED_VALUE_RE = re.compile(r"^\s*=\s*\"([^\"\\]*(?:\\.[^\"\\]*)*)\"")
111
_RETVAL_VALUE_RE = re.compile(r"^\s*=\s*([^\s;]*)")
112
_RETVAL_ESCAPE_RE = re.compile(r"\\(.)")
114
class Pay2023(SerialBase, BaseChequePrinter):
115
implements(IChequePrinter, ICouponPrinter)
37
class Pay2023(FiscNetChequePrinter):
38
log_domain = 'PertoPay2023'
117
41
model_name = "Pertopay Fiscal 2023"
118
coupon_printer_charset = "cp850"
119
cheque_printer_charset = "ascii"
121
CHEQUE_CONFIGFILE = 'perto.ini'
125
EOL_DELIMIT = CMD_SUFFIX
128
7003: OutofPaperError,
129
7004: OutofPaperError,
130
8007: CouponTotalizeError,
131
8011: PaymentAdditionError,
132
8013: CouponTotalizeError,
133
8014: PaymentAdditionError,
134
8017: CloseCouponError,
135
8044: CancelItemError,
136
8045: CancelItemError,
137
8068: PaymentAdditionError,
138
8086: CancelItemError,
139
15009: PendingReduceZ,
140
11002: CommandParametersError,
145
15011: OutofPaperError
148
def __init__(self, port, consts=None):
149
port.set_options(baudrate=115200, parity=PARITY_EVEN)
150
SerialBase.__init__(self, port)
151
BaseChequePrinter.__init__(self)
152
self._consts = consts or Pay2023Constants
157
self._customer_name = ''
158
self._customer_document = ''
159
self._customer_address = ''
164
def _parse_return_value(self, text):
165
# Based on cookielib.split_header_words
166
def unmatched(match):
167
start, end = match.span(0)
168
return match.string[:start] + match.string[end:]
173
m = _RETVAL_TOKEN_RE.search(text)
177
m = _RETVAL_QUOTED_VALUE_RE.search(text)
181
value = _RETVAL_ESCAPE_RE.sub(r"\1", value)
183
m = _RETVAL_VALUE_RE.search(text)
184
if m: # unquoted value
187
value = value.rstrip()
189
# no value, a lone token
197
def _send_command(self, command, **params):
200
for param, value in params.items():
201
if isinstance(value, Decimal):
202
value = ('%.03f' % value).replace('.', ',')
203
elif isinstance(value, basestring):
204
value = '"%s"' % value
205
elif isinstance(value, bool):
210
elif isinstance(value, datetime.date):
211
value = value.strftime('#%d/%m/%y#')
213
parameters.append('%s=%s' % (param, value))
215
reply = self.writeline("%d;%s;%s;" % (self._command_id,
217
' '.join(parameters)))
219
# This happened once after the first command issued after
220
# the power returned, it should probably be handled gracefully
221
raise AssertionError(repr(reply))
224
sections = reply[1:].split(';')
225
if len(sections) != 4:
228
retdict = self._parse_return_value(sections[2])
229
errorcode = int(sections[1])
231
errorname = retdict['NomeErro']
232
errordesc = retdict['Circunstancia']
234
exception = Pay2023.errors_dict[errorcode]
236
raise DriverError(errordesc, errorcode)
237
raise exception(errordesc, errorcode)
241
def _read_register(self, name, regtype):
244
argname = 'NomeInteiro'
245
retname = 'ValorInteiro'
246
elif regtype == Decimal:
248
argname = 'NomeDadoMonetario'
249
retname = 'ValorMoeda'
250
elif regtype == datetime.date:
253
retname = 'ValorData'
256
argname = 'NomeTexto'
257
retname = 'ValorTexto'
261
retdict = self._send_command(cmd, **dict([(argname, name)]))
262
assert len(retdict) == 1
263
assert retname in retdict
264
retval = retdict[retname]
267
elif regtype == Decimal:
268
retval = retval.replace('.', '')
269
retval = retval.replace(',', '.')
270
return Decimal(retval)
271
elif regtype == datetime.date:
272
# This happens the first time we send a ReducaoZ after
273
# opening the printer and removing the jumper.
274
if retval == '#00/00/0000#':
275
return datetime.date.today()
277
# "29/03/2007" -> datetime.date(2007, 3, 29)
278
d, m, y = map(int, retval[1:-1].split('/'))
279
return datetime.date(y, m, d)
281
# '"string"' -> 'string'
286
def _get_status(self):
287
return self._read_register('Indicadores', int)
289
def _get_last_item_id(self):
290
return self._read_register('ContadorDocUltimoItemVendido', int)
292
def _get_coupon_number(self):
293
return self._read_register('COO', int)
295
def _get_coupon_total_value(self):
296
return self._read_register('TotalDocLiquido', Decimal)
298
def _get_coupon_remainder_value(self):
299
value = self._read_register('TotalDocValorPago', Decimal)
300
result = self._get_coupon_total_value() - value
305
# This how the printer needs to be configured.
306
def _define_tax_name(self, code, name):
308
retdict = self._send_command(
309
'LeNaoFiscal', CodNaoFiscal=code)
310
except DriverError, e:
311
if e.code != 8057: # Not configured
314
for retname in ['NomeNaoFiscal', 'DescricaoNaoFiscal']:
315
configured_name = retdict[retname]
316
if configured_name != name:
318
"The name of the tax code %d is set to %r, "
319
"but it needs to be configured as %r" % (
320
code, configured_name, name))
324
'DefineNaoFiscal', CodNaoFiscal=code, DescricaoNaoFiscal=name,
325
NomeNaoFiscal=name, TipoNaoFiscal=False)
326
except DriverError, e:
330
def _delete_tax_name(self, code):
333
'ExcluiNaoFiscal', CodNaoFiscal=code)
334
except DriverError, e:
335
if e.code != 8057: # Not configured
338
def _define_payment_method(self, code, name):
340
retdict = self._send_command(
341
'LeMeioPagamento', CodMeioPagamentoProgram=code)
342
except DriverError, e:
343
if e.code != 8014: # Not configured
347
for retname in ['NomeMeioPagamento', 'DescricaoMeioPagamento']:
348
configured_name = retdict[retname]
349
if configured_name != name:
357
'DefineMeioPagamento',
358
CodMeioPagamentoProgram=code, DescricaoMeioPagamento=name,
359
NomeMeioPagamento=name, PermiteVinculado=False)
360
except DriverError, e:
363
def _delete_payment_method(self, code):
366
'ExcluiMeioPagamento', CodMeioPagamentoProgram=code)
367
except DriverError, e:
368
if e.code != 8014: # Not configured
371
def _define_tax_code(self, code, value, service=False):
373
retdict = self._send_command(
374
'LeAliquota', CodAliquotaProgramavel=code)
375
except DriverError, e:
376
if e.code != 8005: # Not configured
380
for retname in ['PercentualAliquota']:
381
configured_name = retdict[retname]
382
if configured_name != value:
391
CodAliquotaProgramavel=code,
392
DescricaoAliquota='%2.2f%%' % value ,
393
PercentualAliquota=value,
394
AliquotaICMS=not service)
395
except DriverError, e:
398
def _delete_tax_code(self, code):
401
'ExcluiAliquota', CodAliquotaProgramavel=code)
402
except DriverError, e:
403
if e.code != 8005: # Not configured
406
def _get_taxes(self):
408
('I', self._read_register('TotalDiaIsencaoICMS', Decimal)),
409
('F', self._read_register('TotalDiaSubstituicaoTributariaICMS',
411
('N', self._read_register('TotalDiaNaoTributadoICMS', Decimal)),
413
self._read_register('TotalDiaDescontos', Decimal)),
415
self._read_register('TotalDiaCancelamentosICMS', Decimal) +
416
self._read_register('TotalDiaCancelamentosISSQN', Decimal)),
418
self._read_register('TotalDiaISSQN', Decimal)),
421
for reg in range(16):
422
value = self._read_register('TotalDiaValorAliquota[%d]' % (
425
retdict = self._send_command(
426
'LeAliquota', CodAliquotaProgramavel=reg)
427
# The service taxes are already added in the 'ISS' tax
428
# Skip non-ICMS taxes here.
429
if retdict['AliquotaICMS'] == 'N':
431
desc = retdict['PercentualAliquota'].replace(',', '')
432
taxes.append(('%04d' % int(desc), value))
436
self._define_tax_name(0, "Suprimento".encode('cp850'))
437
self._define_tax_name(1, "Sangria".encode('cp850'))
438
for code in range(2, 15):
439
self._delete_tax_name(code)
441
self._define_payment_method(0, u'Cheque'.encode('cp850'))
442
self._define_payment_method(1, u'Boleto'.encode('cp850'))
443
self._define_payment_method(2, u'Cartão credito'.encode('cp850'))
444
self._define_payment_method(3, u'Cartão debito'.encode('cp850'))
445
self._define_payment_method(4, u'Financeira'.encode('cp850'))
446
self._define_payment_method(5, u'Vale compra'.encode('cp850'))
447
for code in range(6, 15):
448
self._delete_payment_method(code)
450
self._define_tax_code(0, Decimal("17.00"))
451
self._define_tax_code(1, Decimal("12.00"))
452
self._define_tax_code(2, Decimal("25.00"))
453
self._define_tax_code(3, Decimal("8.00"))
454
self._define_tax_code(4, Decimal("5.00"))
455
self._define_tax_code(5, Decimal("3.00"), service=True)
456
for code in range(6, 16):
457
self._delete_tax_code(code)
459
def print_status(self):
460
status = self._get_status()
462
for flag in reversed(_status_flags):
464
print flag, _flagnames[flag]
466
print 'non-fiscal registers'
469
print self._send_command(
470
'LeNaoFiscal', CodNaoFiscal=i)
471
except DriverError, e:
476
# ICouponPrinter implementation
479
def coupon_identify_customer(self, customer, address, document):
480
self._customer_name = customer
481
self._customer_document = document
482
self._customer_address = address
484
def coupon_is_customer_identified(self):
485
return len(self._customer_document) > 0
487
def coupon_open(self):
488
status = self._get_status()
489
if status & FLAG_DOCUMENTO_ABERTO:
490
raise CouponOpenError(_("Coupon already opened."))
492
customer = self._customer_name
493
document = self._customer_document
494
address = self._customer_address
495
self._send_command('AbreCupomFiscal',
496
EnderecoConsumidor=address[:80],
497
IdConsumidor=document[:29],
498
NomeConsumidor=customer[:30])
500
def coupon_add_item(self, code, description, price, taxcode,
501
quantity=Decimal("1.0"), unit=UnitType.EMPTY,
502
discount=Decimal("0.0"), surcharge=Decimal("0.0"),
504
status = self._get_status()
505
if not status & FLAG_DOCUMENTO_ABERTO:
506
raise CouponNotOpenError
508
if unit == UnitType.CUSTOM:
511
unit = self._consts.get_value(unit)
513
taxcode = ord(taxcode) - 128
514
self._send_command('VendeItem',
516
CodProduto=code[:48],
517
NomeProduto=description[:200],
521
return self._get_last_item_id()
523
def coupon_cancel_item(self, item_id):
524
self._send_command('CancelaItemFiscal', NumItem=item_id)
526
def coupon_cancel(self):
527
self._send_command('CancelaCupom')
529
def cancel_last_coupon(self):
530
"""Cancel the last non fiscal coupon or the last sale."""
531
self._send_command('CancelaCupom')
533
def coupon_totalize(self, discount=Decimal("0.0"),
534
surcharge=Decimal("0.0"), taxcode=TaxType.NONE):
535
# The FISCnet protocol (the protocol used in this printer model)
536
# doesn't have a command to totalize the coupon, so we just get
537
# the discount/surcharge values and applied to the coupon.
538
value = discount and (discount * -1) or surcharge
540
self._send_command('AcresceSubtotal',
542
ValorPercentual=value)
543
return self._get_coupon_total_value()
545
def coupon_add_payment(self, payment_method, value, description=u"",
548
pm = int(self._consts.get_value(payment_method))
551
self._send_command('PagaCupom',
552
CodMeioPagamento=pm, Valor=value,
553
TextoAdicional=description[:80])
554
return self._get_coupon_remainder_value()
556
def coupon_close(self, message=''):
557
self._send_command('EncerraDocumento',
558
TextoPromocional=message[:492])
560
return self._get_coupon_number()
563
self._send_command('EmiteLeituraX')
565
def close_till(self, previous_day=False):
566
status = self._get_status()
567
if status & FLAG_DOCUMENTO_ABERTO:
571
opening_date=self._read_register('DataAbertura', datetime.date),
572
serial=self._read_register('NumeroSerieECF', str),
573
serial_id=self._read_register('ECF', int),
574
coupon_start=self._read_register('COOInicioDia', int),
575
coupon_end=self._read_register('COO', int),
576
cro=self._read_register('CRO', int),
577
crz=self._read_register('CRZ', int),
578
period_total=self._read_register('TotalDiaVendaBruta', Decimal),
579
total=self._read_register('GT', Decimal),
580
taxes=self._get_taxes())
582
self._send_command('EmiteReducaoZ')
586
def till_add_cash(self, value):
587
status = self._get_status()
588
if status & FLAG_DOCUMENTO_ABERTO:
590
self._send_command('AbreCupomNaoFiscal')
591
self._send_command('EmiteItemNaoFiscal',
592
NomeNaoFiscal="Suprimento",
594
self._send_command('EncerraDocumento')
596
def till_remove_cash(self, value):
597
status = self._get_status()
598
if status & FLAG_DOCUMENTO_ABERTO:
600
self._send_command('AbreCupomNaoFiscal')
601
self._send_command('EmiteItemNaoFiscal',
602
NomeNaoFiscal="Sangria",
604
self._send_command('EncerraDocumento')
606
def till_read_memory(self, start=None, end=None):
608
self._send_command('EmiteLeituraMF',
609
LeituraSimplificada=True,
612
except DriverError, e:
616
def till_read_memory_by_reductions(self, start=None, end=None):
617
self._send_command('EmiteLeituraMF',
618
LeituraSimplificada=True,
619
ReducaoInicial=start,
623
# IChequePrinter implementation
626
def print_cheque(self, bank, value, thirdparty, city, date=None):
628
data = datetime.datetime.now()
629
if not isinstance(bank, BankConfiguration):
630
raise TypeError("bank parameter must be BankConfiguration instance")
632
data = dict(HPosAno=bank.get_x_coordinate("year"),
633
HPosCidade=bank.get_x_coordinate("city"),
634
HPosDia=bank.get_x_coordinate("day"),
635
HPosExtensoLinha1=bank.get_x_coordinate("legal_amount"),
636
HPosExtensoLinha2=bank.get_x_coordinate("legal_amount2"),
637
HPosFavorecido=bank.get_x_coordinate("thirdparty"),
638
HPosMes=bank.get_x_coordinate("month"),
639
HPosValor=bank.get_x_coordinate("value"),
640
VPosCidade=bank.get_y_coordinate("city"),
641
VPosExtensoLinha1=bank.get_y_coordinate("legal_amount"),
642
VPosExtensoLinha2=bank.get_y_coordinate("legal_amount2"),
643
VPosFavorecido=bank.get_y_coordinate("thirdparty"),
644
VPosValor=bank.get_y_coordinate("value"))
646
self._send_command('ImprimeCheque', Cidade=city[:27],
647
Data=date.strftime("#%d/%m/%Y#"),
648
Favorecido=thirdparty[:45],
651
def get_capabilities(self):
652
return dict(item_code=Capability(max_len=48),
653
item_id=Capability(max_size=32767),
654
items_quantity=Capability(digits=14, decimals=4),
655
item_price=Capability(digits=14, decimals=4),
656
item_description=Capability(max_len=200),
657
payment_value=Capability(digits=14, decimals=4),
658
promotional_message=Capability(max_len=492),
659
payment_description=Capability(max_len=80),
660
customer_name=Capability(max_len=30),
661
customer_id=Capability(max_len=29),
662
customer_address=Capability(max_len=80),
663
cheque_thirdparty=Capability(max_len=45),
664
cheque_value=Capability(digits=14, decimals=4),
665
cheque_city=Capability(max_len=27))
667
def get_constants(self):
670
def query_status(self):
671
return '{0;LeInteiro;NomeInteiro="Indicadores";}'
673
def status_reply_complete(self, reply):
676
def get_serial(self):
677
return self._read_register('NumeroSerieECF', str)
679
def get_tax_constants(self):
682
for reg in range(16):
684
retdict = self._send_command(
685
'LeAliquota', CodAliquotaProgramavel=reg)
686
except DriverError, e:
687
if e.code == 8005: # Aliquota nao carregada
691
# The service taxes are already added in the 'ISS' tax
692
# Skip non-ICMS taxes here.
693
if retdict['AliquotaICMS'] == 'Y':
694
tax_type = TaxType.CUSTOM
696
tax_type = TaxType.SERVICE
698
value = Decimal(retdict['PercentualAliquota'].replace(',', '.'))
699
device_value = int(retdict['CodAliquotaProgramavel'])
700
constants.append((tax_type,
701
chr(128 + device_value),
704
# These are signed integers, we're storing them
705
# as strings and then subtract by 127
708
(TaxType.SUBSTITUTION, '\x7e', None), # -2
709
(TaxType.EXEMPTION, '\x7d', None), # -3
710
(TaxType.NONE, '\x7c', None), # -4