Sistema de Consulta Estoque TOTVS - Protheus

Com o intuito de otimizar o processo de consulta de estoque, foi desenvolvido um sistema que integra com o ERP TOTVS-Protheus, permitindo consultas rápidas por código de produto, visualização de saldos, lotes, empenhos e movimentações em tempo real.

Python Flask SQL Server JavaScript HTML/CSS

🔍 Consulta por Código

Busca instantânea de produtos pelo código, com informações detalhadas de estoque, lotes e movimentações.

📊 Saldo Analítico MATR260

Relatório completo de posição de estoque analítico com exportação para Excel formatado.

🏭 Filtro por Armazém

Visualização segmentada por almoxarifado, MP moído, MP fábrica e produto acabado.

📋 Histórico de Buscas

Armazena as últimas consultas no navegador para acesso rápido aos produtos pesquisados.

📦 Controle de Lotes

Visualização de lotes com filtros por vencidos, próximos ao vencimento e ativos.

📈 Exportação Excel

Exporta dados de saldo analítico para planilha Excel formatada com cabeçalhos e estilos profissionais.

Pré-visualização do Frontend

frontend/index.html
1.234
Estoque Total
987
Disponível
123
Reservado
124
Empenhado
ArmazémSaldo AtualDisponívelReservado
01 - Almoxarifado50045050
04 - Prod. Acabado73453773
11 - MP Moído000

Código do Projeto

app.py
src/db.py
frontend/index.html
frontend/app.js
frontend/styles.css
"""
Trimplas - API Flask para consulta de produtos Protheus.
"""

import logging
from flask import Flask, render_template, request, jsonify, send_from_directory, Response
from flask_cors import CORS
from datetime import datetime
import sys, os

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from src.config import HOST, PORT, LOG_LEVEL
from src.db import buscar_info_produto, formatar_resposta, buscar_saldo_analitico, gerar_excel_matr260

app = Flask(__name__, template_folder='frontend', static_folder='frontend/static')
CORS(app)


@app.route("/")
def index():
    """Pagina principal."""
    return render_template('index.html')


@app.route("/api/health")
def health():
    return jsonify({
        "status": "online",
        "servidor": f"http://{HOST}:{PORT}",
        "timestamp": datetime.now().isoformat()
    })


@app.route("/api/produto/<codigo>")
def api_buscar_produto(codigo):
    """Busca informacoes de um produto."""
    if not codigo or not codigo.strip():
        return jsonify({"erro": "Codigo obrigatorio"}), 400

    try:
        dados = buscar_info_produto(codigo.strip())
        if not dados["produto"]:
            return jsonify({"erro": "Produto nao encontrado"}), 404
        return jsonify({
            "sucesso": True,
            "codigo": codigo,
            "dados": dados,
            "timestamp": datetime.now().isoformat()
        })
    except Exception as e:
        return jsonify({"erro": str(e)}), 500


@app.route("/api/matr260")
def api_matr260():
    """Reproduz o relatorio MATR260 - Posicao de Estoque Analitico."""
    filial = request.args.get("filial", "01")
    armazem = request.args.get("armazem", "")
    saldo_tipo = request.args.get("saldo_tipo", "A")

    if not armazem:
        return jsonify({"erro": "Armazem obrigatorio"}), 400

    registros = buscar_saldo_analitico(filial, armazem, saldo_tipo)
    return jsonify({
        "sucesso": True,
        "filial": filial,
        "armazem": armazem,
        "registros": registros,
        "total_registros": len(registros),
        "timestamp": datetime.now().isoformat()
    })


@app.route("/api/matr260/excel")
def api_matr260_excel():
    """Exporta relatorio MATR260 para Excel formatado."""
    filial = request.args.get("filial", "01")
    armazem = request.args.get("armazem", "")

    registros = buscar_saldo_analitico(filial, armazem, "A")
    excel_bytes = gerar_excel_matr260(registros, filial, armazem, "A", "")

    return Response(
        excel_bytes,
        mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        headers={"Content-Disposition": f"attachment; filename={filename}"}
    )


if __name__ == "__main__":
    logger.info("  Trimplas - Consulta de Produtos")
    logger.info(f"  Interface: http://{HOST}:{PORT}/")
    app.run(host=HOST, port=PORT, debug=False, threaded=True)
"""
Modulo de conexao e consultas ao banco Protheus.
Tabelas: SB1 (Produtos), SB2 (Saldos), SB8 (Lotes),
         SC2 (Empenhos), SD3 (Movimentacoes)
"""

import pyodbc
import logging
from contextlib import contextmanager
from typing import Optional, List, Dict, Any
from src.config import DB_CONFIG


def _build_connection_string():
    return (
        "DRIVER={SQL Server};"
        f"SERVER={DB_CONFIG['server']};"
        f"DATABASE={DB_CONFIG['database']};"
        f"UID={DB_CONFIG['username']};"
        f"PWD={DB_CONFIG['password']};"
        "Connection Timeout=30;"
    )


@contextmanager
def get_connection():
    """Context manager para conexao com banco Protheus."""
    conn = None
    try:
        conn = pyodbc.connect(_build_connection_string())
        yield conn
    finally:
        if conn:
            conn.close()


def _execute_query(query, params=None):
    import pandas as pd
    with get_connection() as conn:
        df = pd.read_sql(query, conn, params=params)
        df = df.astype(object).where(pd.notnull(df), None)
        return df.to_dict('records')


def buscar_produto(codigo):
    """Busca informacoes basicas do produto (SB1010)."""
    query = """
        SELECT TOP 1
            LTRIM(RTRIM(B1_COD))    AS CODIGO,
            LTRIM(RTRIM(B1_DESC))   AS DESCRICAO,
            LTRIM(RTRIM(B1_UM))     AS UNIDADE,
            LTRIM(RTRIM(B1_GRUPO))  AS GRUPO
        FROM SB1010
        WHERE B1_COD = ? AND D_E_L_E_T_ = ''
    """
    resultados = _execute_query(query, (codigo,))
    return resultados[0] if resultados else None


def buscar_saldos(codigo):
    """Busca saldos do produto por armazem (SB2010)."""
    query = """
        SELECT
            LTRIM(RTRIM(B2_LOCAL))  AS ARMAZEM,
            B2_QATU                 AS SALDO_ATUAL,
            B2_RESERV               AS RESERVADO,
            B2_EMPENH               AS EMPENHADO,
            (B2_QATU - B2_RESERV - B2_EMPENH) AS DISPONIVEL
        FROM SB2010
        WHERE B2_COD = ? AND D_E_L_E_T_ = ''
        ORDER BY B2_LOCAL
    """
    return _execute_query(query, (codigo,))
<!-- Frontend principal da aplicacao de consulta -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="UTF-8">
    <title>Trimplas - Consulta de Produtos</title>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="/static/styles.css">
</head>
<body>
    <div class="app">

        <!-- HEADER com busca e acoes -->
        <header class="header">
            <div class="header-brand">
                <div class="brand-text">
                    <span class="brand-name">Trimplas</span>
                    <span class="brand-subtitle">Perfilados Plasticos</span>
                </div>
            </div>

            <!-- Filtro por Armazem -->
            <form class="search-form" id="search-form">
                <div class="warehouse-filter">
                    <button type="button" data-armazem="">Exibir todos</button>
                    <button type="button" data-armazem="01">01 - Almoxarifado</button>
                    <button type="button" data-armazem="04">04 - Produto Acabado</button>
                </div>

                <!-- Input de busca -->
                <div class="search-wrapper">
                    <input type="text" id="codigo"
                           placeholder="Buscar por codigo do produto...">
                </div>
                <button type="submit" class="btn-search">Buscar</button>
            </form>

            <!-- Acoes: Saldo Analitico, Historico, Status -->
            <div class="header-actions">
                <button id="btn-matr260">Saldo Analitico</button>
                <button id="btn-history">Histórico</button>
                <div class="status-pill">
                    <span class="status-dot"></span>
                    <span class="status-text">Online</span>
                </div>
            </div>
        </header>

        <!-- WELCOME STATE -->
        <div class="welcome-state" id="welcome-state">
            <h1>Consulta de Produtos</h1>
            <p>Digite o codigo do produto no campo de busca</p>
        </div>

        <!-- PRODUCT VIEW -->
        <div class="product-view" id="product-view">
            <div class="product-header">
                <h2 id="product-name">--</h2>
                <button class="btn btn-success" id="btn-export">
                    Exportar Excel
                </button>
            </div>

            <!-- Sumario de Estoques -->
            <div class="stock-summary">
                <div class="summary-card">
                    <span>Estoque Total</span>
                    <strong id="summary-total">0,00</strong>
                </div>
                <!-- ... demais cards ... -->
            </div>

            <!-- Abas: Saldos | Lotes | Empenhos | Movimentacoes -->
            <div class="tabs">
                <button class="tab active">Saldos</button>
                <button class="tab">Lotes</button>
                <button class="tab">Empenhos</button>
                <button class="tab">Movimentações</button>
            </div>
        </div>

        <!-- LOADING STATE -->
        <div class="loading-state" id="loading-state">
            Carregando...
        </div>
    </div>
    <script src="/static/app.js"></script>
</body>
</html>
const API_BASE = '';
let dadosAtuais = null;
let codigoAtual = null;
let armazemSelecionado = '';
let searchHistory = JSON.parse(localStorage.getItem('searchHistory') || '[]');

function formatarNumero(valor) {
    if (valor === null || valor === undefined) return '0,00';
    const num = parseFloat(valor);
    return isNaN(num) ? '0,00' :
        num.toLocaleString('pt-BR', { minimumFractionDigits: 2 });
}

function showState(state) {
    document.getElementById('welcome-state').style.display =
        state === 'welcome' ? 'flex' : 'none';
    document.getElementById('product-view').style.display =
        state === 'product' ? 'block' : 'none';
    document.getElementById('loading-state').style.display =
        state === 'loading' ? 'flex' : 'none';
}

async function buscarProduto(codigo) {
    codigo = codigo.toUpperCase().trim();
    if (!codigo) return;

    showState('loading');

    try {
        const response = await fetch(
            `${API_BASE}/api/produto/${encodeURIComponent(codigo)}`
        );
        const data = await response.json();

        if (!data.sucesso || !data.dados.produto) {
            showState('error');
            return;
        }

        dadosAtuais = data;
        codigoAtual = codigo;
        atualizarUI();
        showState('product');
    } catch (error) {
        showState('error');
    }
}

function atualizarUI() {
    const p = dadosAtuais.dados.produto;
    const saldos = dadosAtuais.dados.saldos || [];

    document.getElementById('product-name').textContent = p.DESCRICAO;
    document.getElementById('product-code').textContent = p.CODIGO;

    const total = saldos.reduce((acc, s) => acc + (parseFloat(s.SALDO_ATUAL) || 0), 0);
    const disp = saldos.reduce((acc, s) => acc + (parseFloat(s.DISPONIVEL) || 0), 0);

    document.getElementById('summary-total').textContent = formatarNumero(total);
    document.getElementById('summary-available').textContent = formatarNumero(disp);

    renderizarSaldos(saldos);
    renderizarLotes(dadosAtuais.dados.lotes || []);
    renderizarEmpenhos(dadosAtuais.dados.empenhos || []);
    renderizarMovimentacoes(dadosAtuais.dados.movimentacoes || []);
}

// Renderizacao das tabelas
function renderizarSaldos(saldos) { /* ... */ }
function renderizarLotes(lotes) { /* ... */ }
function renderizarEmpenhos(empenhos) { /* ... */ }
function renderizarMovimentacoes(mov) { /* ... */ }

// MATR260 - Relatorio de Saldo Analitico
async function abrirMatr260() {
    const response = await fetch(
        `${API_BASE}/api/matr260?armazem=${armazemSelecionado}`
    );
    const data = await response.json();
    // Exibe modal com tabela analitica
}

// Exportar Excel
function exportarExcel() {
    window.location.href =
        `${API_BASE}/api/matr260/excel?armazem=${armazemSelecionado}`;
}

// Inicializacao
document.getElementById('search-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const input = document.getElementById('codigo');
    await buscarProduto(input.value);
});
/* Variaveis e Design System */
:root {
    --primary: #2563eb;
    --primary-dark: #1d4ed8;
    --success: #16a34a;
    --warning: #ca8a04;
    --danger: #dc2626;
    --bg-primary: #f8fafc;
    --bg-secondary: #ffffff;
    --text-primary: #0f172a;
    --text-secondary: #64748b;
    --border: #e2e8f0;
    --radius: 8px;
    --font-sans: 'Inter', sans-serif;
    --font-mono: 'JetBrains Mono', monospace;
}

/* Layout */
.app {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

/* Header */
.header {
    background: var(--bg-secondary);
    border-bottom: 1px solid var(--border);
    padding: 12px 24px;
    display: flex;
    align-items: center;
    gap: 24px;
    position: sticky;
    top: 0;
    z-index: 100;
}

/* Search Form */
.search-form {
    flex: 1;
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.warehouse-filter {
    display: flex;
    gap: 4px;
    flex-wrap: wrap;
}

.filter-btn {
    padding: 4px 12px;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: transparent;
    font-size: 0.8rem;
    cursor: pointer;
    transition: all 0.15s;
}

.filter-btn.active {
    background: var(--primary);
    color: white;
    border-color: var(--primary);
}

/* Cards de Sumario */
.stock-summary {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 12px;
    padding: 20px 24px;
}

.summary-card {
    background: var(--bg-secondary);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 16px;
    text-align: center;
}

.summary-card strong {
    font-size: 1.5rem;
    font-family: var(--font-mono);
    color: var(--primary);
    display: block;
    margin-top: 4px;
}

/* Tabs */
.tabs {
    display: flex;
    gap: 2px;
    padding: 0 24px;
    border-bottom: 1px solid var(--border);
}

.tab {
    padding: 10px 20px;
    border: none;
    background: transparent;
    cursor: pointer;
    font-size: 0.85rem;
    color: var(--text-secondary);
    border-bottom: 2px solid transparent;
}

.tab.active {
    color: var(--primary);
    border-bottom-color: var(--primary);
}

/* Tabelas */
.data-table {
    width: 100%;
    border-collapse: collapse;
}

.data-table th {
    background: var(--bg-tertiary);
    padding: 10px 12px;
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-secondary);
}

.data-table td {
    padding: 10px 12px;
    font-size: 0.85rem;
    border-bottom: 1px solid var(--border);
}