Repository navigation
Expand file tree
/
Copy pathserver.py
More file actions
3903 lines (3618 loc) · 203 KB
/
Copy pathserver.py
File metadata and controls
3903 lines (3618 loc) · 203 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# SGEA v0.35.14 — Servidor local: SQLite, autenticação, REST API, controle de estoque por lote (FEFO), backup automático
import http.server
import os
import json
import sqlite3
import hashlib
import ssl
import smtplib
import threading
import time
import subprocess
import sys
import urllib.request
import urllib.error
import uuid
import re
import csv
import io
import base64
import zipfile
import unicodedata
from xml.etree import ElementTree as ET
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from urllib.parse import urlparse, parse_qs
import sgx_base
# Windows: console pode usar cp1252/cp850 em vez de UTF-8, quebrando prints
# com caracteres especiais (╔═╗, emojis). Força UTF-8 para evitar UnicodeEncodeError.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, 'reconfigure'):
try:
_stream.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# Versão do servidor — DEVE acompanhar o SGEA_VERSION do SGEA.html a cada release.
# Exposta em /health para o frontend detectar quando o processo em execução está
# desatualizado (HTML novo servido, mas server.py antigo ainda rodando em memória).
SERVER_VERSION = '0.36.0'
PORT = int(os.environ.get('SGEA_PORT', 3003))
_BASE = os.path.dirname(os.path.abspath(__file__))
# SGEA_DATA_DIR: usado pelos testes E2E para isolar banco/backups do sgea.db
# real sem precisar rodar o servidor a partir de outra pasta (os arquivos
# estáticos como SGEA.html continuam servidos a partir de _BASE).
_DATA_DIR = os.environ.get('SGEA_DATA_DIR', _BASE)
DB_PATH = os.path.join(_DATA_DIR, 'sgea.db')
BACKUP_DIR = os.path.join(_DATA_DIR, 'backups')
# Perfil do navegador em %TEMP%, como nos irmaos: dentro da pasta do sistema
# ele inchava para gigabytes e ia junto em qualquer copia da pasta.
PROFILE_DIR = os.path.join(os.environ.get('TEMP', os.path.expanduser('~')), 'SGEA-Profile')
BACKUP_KEEP = 7 # número de backups automáticos mantidos
SESSION_TTL = 60 # renovado pelo ping a cada 5s (ver comentário em _watchdog mais abaixo)
# Motor de erros da família (log rotativo UTF-8 + classificação) — ver sgx_base.
_log = sgx_base.configurar_log('SGEA', _DATA_DIR)
os.chdir(_BASE)
_watchdog_paused = False # pausa o watchdog durante diálogos bloqueantes (ex: FolderBrowser)
_had_session = False # True após primeiro login; controla quando o backup pós-sessão pode disparar
_backup_pos_sess = False # True = backup pós-sessão já executado; aguarda nova sessão para resetar
_last_trash_purge = 0 # time.time() da última purga da lixeira (roda no máx. 1x/hora)
TRASH_RETENTION_DIAS = 30 # dias até um registro na lixeira ser apagado de vez
# ── Banco de dados ────────────────────────────────────────────────────────────
# Alias de compatibilidade: _ConnAutoClose é referenciada diretamente em vários
# pontos além de get_db() (backup manual, restore, integrity check) — manter o
# nome em vez de caçar cada call site.
_ConnAutoClose = sgx_base.ConnAutoClose
def get_db():
return sgx_base.connect_db(DB_PATH)
# Ficha de manutenção do veículo: ano + catálogo de peças por veículo.
# Ordem = ordem das colunas na planilha "CONTROLE DE FROTA" (usada na migração,
# no CRUD e no importador). Todas TEXT, valor livre (referências cruzadas de marcas).
FROTA_PECAS_COLS = (
'ano',
'filtro_ar_cabine', 'filtro_ar_motor', 'filtro_ar_primario', 'filtro_ar_secundario',
'filtro_combustivel', 'filtro_sedimentador', 'filtro_lubrificante', 'filtro_desumidificador',
'filtro_hidraulico', 'filtro_transmissao', 'filtro_ureia',
'oleo_motor', 'oleo_transmissao', 'bateria', 'pneu_dianteiro', 'pneu_traseiro',
)
# Identificação oficial do veículo (vinda do Fiorilli, consolidada na planilha
# CONTROLE DE FROTA). Também TEXT/valor livre. Separada das peças de propósito:
# os relatórios de pendência olham só as peças, não estes campos.
FROTA_DOC_COLS = (
'renavam', 'chassi', 'cor', 'km_atual', 'categoria_cnh',
'especie_tce', 'potencia_cilindrada', 'lotacao', 'situacao', 'observacao',
)
def init_db():
with get_db() as conn:
conn.executescript('''
CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
nome TEXT NOT NULL,
senha_hash TEXT NOT NULL,
admin INTEGER DEFAULT 0,
ativo INTEGER DEFAULT 1,
must_change_password INTEGER DEFAULT 0,
criado_em TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime'))
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
expires REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_global (
id TEXT PRIMARY KEY, ts TEXT NOT NULL, user_id INTEGER, user_nome TEXT,
type TEXT, label TEXT, detail TEXT, process_id TEXT, process_obj TEXT
);
CREATE TABLE IF NOT EXISTS sys_settings (
key TEXT PRIMARY KEY, value TEXT
);
CREATE TABLE IF NOT EXISTS centros_custo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
codigo TEXT UNIQUE,
nome TEXT NOT NULL,
ativo INTEGER DEFAULT 1,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS fornecedores (
id TEXT PRIMARY KEY,
cnpj TEXT UNIQUE,
razao_social TEXT NOT NULL,
nome_fantasia TEXT,
telefone TEXT,
email TEXT,
ativo INTEGER DEFAULT 1,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS funcionarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
cargo TEXT,
unidade TEXT,
matricula TEXT,
ativo INTEGER DEFAULT 1,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS frota (
id INTEGER PRIMARY KEY AUTOINCREMENT,
numero TEXT UNIQUE,
placa TEXT,
marca TEXT,
modelo TEXT,
combustivel TEXT,
centro_custo_id INTEGER REFERENCES centros_custo(id),
ativo INTEGER DEFAULT 1,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS produtos (
id TEXT PRIMARY KEY,
codigo_fiorilli TEXT NOT NULL UNIQUE,
nome TEXT NOT NULL,
centro_custo_id INTEGER REFERENCES centros_custo(id),
unidade_licitada TEXT,
qtd_por_embalagem INTEGER NOT NULL DEFAULT 1,
unidade_consumo TEXT NOT NULL DEFAULT 'UN',
situacao_licitacao TEXT,
codigo_licitacao TEXT,
objeto_licitacao TEXT,
ativo INTEGER DEFAULT 1,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS pedidos (
id TEXT PRIMARY KEY,
numero TEXT NOT NULL,
codigo_licitacao TEXT,
data_pedido TEXT,
fornecedor_id TEXT REFERENCES fornecedores(id),
status TEXT DEFAULT 'aberto',
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
updated_at TEXT,
UNIQUE(numero, codigo_licitacao)
);
CREATE TABLE IF NOT EXISTS entradas (
id TEXT PRIMARY KEY,
pedido_id TEXT REFERENCES pedidos(id),
tipo TEXT NOT NULL DEFAULT 'pedido' CHECK(tipo IN ('pedido','compra_direta')),
fornecedor_id TEXT REFERENCES fornecedores(id),
nfe_numero TEXT,
nfe_chave_acesso TEXT,
data_entrega TEXT NOT NULL,
recebedor_id INTEGER REFERENCES funcionarios(id),
centro_custo_id INTEGER REFERENCES centros_custo(id),
observacao TEXT,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
created_by INTEGER REFERENCES usuarios(id),
updated_at TEXT,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS entrada_itens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entrada_id TEXT NOT NULL REFERENCES entradas(id) ON DELETE CASCADE,
produto_id TEXT NOT NULL REFERENCES produtos(id),
quantidade_embalagem REAL,
quantidade_unidades INTEGER NOT NULL,
valor_unitario REAL NOT NULL,
valor_total REAL NOT NULL,
lote_numero TEXT,
data_validade TEXT
);
CREATE TABLE IF NOT EXISTS pedido_itens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pedido_id TEXT NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
produto_id TEXT NOT NULL REFERENCES produtos(id),
quantidade_pedida INTEGER NOT NULL CHECK(quantidade_pedida > 0),
quantidade_anulada INTEGER NOT NULL DEFAULT 0,
UNIQUE(pedido_id, produto_id)
);
CREATE TABLE IF NOT EXISTS lotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
produto_id TEXT NOT NULL REFERENCES produtos(id),
lote_numero TEXT,
data_validade TEXT,
quantidade_recebida INTEGER NOT NULL,
quantidade_atual INTEGER NOT NULL CHECK(quantidade_atual >= 0),
valor_unitario_custo REAL NOT NULL DEFAULT 0,
entrada_item_id INTEGER REFERENCES entrada_itens(id),
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_lotes_produto_validade ON lotes(produto_id, data_validade);
CREATE TABLE IF NOT EXISTS saidas (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
solicitante_id INTEGER REFERENCES funcionarios(id),
solicitante_nome TEXT,
solicitante_cargo TEXT,
centro_custo_id INTEGER REFERENCES centros_custo(id),
frota_id INTEGER REFERENCES frota(id),
numero_solicitacao TEXT,
observacao TEXT,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now','localtime')),
created_by INTEGER REFERENCES usuarios(id),
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS saida_itens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
saida_id TEXT NOT NULL REFERENCES saidas(id) ON DELETE CASCADE,
produto_id TEXT NOT NULL REFERENCES produtos(id),
quantidade INTEGER NOT NULL CHECK(quantidade > 0),
valor_unitario_medio REAL NOT NULL,
valor_total REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS saida_item_lotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
saida_item_id INTEGER NOT NULL REFERENCES saida_itens(id) ON DELETE CASCADE,
lote_id INTEGER NOT NULL REFERENCES lotes(id),
quantidade INTEGER NOT NULL CHECK(quantidade > 0),
valor_unitario_custo REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_entradas_deleted ON entradas(deleted_at);
CREATE INDEX IF NOT EXISTS idx_saidas_deleted ON saidas(deleted_at);
CREATE INDEX IF NOT EXISTS idx_produtos_ativo ON produtos(ativo);
''')
cols_usu = [r[1] for r in conn.execute('PRAGMA table_info(usuarios)').fetchall()]
if 'cpf' not in cols_usu: conn.execute("ALTER TABLE usuarios ADD COLUMN cpf TEXT DEFAULT ''")
if 'email' not in cols_usu: conn.execute("ALTER TABLE usuarios ADD COLUMN email TEXT DEFAULT ''")
if 'cargo' not in cols_usu: conn.execute("ALTER TABLE usuarios ADD COLUMN cargo TEXT DEFAULT ''")
if 'matricula' not in cols_usu: conn.execute("ALTER TABLE usuarios ADD COLUMN matricula TEXT DEFAULT ''")
# Config SMTP por usuário (vazio = herda a do sistema em sys_settings) — paridade com SGCD/SGCA/SGDP
for _sc in _USER_SMTP_COLS:
if _sc not in cols_usu: conn.execute(f"ALTER TABLE usuarios ADD COLUMN {_sc} TEXT DEFAULT ''")
# Dados extras do funcionário vindos da folha do Fiorilli (natureza/forma de
# provimento do cargo, data e ato de admissão) — usados no cadastro e no import.
cols_func = [r[1] for r in conn.execute('PRAGMA table_info(funcionarios)').fetchall()]
for _fc in ('natureza', 'forma_provimento', 'data_admissao', 'ato_admissao'):
if _fc not in cols_func: conn.execute(f"ALTER TABLE funcionarios ADD COLUMN {_fc} TEXT DEFAULT ''")
# Ficha de manutenção do veículo (ano + catálogo de peças por veículo:
# filtros, óleos, bateria, pneus — referências cruzadas entre marcas).
cols_frota = [r[1] for r in conn.execute('PRAGMA table_info(frota)').fetchall()]
for _vc in FROTA_PECAS_COLS + FROTA_DOC_COLS:
if _vc not in cols_frota: conn.execute(f"ALTER TABLE frota ADD COLUMN {_vc} TEXT DEFAULT ''")
# Campos da entrada alinhados ao Fiorilli: nº e data da requisição, e o tipo
# de entrada do Fiorilli (TPREQUI, ex. "OUTRA"). Usados também pela importação.
cols_ent = [r[1] for r in conn.execute('PRAGMA table_info(entradas)').fetchall()]
for _ec in ('requisicao', 'data_requisicao', 'tipo_fiorilli'):
if _ec not in cols_ent: conn.execute(f"ALTER TABLE entradas ADD COLUMN {_ec} TEXT DEFAULT ''")
# Origem da entrada: '' (manual), 'reconciliacao' (saldo inicial do bootstrap) ou
# 'fiorilli' (import de entradas). tipo (pedido/compra_direta) tem CHECK e não
# comporta valor novo sem rebuild — origem é uma coluna à parte. Backfill das já
# criadas pelo texto da observação.
if 'origem' not in cols_ent:
conn.execute("ALTER TABLE entradas ADD COLUMN origem TEXT DEFAULT ''")
conn.execute("UPDATE entradas SET origem='reconciliacao' WHERE (origem IS NULL OR origem='') AND observacao LIKE 'Saldo inicial%'")
conn.execute("UPDATE entradas SET origem='fiorilli' WHERE (origem IS NULL OR origem='') AND observacao LIKE 'Importado do Fiorilli%'")
# Origem da saída: '' (manual) ou 'fiorilli' (import de requisições de saída).
cols_sai = [r[1] for r in conn.execute('PRAGMA table_info(saidas)').fetchall()]
if 'origem' not in cols_sai:
conn.execute("ALTER TABLE saidas ADD COLUMN origem TEXT DEFAULT ''")
# Campos do pedido que o Fiorilli exporta: previsão de entrega (DATENT), e por
# item o valor unitário arrematado (PRCUNT) e a marca vencedora (MARCA).
# origem: '' (manual) ou 'fiorilli' (import de pedidos).
cols_ped = [r[1] for r in conn.execute('PRAGMA table_info(pedidos)').fetchall()]
for _pc in ('data_entrega_prevista', 'origem'):
if _pc not in cols_ped: conn.execute(f"ALTER TABLE pedidos ADD COLUMN {_pc} TEXT DEFAULT ''")
cols_pi = [r[1] for r in conn.execute('PRAGMA table_info(pedido_itens)').fetchall()]
if 'valor_unitario' not in cols_pi:
conn.execute("ALTER TABLE pedido_itens ADD COLUMN valor_unitario REAL DEFAULT 0")
if 'marca' not in cols_pi:
conn.execute("ALTER TABLE pedido_itens ADD COLUMN marca TEXT DEFAULT ''")
# Responsável e e-mail do centro de custo (vêm do cadastro do Fiorilli).
cols_cc = [r[1] for r in conn.execute('PRAGMA table_info(centros_custo)').fetchall()]
for _cc in ('responsavel', 'email'):
if _cc not in cols_cc: conn.execute(f"ALTER TABLE centros_custo ADD COLUMN {_cc} TEXT DEFAULT ''")
cols_forn = [r[1] for r in conn.execute('PRAGMA table_info(fornecedores)').fetchall()]
if 'data' not in cols_forn:
# Reescrita rica do fornecedor (paridade com SGCD): schema vira id/data(JSON)/
# cnpj/razao_social/updated_at/deleted_at. Precisa recriar a tabela (não dá pra
# só ADD COLUMN) porque o UNIQUE em cnpj tem que sair — com ele, o INSERT OR
# REPLACE usado no upsert por CNPJ casaria por QUALQUER coluna UNIQUE/PK, podendo
# apagar silenciosamente uma linha errada casada pelo cnpj em vez do id (o SGCD
# nunca teve esse UNIQUE por isso). Constrói a substituta sob nome temporário e só
# DEPOIS derruba a original e renomeia (ver [[feedback_sqlite_fk_rename_migration]])
# — nunca renomear a original primeiro: o SQLite reescreve silenciosamente o
# REFERENCES fornecedores(id) de pedidos/entradas para apontar pro nome novo,
# deixando esses FKs órfãos assim que a tabela temporária é derrubada.
old_rows = conn.execute('SELECT * FROM fornecedores').fetchall()
conn.execute('''
CREATE TABLE fornecedores_new (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
cnpj TEXT,
razao_social TEXT,
updated_at TEXT,
deleted_at TEXT
)
''')
for r in old_rows:
blob = {
'id': r['id'], 'cnpj': r['cnpj'], 'razao_social': r['razao_social'],
'nome_fantasia': r['nome_fantasia'], 'telefone': r['telefone'],
'email': r['email'], 'ativo': r['ativo'], 'updatedAt': _now_precise(),
}
conn.execute(
'INSERT INTO fornecedores_new (id,data,cnpj,razao_social,updated_at,deleted_at) VALUES (?,?,?,?,?,NULL)',
(r['id'], json.dumps(blob, ensure_ascii=False), r['cnpj'], r['razao_social'], blob['updatedAt'])
)
conn.execute('DROP TABLE fornecedores')
conn.execute('ALTER TABLE fornecedores_new RENAME TO fornecedores')
conn.execute('CREATE INDEX IF NOT EXISTS idx_forn_cnpj ON fornecedores(cnpj)')
conn.execute('CREATE INDEX IF NOT EXISTS idx_forn_deleted ON fornecedores(deleted_at)')
conn.execute('''
CREATE VIEW IF NOT EXISTS v_estoque AS
SELECT p.id AS produto_id,
COALESCE(SUM(l.quantidade_atual), 0) AS estoque_fisico,
COALESCE(SUM(l.quantidade_atual * l.valor_unitario_custo), 0) AS estoque_financeiro,
MIN(CASE WHEN l.quantidade_atual > 0 THEN l.data_validade END) AS proxima_validade
FROM produtos p LEFT JOIN lotes l ON l.produto_id = p.id
GROUP BY p.id
''')
# Sessões são descartadas a cada início do servidor (logout automático ao fechar janela)
conn.execute('DELETE FROM sessions')
conn.executemany('INSERT OR IGNORE INTO sys_settings VALUES (?,?)', [
('orgao', ''), ('municipio', ''), ('cnpj_orgao', ''), ('aut_nome', ''), ('aut_cargo', ''),
('backup_path', BACKUP_DIR),
('auto_backup_enabled', '1'),
('auto_backup_keep', str(BACKUP_KEEP)),
('smtp_host', ''), ('smtp_port', '587'), ('smtp_user', ''), ('smtp_pass', ''),
('smtp_secure', '0'), ('smtp_require_tls', '1'), ('smtp_ignore_ssl', '0'),
('smtp_from_name', ''), ('smtp_to', ''),
])
conn.commit()
if conn.execute('SELECT COUNT(*) FROM usuarios').fetchone()[0] == 0:
conn.execute(
'INSERT INTO usuarios (username,nome,senha_hash,admin,must_change_password) VALUES (?,?,?,1,1)',
('admin', 'Administrador', _hash_password('admin123'))
)
conn.commit()
print('Usuário padrão criado: admin / admin123 — troque a senha no primeiro acesso.')
if sgx_base.marcar_senha_padrao(conn):
print('Atenção: há conta com a senha padrão — o sistema exigirá a troca no próximo acesso.')
# ── Segurança ─────────────────────────────────────────────────────────────────
_hash_password = sgx_base.hash_password
_verify_password = sgx_base.verify_password
# ── Rate limit de login ─────────────────────────────────────────────────────
LOGIN_MAX_ATTEMPTS = 5
LOGIN_LOCKOUT_WINDOW = 300 # 5 min — janela deslizante de tentativas falhas
_rate_limiter = sgx_base.LoginRateLimiter(LOGIN_MAX_ATTEMPTS, LOGIN_LOCKOUT_WINDOW)
_login_rate_limited = _rate_limiter.is_locked
_record_login_failure = _rate_limiter.record_failure
_clear_login_failures = _rate_limiter.clear
# create_session/delete_session/renew_session/active_sessions delegam pro
# sgx_base (mecânica idêntica nos 4 sistemas). get_session() fica local: faz
# um SELECT de colunas explícito (não u.*) por segurança — nunca deve devolver
# a coluna de hash de senha junto com os dados da sessão — e as colunas
# selecionadas divergem por sistema (schema de usuarios não é idêntico).
def create_session(user_id):
return sgx_base.create_session(get_db, user_id, SESSION_TTL)
def get_session(token):
if not token:
return None
with get_db() as conn:
row = conn.execute(
'''SELECT s.token, s.user_id, s.expires, u.nome, u.username, u.admin, u.ativo,
u.must_change_password
FROM sessions s JOIN usuarios u ON u.id=s.user_id
WHERE s.token=? AND s.expires>? AND u.ativo=1''',
(token, time.time())
).fetchone()
return dict(row) if row else None
def delete_session(token):
sgx_base.delete_session(get_db, token)
def renew_session(token):
sgx_base.renew_session(get_db, token, SESSION_TTL)
def active_sessions():
return sgx_base.active_sessions(get_db)
def _check_shutdown():
"""Dispara um backup automático, uma única vez, depois que a última sessão
ativa termina. O servidor nunca encerra sozinho — só via Ctrl+C."""
global _backup_pos_sess
if _had_session and active_sessions() == 0 and not _backup_pos_sess:
_backup_pos_sess = True
cfg = _get_backup_cfg()
if cfg['enabled']:
print('\nÚltima sessão encerrada. Executando backup automático...')
_do_json_backup(cfg)
_do_db_backup(cfg)
# ── Utilitários ───────────────────────────────────────────────────────────────
def _now():
return time.strftime('%Y-%m-%dT%H:%M:%S')
def _now_precise():
# Com precisão de milissegundo — usado especificamente em updatedAt para a
# checagem de conflito de edição concorrente (_baseUpdatedAt) do fornecedor
# rico. _now(), com precisão de segundo, colide facilmente entre duas
# edições rápidas em sequência. Formato ainda parseável por new Date() no cliente.
t = time.time()
return time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(t)) + f'.{int((t % 1) * 1000):03d}'
def _float(v):
# Regra única da família — ver sgx_base.parse_valor. Antes, este parser e o
# do front discordavam: "1.234" virava 1,234 aqui e 1234 na tela.
return sgx_base.parse_valor(v)
# ── Reconciliação com o Fiorilli (razão oficial; import read-only) ───────────
# O Fiorilli não converte unidade: exporta um número achatado na UNID1 dele. Todo
# o fator de conversão vive no SGEA (produtos.qtd_por_embalagem). Ver o desenho em
# memory/project_reconciliacao_fiorilli.md.
UNID_SINONIMOS = {
'UN': 'UN', 'UND': 'UN', 'UNID': 'UN', 'UNIDADE': 'UN', 'UNIDADES': 'UN',
'CX': 'CX', 'CAIXA': 'CX', 'CAIXAS': 'CX',
'PC': 'PC', 'PCT': 'PC', 'PACOTE': 'PC', 'PACOTES': 'PC',
'FR': 'FR', 'FRASCO': 'FR', 'FRASCOS': 'FR',
'KG': 'KG', 'QUILO': 'KG', 'QUILOS': 'KG', 'KILO': 'KG',
'L': 'L', 'LT': 'L', 'LITRO': 'L', 'LITROS': 'L', # LATA != litro, fica fora
'M': 'M', 'METRO': 'M', 'METROS': 'M',
'RL': 'RL', 'ROLO': 'RL', 'ROLOS': 'RL',
'PAR': 'PAR', 'PARES': 'PAR',
'RESMA': 'RESMA', 'RESMAS': 'RESMA',
}
_CADPRO_RE = re.compile(r'^\d{3}\.\d{3}\.\d{3}$')
_RECON_EPS = 0.5 # tolerância no grão da unidade física (estoque é inteiro); absorve o ×fator do arredondamento do Fiorilli
def _norm_unid(u):
u = (u or '').strip().upper()
return UNID_SINONIMOS.get(u, u)
def _parse_fiorilli_posicao(csv_text):
"""Parseia o export 'CSV (Dados)' do Fiorilli. Retorna {codigo: {...}}.
Levanta ValueError se o cabeçalho não bater (arquivo errado)."""
reader = csv.DictReader(io.StringIO(csv_text), delimiter=';')
faltando = {'CADPRO', 'DISC1', 'UNID1', 'QUAN3', 'VATO3'} - set(reader.fieldnames or [])
if faltando:
raise ValueError('Não parece o "CSV (Dados)" do Fiorilli. Faltam colunas: ' + ', '.join(sorted(faltando)))
itens = {}
for row in reader:
cod = (row.get('CADPRO') or '').strip()
if not _CADPRO_RE.match(cod):
continue # grupo (NNN), subgrupo (NNN.NNN) ou linha vazia
est = _float(row.get('QUAN3')) or 0.0
val = _float(row.get('VATO3')) or 0.0
itens[cod] = {
'descricao': (row.get('DISC1') or '').strip(),
'unidade': (row.get('UNID1') or '').strip().upper(),
'estoque': round(est, 4), # QUAN3 vem com lixo de float (até 15 casas)
'valor': round(val, 2),
'estoque_negativo': est < 0,
'valor_negativo': val < 0,
}
return itens
def _data_br_iso(s):
"""dd/mm/aaaa -> aaaa-mm-dd (ISO). Vazio/inválido -> ''."""
m = re.match(r'^\s*(\d{2})/(\d{2})/(\d{4})\s*$', s or '')
return f'{m.group(3)}-{m.group(2)}-{m.group(1)}' if m else ''
def _parse_fiorilli_entrada(csv_text):
"""Parseia o CSV 'REQUISIÇÃO DE ENTRADA' do Fiorilli. Agrupa por REQUI
(uma entrada, N itens). Levanta ValueError se o cabeçalho não bater."""
reader = csv.DictReader(io.StringIO((csv_text or '').lstrip('')), delimiter=';')
faltando = {'REQUI', 'DATAE', 'CADPRO', 'QUAN1'} - set(reader.fieldnames or [])
if faltando:
raise ValueError('Não parece a "REQUISIÇÃO DE ENTRADA" do Fiorilli. Faltam colunas: ' + ', '.join(sorted(faltando)))
def g(row, k): return (row.get(k) or '').strip()
reqs = {}
for row in reader:
requi = g(row, 'REQUI')
if not requi:
continue
if requi not in reqs:
reqs[requi] = {
'requisicao': requi,
'data_requisicao': _data_br_iso(g(row, 'DTLAN')),
'data_entrega': _data_br_iso(g(row, 'DATAE')) or _data_br_iso(g(row, 'DTLAN')),
'documento': g(row, 'DOCUM'),
'cnpj': g(row, 'INSMF'),
'fornecedor_nome': g(row, 'NOME'),
'numped': g(row, 'NUMPED'),
'centro_raw': g(row, 'COD_DESC_CCUSTOR') or g(row, 'CCUSTOR'),
'responsavel': g(row, 'RESPONSA') or g(row, 'SOLICITANTE'),
'proclic': g(row, 'PROCLIC'),
'tprequi': g(row, 'TPREQUI'),
'itens': [],
}
reqs[requi]['itens'].append({
'cadpro': g(row, 'CADPRO'), 'desc': g(row, 'DISC1'), 'desc_tec': g(row, 'DISCR1'),
'unid': g(row, 'UNID1'), 'qtd': _float(g(row, 'QUAN1')) or 0.0,
'valor': _float(g(row, 'VAUN1')) or 0.0, 'lote': g(row, 'LOTE'),
'validade': _data_br_iso(g(row, 'VALIDADE')), 'marca': g(row, 'MARCA'),
})
return list(reqs.values())
def _parse_fiorilli_saida(csv_text):
"""Parseia o CSV 'REQUISIÇÃO DE SAÍDA' do Fiorilli. Mesmo layout de colunas da
entrada, mas é o outro lado do razão: quantidade em QUAN2, valor unitário em
VAUN2 (QUAN1/VAUN1 vêm zerados), sem fornecedor nem NF. Agrupa por REQUI."""
reader = csv.DictReader(io.StringIO((csv_text or '').lstrip('')), delimiter=';')
faltando = {'REQUI', 'DTLAN', 'CADPRO', 'QUAN2'} - set(reader.fieldnames or [])
if faltando:
raise ValueError('Não parece a "REQUISIÇÃO DE SAÍDA" do Fiorilli. Faltam colunas: ' + ', '.join(sorted(faltando)))
def g(row, k): return (row.get(k) or '').strip()
reqs = {}
for row in reader:
requi = g(row, 'REQUI')
if not requi:
continue
if requi not in reqs:
reqs[requi] = {
'requisicao': requi,
'data': _data_br_iso(g(row, 'DTLAN')) or _data_br_iso(g(row, 'DTPAG')),
'centro_raw': g(row, 'COD_DESC_CCUSTOR') or g(row, 'CCUSTOR'),
'solicitante': g(row, 'RESPONSA') or g(row, 'SOLICITANTE'),
'numorc': g(row, 'NUMORC'),
'itens': [],
}
reqs[requi]['itens'].append({
'cadpro': g(row, 'CADPRO'), 'desc': g(row, 'DISC1'), 'unid': g(row, 'UNID1'),
'qtd': _float(g(row, 'QUAN2')) or 0.0, 'valor': _float(g(row, 'VAUN2')) or 0.0,
'lote': g(row, 'LOTE'), 'validade': _data_br_iso(g(row, 'VALIDADE')),
})
return list(reqs.values())
def _parse_fiorilli_pedido(csv_text):
"""Parseia o CSV 'PEDIDO' do Fiorilli. Uma linha por item, cabeçalho repetido;
agrupa por NUMPED. QTD vem na unidade licitada — quem chama converte para
unidade de consumo usando a embalagem do produto."""
reader = csv.DictReader(io.StringIO((csv_text or '').lstrip('')), delimiter=';')
faltando = {'NUMPED', 'DATPED', 'CADPRO', 'QTD'} - set(reader.fieldnames or [])
if faltando:
raise ValueError('Não parece a exportação de "PEDIDO" do Fiorilli. Faltam colunas: ' + ', '.join(sorted(faltando)))
def g(row, k): return (row.get(k) or '').strip()
peds = {}
for row in reader:
num = g(row, 'NUMPED')
if not num:
continue
if num not in peds:
peds[num] = {
'numero': num,
'data_pedido': _data_br_iso(g(row, 'DATPED')),
'data_entrega_prevista': _data_br_iso(g(row, 'DATENT')),
'cnpj': g(row, 'INSMF'),
'fornecedor_nome': g(row, 'NOME'),
'proclic': g(row, 'PROCLIC') or g(row, 'NUMLICIT'),
'modalidade': g(row, 'LICIT'),
'centro_raw': g(row, 'CODCCUSTO'),
'itens': [],
}
peds[num]['itens'].append({
'cadpro': g(row, 'CADPRO'), 'desc': g(row, 'DISC1'), 'unid': g(row, 'UNID1'),
'qtd': _float(g(row, 'QTD')) or 0.0, 'qtd_anulada': _float(g(row, 'QTDANU')) or 0.0,
'qtd_entregue': _float(g(row, 'QTDENT')) or 0.0,
'valor': _float(g(row, 'PRCUNT')) or 0.0, 'marca': g(row, 'MARCA'),
})
return list(peds.values())
# ── Importação da planilha "CONTROLE DE FROTA" ──────────────────────────────
def _norm_header(h):
"""Normaliza um cabeçalho de coluna: sem acento, maiúsculo, espaços colapsados."""
h = unicodedata.normalize('NFKD', h or '').encode('ascii', 'ignore').decode()
return ' '.join(h.upper().split())
# Cabeçalho da planilha (normalizado) -> campo da tabela frota.
_FROTA_HEADER_MAP = {
'FROTA': 'numero', 'PLACA': 'placa', 'CENTRO DE CUSTO': '_centro_custo_nome',
'MARCA': 'marca', 'MODELO': 'modelo', 'COMBUSTIVEL': 'combustivel',
'FABRICACAO / MODELO': 'ano', 'FABRICACAO/MODELO': 'ano', 'ANO': 'ano',
'FILTRO AR CABINE': 'filtro_ar_cabine', 'FILTRO AR MOTOR': 'filtro_ar_motor',
'FILTRO AR PRIMARIO': 'filtro_ar_primario', 'FILTRO AR SECUNDARIO': 'filtro_ar_secundario',
'FILTRO COMBUSTIVEL': 'filtro_combustivel', 'FILTRO SEDIMENTADOR': 'filtro_sedimentador',
'FILTRO LUBRIFICANTE': 'filtro_lubrificante', 'FILTRO DESUMIDIFICADOR': 'filtro_desumidificador',
'FILTRO HIDRAULICO': 'filtro_hidraulico', 'FILTRO TRANSMISSAO': 'filtro_transmissao',
'FILTRO UREIA': 'filtro_ureia', 'OLEO DE MOTOR': 'oleo_motor',
'OLEO DA TRANSMISSAO': 'oleo_transmissao', 'BATERIA': 'bateria',
'PNEU DIANTEIRO': 'pneu_dianteiro', 'PNEU TRASEIRO': 'pneu_traseiro',
# Identificação oficial (planilha aprimorada / Fiorilli)
'RENAVAM': 'renavam', 'CHASSI': 'chassi', 'COR': 'cor',
'KM ATUAL FIORILLI': 'km_atual', 'KM ATUAL': 'km_atual',
'CATEGORIA CNH': 'categoria_cnh', 'ESPECIE TCE': 'especie_tce',
'POTENCIA/CILINDRADA': 'potencia_cilindrada', 'POTENCIA / CILINDRADA': 'potencia_cilindrada',
'LOTACAO FIORILLI': 'lotacao', 'LOTACAO': 'lotacao',
'SITUACAO FIORILLI': 'situacao', 'SITUACAO': 'situacao',
'OBSERVACAO DE QUALIDADE': 'observacao', 'OBSERVACAO': 'observacao',
}
def _frota_registros(rows):
"""Recebe linhas (lista de listas) — de CSV ou XLSX — mapeia colunas por
cabeçalho (sem acento/caixa) e deduplica por número de frota (última vence).
Retorna lista de dicts (campo -> valor)."""
rows = [r for r in rows if r]
if not rows:
raise ValueError('Planilha vazia.')
header = [_norm_header(str(h)) for h in rows[0]]
idx = {}
for i, h in enumerate(header):
campo = _FROTA_HEADER_MAP.get(h)
if campo and campo not in idx:
idx[campo] = i
if 'numero' not in idx:
raise ValueError('Não encontrei a coluna "FROTA" (número do veículo) na planilha.')
dedup = {} # numero -> registro (última linha vence)
for r in rows[1:]:
if not any(str(c or '').strip() for c in r):
continue
reg = {campo: (str(r[i]).strip() if i < len(r) and r[i] is not None else '') for campo, i in idx.items()}
num = (reg.get('numero') or '').strip()
if not num:
continue
dedup[num] = reg
return list(dedup.values())
def _parse_frota_csv(csv_text):
"""Parseia a planilha CONTROLE DE FROTA exportada em CSV (detecta ; ou ,)."""
csv_text = (csv_text or '').lstrip('')
linhas = csv_text.splitlines()
if not linhas or not csv_text.strip():
raise ValueError('CSV vazio.')
delim = ';' if linhas[0].count(';') >= linhas[0].count(',') else ','
rows = list(csv.reader(io.StringIO(csv_text), delimiter=delim))
return _frota_registros(rows)
_XLSX_NS = '{http://schemas.openxmlformats.org/spreadsheetml/2006/main}'
_XLSX_R = '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}'
_XLSX_REL = '{http://schemas.openxmlformats.org/package/2006/relationships}'
def _col_idx(ref):
"""Referência de célula ('B3') -> índice 0-based da coluna (1)."""
letras = ''.join(ch for ch in ref if ch.isalpha())
n = 0
for ch in letras:
n = n * 26 + (ord(ch.upper()) - 64)
return n - 1
def _xlsx_para_linhas(raw, aba='DADOS'):
"""Lê um .xlsx (zip de XMLs) só com a stdlib e devolve as linhas da aba pedida
(ou a primeira, se não achar) como lista de listas de strings. Sem dependência."""
try:
z = zipfile.ZipFile(io.BytesIO(raw))
except Exception:
raise ValueError('Arquivo .xlsx inválido ou corrompido.')
nomes = z.namelist()
# workbook: nome da aba -> r:id ; primeiro como fallback
wb = ET.fromstring(z.read('xl/workbook.xml'))
alvo_rid = primeiro_rid = None
for sh in wb.iter(f'{_XLSX_NS}sheet'):
rid = sh.get(f'{_XLSX_R}id')
if primeiro_rid is None:
primeiro_rid = rid
if (sh.get('name') or '').strip().upper() == aba.upper():
alvo_rid = rid
rid = alvo_rid or primeiro_rid
# rels: r:id -> arquivo da planilha
rels = ET.fromstring(z.read('xl/_rels/workbook.xml.rels'))
target = None
for rel in rels.iter(f'{_XLSX_REL}Relationship'):
if rel.get('Id') == rid:
target = rel.get('Target')
if not target:
raise ValueError('Não consegui localizar a aba dentro do .xlsx.')
path = target if target.startswith('xl/') else 'xl/' + target.lstrip('/')
# textos compartilhados
shared = []
if 'xl/sharedStrings.xml' in nomes:
sst = ET.fromstring(z.read('xl/sharedStrings.xml'))
for si in sst.iter(f'{_XLSX_NS}si'):
shared.append(''.join(t.text or '' for t in si.iter(f'{_XLSX_NS}t')))
# linhas da aba
ws = ET.fromstring(z.read(path))
linhas = []
for row in ws.iter(f'{_XLSX_NS}row'):
cells, maxc = {}, -1
for c in row.findall(f'{_XLSX_NS}c'):
col = _col_idx(c.get('r') or 'A')
t = c.get('t')
v = c.find(f'{_XLSX_NS}v')
if t == 's':
val = shared[int(v.text)] if v is not None and v.text is not None else ''
elif t == 'inlineStr':
isn = c.find(f'{_XLSX_NS}is')
val = ''.join(tt.text or '' for tt in isn.iter(f'{_XLSX_NS}t')) if isn is not None else ''
else:
val = v.text if v is not None and v.text is not None else ''
cells[col] = val
maxc = max(maxc, col)
linhas.append([str(cells.get(i, '')) for i in range(maxc + 1)])
return linhas
# ── Importação do "CADASTRO DE CENTROS DE CUSTO" (export do Fiorilli) ───────
# CODCCUSTO é o código único do centro (1..N); DESCR é a descrição usada como nome.
_CC_HEADER_MAP = {
'CODCCUSTO': 'codigo', 'CODIGO': 'codigo', 'COD': 'codigo',
'DESCR': 'nome', 'DESCRICAO': 'nome', 'NOME': 'nome',
'RESPONSA': 'responsavel', 'RESPONSAVEL': 'responsavel',
'EMAIL': 'email', 'E-MAIL': 'email',
}
def _norm_nome(s):
"""Nome sem acento, maiúsculo e com espaços colapsados — usado para casar
um centro do arquivo com um já cadastrado (que pode ter vindo sem código)."""
s = unicodedata.normalize('NFKD', s or '').encode('ascii', 'ignore').decode()
return ' '.join(s.upper().split())
def _parse_centros_csv(csv_text):
"""Parseia o CSV de centros de custo do Fiorilli. Detecta o delimitador,
mapeia colunas por cabeçalho e deduplica por código. Retorna lista de dicts."""
csv_text = csv_text.lstrip('')
linhas = csv_text.splitlines()
if not linhas or not csv_text.strip():
raise ValueError('CSV vazio.')
delim = ';' if linhas[0].count(';') >= linhas[0].count(',') else ','
rows = list(csv.reader(io.StringIO(csv_text), delimiter=delim))
header = [_norm_header(h) for h in rows[0]]
idx = {}
for i, h in enumerate(header):
campo = _CC_HEADER_MAP.get(h)
if campo and campo not in idx:
idx[campo] = i
if 'nome' not in idx:
raise ValueError('Não encontrei a coluna de descrição do centro de custo (DESCR) no CSV.')
dedup = {}
for r in rows[1:]:
if not any((c or '').strip() for c in r):
continue
reg = {campo: (r[i].strip() if i < len(r) and r[i] is not None else '') for campo, i in idx.items()}
if not (reg.get('nome') or '').strip():
continue
chave = (reg.get('codigo') or '').strip() or _norm_nome(reg['nome'])
dedup[chave] = reg
return list(dedup.values())
def _classificar_reconciliacao(fiorilli, produtos, data_corte):
"""Cruza o Fiorilli (só itens com saldo; zerados omitidos) com os produtos do
SGEA (saldo inteiro em unidade_consumo) por codigo_fiorilli. Um balde por código."""
by_cod = {}
for p in produtos:
c = (p.get('codigo_fiorilli') or '').strip()
if c:
by_cod[c] = p
resumo = {'confere': 0, 'diverge': 0, 'so_fiorilli': 0, 'so_sgea': 0, 'unidade': 0}
itens = []
for cod in sorted(set(fiorilli) | set(by_cod)):
F, S = fiorilli.get(cod), by_cod.get(cod)
flags = []
if F and F['estoque_negativo']: flags.append('fiorilli_estoque_negativo')
if F and F['valor_negativo']: flags.append('fiorilli_valor_negativo')
qf = qs = delta = None
if S is None: # Fiorilli tem saldo, SGEA não cadastrou
balde, qf, unid = 'so_fiorilli', F['estoque'], F['unidade']
elif F is None: # ausente do Fiorilli => saldo 0 lá (omite zerados)
qs = float(S['estoque_fisico'] or 0)
unid = S.get('unidade_consumo') or 'UN'
if abs(qs) <= _RECON_EPS:
balde, qf, delta = 'confere', 0.0, 0.0 # zero implícito
else:
balde, qf, delta = 'so_sgea', 0.0, qs
else: # ambos presentes
qs = float(S['estoque_fisico'] or 0)
unid = S.get('unidade_consumo') or 'UN'
uf = _norm_unid(F['unidade'])
uc, ul = _norm_unid(unid), _norm_unid(S.get('unidade_licitada') or '')
fator = 1.0 if uf == uc else (float(S.get('qtd_por_embalagem') or 1) if ul and uf == ul else None)
if fator is None: # unidade não casa nem com consumo nem com licitada
balde, qf = 'unidade', F['estoque']
else:
qf = round(F['estoque'] * fator, 4) # Fiorilli trazido p/ unidade de consumo
delta = round(qs - qf, 4)
if abs(delta) < _RECON_EPS:
balde = 'confere'
if abs((F['valor'] or 0) - float(S['estoque_financeiro'] or 0)) > 0.02:
flags.append('valor_divergente')
else:
balde = 'diverge'
resumo[balde] += 1
itens.append({
'codigo': cod, 'balde': balde,
'descricao': (S['nome'] if S else F['descricao']),
'fiorilli_qtd': qf, 'sgea_qtd': qs, 'delta': delta, 'unidade': unid,
'valor_fiorilli': (F['valor'] if F else None), 'flags': flags,
})
resumo['total'] = len(itens)
return {'data_corte': data_corte, 'resumo': resumo, 'itens': itens}
def _require(data, *fields):
for f in fields:
v = data.get(f)
if v is None or (isinstance(v, str) and not v.strip()):
raise ValueError(f'Campo obrigatório: {f}')
class EstoqueInsuficiente(Exception):
def __init__(self, produto_id, solicitado, disponivel):
self.produto_id, self.solicitado, self.disponivel = produto_id, solicitado, disponivel
super().__init__(f'Estoque insuficiente para o produto (solicitado: {solicitado}, disponível: {disponivel})')
class SaldoPedidoExcedido(Exception):
def __init__(self, mensagem):
super().__init__(mensagem)
def _pedido_item_status(pedida, recebida, anulada):
if anulada > 0:
return 'encerrado_parcial'
if recebida >= pedida:
return 'atendido'
if recebida > 0:
return 'parcial'
return 'aberto'
def _pedido_itens_com_saldo(conn, pedido_id, ignorar_entrada_id=None):
"""Itens do pedido com quantidade_recebida/saldo/status calculados a partir das
entradas vinculadas — quantidade_recebida nunca é armazenada, só derivada, pra não
correr o risco de um contador desalinhar da soma real de entrada_itens.
`ignorar_entrada_id` tira uma entrada da conta. Serve à edição: ao regravar os
itens de uma entrada já lançada, o que ela mesma trouxe não pode contar contra
o saldo do pedido — senão editar qualquer coisa nela acusaria excesso."""
rows = conn.execute('''
SELECT pi.id, pi.produto_id, pi.quantidade_pedida, pi.quantidade_anulada,
pi.valor_unitario, pi.marca,
p.nome AS produto_nome, p.unidade_consumo,
COALESCE((
SELECT SUM(ei.quantidade_unidades)
FROM entrada_itens ei JOIN entradas e ON e.id = ei.entrada_id
WHERE e.pedido_id = pi.pedido_id AND ei.produto_id = pi.produto_id
AND e.deleted_at IS NULL AND (? IS NULL OR e.id <> ?)
), 0) AS quantidade_recebida
FROM pedido_itens pi JOIN produtos p ON p.id = pi.produto_id
WHERE pi.pedido_id = ?
ORDER BY pi.id
''', (ignorar_entrada_id, ignorar_entrada_id, pedido_id)).fetchall()
itens = []
for r in rows:
d = dict(r)
d['saldo'] = max(d['quantidade_pedida'] - d['quantidade_recebida'] - d['quantidade_anulada'], 0)
d['status'] = _pedido_item_status(d['quantidade_pedida'], d['quantidade_recebida'], d['quantidade_anulada'])
itens.append(d)
return itens
def _pedido_status_agregado(status_coluna, itens):
"""Status exibido do pedido: 'cancelado' é o único valor manual (setado só por
_cancelar_pedido); qualquer outra coisa é sempre recalculada a partir dos itens,
nunca lida da coluna — evita a coluna e os itens saírem de sincronia."""
if status_coluna == 'cancelado':
return 'cancelado'
if not itens:
return 'aberto'
if any(i['status'] in ('aberto', 'parcial') for i in itens):
return 'aberto'
if all(i['status'] == 'atendido' for i in itens):
return 'atendido'
return 'encerrado_parcial'
def _lote_norm(x):
"""Normaliza nº de lote para comparação: o Fiorilli exporta zero-padded
('00000001'), a digitação manual não ('1'). '00000000'/'' = sem lote."""
return (x or '').strip().lstrip('0').upper()
def _consumir_fefo(conn, produto_id, quantidade_solicitada, preferir_lote=None):
"""Consome `quantidade_solicitada` unidades do produto, priorizando o lote
com validade mais próxima (FEFO); lotes sem validade (incl. saldo inicial)
são consumidos por último, em ordem de chegada. `preferir_lote` (nº, validade)
põe na frente o lote que o Fiorilli baixou, quando ele existe aqui — o resto
da fila segue FEFO. Levanta EstoqueInsuficiente sem alterar nenhuma linha se o
total disponível não cobrir o pedido — quem chama roda isso dentro do mesmo
`with get_db()` do resto da operação, então qualquer exceção desfaz tudo via
_ConnAutoClose."""
lotes = conn.execute('''
SELECT id, quantidade_atual, valor_unitario_custo, lote_numero, data_validade
FROM lotes
WHERE produto_id = ? AND quantidade_atual > 0
ORDER BY (data_validade IS NULL) ASC, data_validade ASC, id ASC
''', (produto_id,)).fetchall()
if preferir_lote:
_num, _val = preferir_lote
_num = _lote_norm(_num)
if _num or _val:
# sorted é estável: o que não casa mantém a ordem FEFO acima
lotes = sorted(lotes, key=lambda l: 0 if (
_lote_norm(l['lote_numero']) == _num if _num else l['data_validade'] == _val
) else 1)
disponivel = sum(l['quantidade_atual'] for l in lotes)
if disponivel < quantidade_solicitada:
raise EstoqueInsuficiente(produto_id, quantidade_solicitada, disponivel)
restante, consumos = quantidade_solicitada, []
for l in lotes:
if restante <= 0:
break
qtd = min(l['quantidade_atual'], restante)
conn.execute('UPDATE lotes SET quantidade_atual = quantidade_atual - ? WHERE id = ?', (qtd, l['id']))
consumos.append((l['id'], qtd, l['valor_unitario_custo']))
restante -= qtd
valor_total = sum(q * c for _, q, c in consumos)
valor_medio = valor_total / quantidade_solicitada if quantidade_solicitada else 0
return consumos, valor_medio, valor_total
def _lotes_vencendo(dias):
"""Lotes com saldo vencidos ou vencendo dentro de `dias`. Compartilhado entre
GET /api/alertas/validade e o alerta diário por e-mail (_send_daily_alerts)."""
limite = time.strftime('%Y-%m-%d', time.localtime(time.time() + dias * 86400))
hoje = time.strftime('%Y-%m-%d')
with get_db() as conn:
rows = conn.execute('''
SELECT l.*, p.nome AS produto_nome, p.codigo_fiorilli, p.unidade_consumo,
c.nome AS centro_custo_nome
FROM lotes l
JOIN produtos p ON p.id=l.produto_id
LEFT JOIN centros_custo c ON c.id=p.centro_custo_id
WHERE l.quantidade_atual > 0 AND l.data_validade IS NOT NULL AND l.data_validade <= ?
ORDER BY l.data_validade ASC
''', (limite,)).fetchall()
items = []
for r in rows:
d = dict(r)
d['vencido'] = d['data_validade'] < hoje
items.append(d)
return items
def _movimentacao_mensal(conn):
"""Série de 6 meses (mais antigo → mais recente) de valor_total de entradas e
saídas, agrupada por mês com zero-fill — mês sem movimento aparece com 0, não
some da série (senão o gráfico do dashboard distorceria a escala)."""
meses = []
hoje = time.localtime()
for i in range(5, -1, -1):
ano, mes = hoje.tm_year, hoje.tm_mon - i
while mes <= 0:
mes += 12; ano -= 1
meses.append(f'{ano:04d}-{mes:02d}')
entradas_por_mes = dict(conn.execute('''
SELECT strftime('%Y-%m', e.data_entrega) AS m, SUM(ei.valor_total)