Aplicação Prática: Construindo um Sistema de Análise de Vendas

EXERCÍCIO GUIADO

Vamos construir um sistema realista de processamento de vendas que integra todos os conceitos apresentados: estrutura de projetos, testes unitários, entrada/saída via console, e processamento de dados com LINQ.

Cenário de Negócio

Uma loja de varejo precisa processar arquivos de vendas diárias e gerar relatórios gerenciais. Cada arquivo contém linhas no formato:

PRODUTO;QUANTIDADE;PRECO_UNITARIO;DATA

O sistema deve:

  1. Ler o arquivo de vendas
  2. Calcular métricas: total vendido, ticket médio, produto mais vendido
  3. Gerar relatório em formato texto
  4. Suportar múltiplos formatos de entrada (extensibilidade)

Passo 1: Estrutura da Solução

Primeiro, crie a estrutura de projetos:

mkdir VendasAnalytics
cd VendasAnalytics

# Criar solução
dotnet new sln -n VendasAnalytics

# Projeto principal (Console App)
dotnet new console -n VendasAnalytics.Cli
dotnet sln add VendasAnalytics.Cli/VendasAnalytics.Cli.csproj

# Biblioteca de domínio (Class Library)
dotnet new classlib -n VendasAnalytics.Core
dotnet sln add VendasAnalytics.Core/VendasAnalytics.Core.csproj

# Projeto de testes
dotnet new mstest -n VendasAnalytics.Tests
dotnet sln add VendasAnalytics.Tests/VendasAnalytics.Tests.csproj

# Adicionar referências
dotnet add VendasAnalytics.Cli reference VendasAnalytics.Core
dotnet add VendasAnalytics.Tests reference VendasAnalytics.Core

Passo 2: Implementar o Domínio (Core)

Arquivo: VendasAnalytics.Core/Models/Venda.cs

namespace VendasAnalytics.Core.Models;

/// <summary>
/// Representa uma única transação de venda.
/// Usamos record para imutabilidade e comparação por valor.
/// </summary>
public record Venda
{
    public string Produto { get; init; }
    public int Quantidade { get; init; }
    public decimal PrecoUnitario { get; init; }
    public DateOnly Data { get; init; }

    public decimal ValorTotal => Quantidade * PrecoUnitario;

    // Validação no construtor garante que nunca teremos objetos inválidos
    public Venda(string produto, int quantidade, decimal precoUnitario, DateOnly data)
    {
        if (string.IsNullOrWhiteSpace(produto))
            throw new ArgumentException("Produto não pode ser vazio", nameof(produto));

        if (quantidade <= 0)
            throw new ArgumentException("Quantidade deve ser positiva", nameof(quantidade));

        if (precoUnitario <= 0)
            throw new ArgumentException("Preço unitário deve ser positivo", nameof(precoUnitario));

        Produto = produto.Trim();
        Quantidade = quantidade;
        PrecoUnitario = precoUnitario;
        Data = data;
    }
}

Arquivo: VendasAnalytics.Core/Models/RelatorioVendas.cs

namespace VendasAnalytics.Core.Models;

/// <summary>
/// Resultado da análise de vendas - imutável para thread-safety
/// </summary>
public record RelatorioVendas
{
    public int TotalTransacoes { get; init; }
    public int TotalItensVendidos { get; init; }
    public decimal ReceitaTotal { get; init; }
    public decimal TicketMedio { get; init; }
    public string ProdutoMaisVendido { get; init; } = string.Empty;
    public int QuantidadeProdutoMaisVendido { get; init; }
    public DateOnly DataInicio { get; init; }
    public DateOnly DataFim { get; init; }

    // Método de fábrica para criar relatório vazio (evita null)
    public static RelatorioVendas Vazio(DateOnly data) =>
        new()
        {
            DataInicio = data,
            DataFim = data
        };
}

Arquivo: VendasAnalytics.Core/AnalisadorVendas.cs

using VendasAnalytics.Core.Models;

namespace VendasAnalytics.Core;

/// <summary>
/// Serviço principal de análise de vendas.
/// Seguindo Single Responsibility Principle.
/// </summary>
public class AnalisadorVendas
{
    /// <summary>
    /// Analisa uma coleção de vendas e produz relatório consolidado.
    /// </summary>
    public RelatorioVendas Analisar(IEnumerable<Venda> vendas)
    {
        var vendasArray = vendas.ToArray();

        if (vendasArray.Length == 0)
            return RelatorioVendas.Vazio(DateOnly.FromDateTime(DateTime.Today));

        // Agrupamento por produto para identificar o mais vendido
        var vendasPorProduto = vendasArray
            .GroupBy(v => v.Produto)
            .Select(g => new
            {
                Produto = g.Key,
                QuantidadeTotal = g.Sum(v => v.Quantidade)
            })
            .OrderByDescending(x => x.QuantidadeTotal)
            .First();

        // Cálculos agregados
        var receitaTotal = vendasArray.Sum(v => v.ValorTotal);
        var totalItens = vendasArray.Sum(v => v.Quantidade);

        return new RelatorioVendas
        {
            TotalTransacoes = vendasArray.Length,
            TotalItensVendidos = totalItens,
            ReceitaTotal = receitaTotal,
            TicketMedio = totalItens > 0 ? receitaTotal / totalItens : 0,
            ProdutoMaisVendido = vendasPorProduto.Produto,
            QuantidadeProdutoMaisVendido = vendasPorProduto.QuantidadeTotal,
            DataInicio = vendasArray.Min(v => v.Data),
            DataFim = vendasArray.Max(v => v.Data)
        };
    }
}

Arquivo: VendasAnalytics.Core/Parsers/IVendaParser.cs

using VendasAnalytics.Core.Models;

namespace VendasAnalytics.Core.Parsers;

/// <summary>
/// Interface para parsers de diferentes formatos.
/// Permite extensibilidade sem modificar código existente (OCP).
/// </summary>
public interface IVendaParser
{
    string NomeFormato { get; }
    bool PodeProcessar(string linha);
    Venda Parse(string linha, int numeroLinha);
}

Arquivo: VendasAnalytics.Core/Parsers/VendaParserCsv.cs

using System.Globalization;
using VendasAnalytics.Core.Models;

namespace VendasAnalytics.Core.Parsers;

/// <summary>
/// Parser para formato CSV com separador ponto-e-vírgula.
/// </summary>
public class VendaParserCsv : IVendaParser
{
    public string NomeFormato => "CSV (separador ;)";

    public bool PodeProcessar(string linha)
    {
        // Evita processar linhas vazias ou comentários
        if (string.IsNullOrWhiteSpace(linha) || linha.StartsWith('#'))
            return false;

        var partes = linha.Split(';');
        return partes.Length == 4;
    }

    public Venda Parse(string linha, int numeroLinha)
    {
        var partes = linha.Split(';');

        try
        {
            var produto = partes[0].Trim();

            if (!int.TryParse(partes[1], out var quantidade))
                throw new FormatException($"Quantidade inválida na linha {numeroLinha}");

            if (!decimal.TryParse(partes[2], NumberStyles.Any, CultureInfo.InvariantCulture, out var preco))
                throw new FormatException($"Preço inválido na linha {numeroLinha}");

            if (!DateOnly.TryParseExact(partes[3].Trim(), "yyyy-MM-dd", out var data))
                throw new FormatException($"Data inválida na linha {numeroLinha}. Use formato yyyy-MM-dd");

            return new Venda(produto, quantidade, preco, data);
        }
        catch (ArgumentException ex)
        {
            throw new FormatException($"Erro na linha {numeroLinha}: {ex.Message}", ex);
        }
    }
}

Arquivo: VendasAnalytics.Core/LeitorVendas.cs

using VendasAnalytics.Core.Models;
using VendasAnalytics.Core.Parsers;

namespace VendasAnalytics.Core;

/// <summary>
/// Responsável por ler e interpretar arquivos de vendas.
/// </summary>
public class LeitorVendas
{
    private readonly List<IVendaParser> _parsers;

    public LeitorVendas(IEnumerable<IVendaParser> parsers)
    {
        _parsers = parsers.ToList();
    }

    /// <summary>
    /// Lê vendas de um arquivo usando o parser apropriado.
    /// </summary>
    public async Task<IReadOnlyList<Venda>> LerArquivoAsync(string caminhoArquivo)
    {
        var linhas = await File.ReadAllLinesAsync(caminhoArquivo);
        var vendas = new List<Venda>();
        var erros = new List<string>();

        // Detecta o parser adequado baseado na primeira linha válida
        var primeiraLinhaValida = linhas.FirstOrDefault(l =>
            !string.IsNullOrWhiteSpace(l) && !l.StartsWith('#'));

        var parser = _parsers.FirstOrDefault(p => p.PodeProcessar(primeiraLinhaValida))
            ?? throw new InvalidOperationException(
                $"Nenhum parser disponível para o formato do arquivo: {caminhoArquivo}");

        Console.WriteLine($"Usando parser: {parser.NomeFormato}");

        for (int i = 0; i < linhas.Length; i++)
        {
            var linha = linhas[i];
            var numeroLinha = i + 1;

            if (!parser.PodeProcessar(linha))
                continue;

            try
            {
                var venda = parser.Parse(linha, numeroLinha);
                vendas.Add(venda);
            }
            catch (FormatException ex)
            {
                erros.Add(ex.Message);
            }
        }

        if (erros.Any())
        {
            Console.WriteLine($"Aviso: {erros.Count} linha(s) com erro foram ignoradas:");
            foreach (var erro in erros.Take(5))
                Console.WriteLine($"  - {erro}");

            if (erros.Count > 5)
                Console.WriteLine($"  ... e mais {erros.Count - 5} erros");
        }

        return vendas;
    }
}

Passo 3: Implementar a Interface do Usuário (CLI)

Arquivo: VendasAnalytics.Cli/Program.cs

using System.Text;
using VendasAnalytics.Core;
using VendasAnalytics.Core.Parsers;

// Configuração dos parsers disponíveis
var parsers = new IVendaParser[]
{
    new VendaParserCsv()
    // Futuramente: new VendaParserJson(), new VendaParserExcel(), etc.
};

var leitor = new LeitorVendas(parsers);
var analisador = new AnalisadorVendas();

Console.OutputEncoding = Encoding.UTF8;
Console.WriteLine("📊 Sistema de Análise de Vendas");
Console.WriteLine("================================");

// Verifica argumentos
if (args.Length == 0)
{
    Console.WriteLine("Uso: VendasAnalytics.Cli <arquivo-vendas.csv>");
    Console.WriteLine();
    Console.WriteLine("Formato esperado do arquivo:");
    Console.WriteLine("  PRODUTO;QUANTIDADE;PRECO_UNITARIO;DATA");
    Console.WriteLine("  Exemplo: Notebook;2;3500.50;2024-01-15");
    Console.WriteLine();
    Console.WriteLine("Linhas iniciadas com # são ignoradas (comentários)");
    return 1;
}

var caminhoArquivo = args[0];

if (!File.Exists(caminhoArquivo))
{
    Console.WriteLine($"❌ Arquivo não encontrado: {caminhoArquivo}");
    return 1;
}

try
{
    Console.WriteLine($"📂 Lendo arquivo: {Path.GetFileName(caminhoArquivo)}");

    var vendas = await leitor.LerArquivoAsync(caminhoArquivo);

    if (!vendas.Any())
    {
        Console.WriteLine("⚠️ Nenhuma venda válida encontrada no arquivo.");
        return 0;
    }

    Console.WriteLine($"✅ {vendas.Count} vendas processadas com sucesso.");
    Console.WriteLine();

    var relatorio = analisador.Analisar(vendas);

    // Exibir relatório formatado
    Console.WriteLine("📈 RELATÓRIO DE VENDAS");
    Console.WriteLine("======================");
    Console.WriteLine($"Período: {relatorio.DataInicio:dd/MM/yyyy} a {relatorio.DataFim:dd/MM/yyyy}");
    Console.WriteLine();
    Console.WriteLine($"Total de Transações: {relatorio.TotalTransacoes:N0}");
    Console.WriteLine($"Total de Itens Vendidos: {relatorio.TotalItensVendidos:N0}");
    Console.WriteLine($"Receita Total: R$ {relatorio.ReceitaTotal:N2}");
    Console.WriteLine($"Ticket Médio: R$ {relatorio.TicketMedio:N2}");
    Console.WriteLine();
    Console.WriteLine("🏆 PRODUTO DESTAQUE");
    Console.WriteLine($"Produto: {relatorio.ProdutoMaisVendido}");
    Console.WriteLine($"Quantidade Vendida: {relatorio.QuantidadeProdutoMaisVendido:N0} unidades");

    // Gerar arquivo de relatório
    var nomeRelatorio = Path.ChangeExtension(caminhoArquivo, ".relatorio.txt");
    await GerarArquivoRelatorioAsync(nomeRelatorio, relatorio, vendas);
    Console.WriteLine();
    Console.WriteLine($"📄 Relatório salvo em: {nomeRelatorio}");

    return 0;
}
catch (Exception ex)
{
    Console.WriteLine($"❌ Erro ao processar arquivo: {ex.Message}");
    return 1;
}

static async Task GerarArquivoRelatorioAsync(string caminho, RelatorioVendas relatorio, IReadOnlyList<Venda> vendas)
{
    var sb = new StringBuilder();
    sb.AppendLine("RELATÓRIO DE VENDAS - ANALYTICS");
    sb.AppendLine("================================");
    sb.AppendLine($"Gerado em: {DateTime.Now:dd/MM/yyyy HH:mm:ss}");
    sb.AppendLine($"Período analisado: {relatorio.DataInicio:dd/MM/yyyy} a {relatorio.DataFim:dd/MM/yyyy}");
    sb.AppendLine();
    sb.AppendLine("RESUMO GERAL");
    sb.AppendLine("-------------");
    sb.AppendLine($"Transações: {relatorio.TotalTransacoes}");
    sb.AppendLine($"Itens vendidos: {relatorio.TotalItensVendidos}");
    sb.AppendLine($"Receita total: R$ {relatorio.ReceitaTotal:N2}");
    sb.AppendLine($"Ticket médio: R$ {relatorio.TicketMedio:N2}");
    sb.AppendLine();
    sb.AppendLine("PRODUTO MAIS VENDIDO");
    sb.AppendLine("--------------------");
    sb.AppendLine($"Produto: {relatorio.ProdutoMaisVendido}");
    sb.AppendLine($"Quantidade: {relatorio.QuantidadeProdutoMaisVendido}");
    sb.AppendLine();
    sb.AppendLine("TOP 5 PRODUTOS POR QUANTIDADE");
    sb.AppendLine("-----------------------------");

    var topProdutos = vendas
        .GroupBy(v => v.Produto)
        .Select(g => new { Produto = g.Key, Quantidade = g.Sum(v => v.Quantidade) })
        .OrderByDescending(x => x.Quantidade)
        .Take(5);

    int posicao = 1;
    foreach (var p in topProdutos)
    {
        sb.AppendLine($"{posicao}. {p.Produto}: {p.Quantidade} unidades");
        posicao++;
    }

    await File.WriteAllTextAsync(caminho, sb.ToString());
}

Passo 4: Criar Arquivo de Testes

Arquivo: VendasAnalytics.Tests/AnalisadorVendasTests.cs

using VendasAnalytics.Core;
using VendasAnalytics.Core.Models;

namespace VendasAnalytics.Tests;

[TestClass]
public class AnalisadorVendasTests
{
    private AnalisadorVendas _analisador = null!;

    [TestInitialize]
    public void Setup()
    {
        _analisador = new AnalisadorVendas();
    }

    [TestMethod]
    public void Analisar_ComVendasValidas_CalculaMetricasCorretamente()
    {
        // Arrange
        var vendas = new[]
        {
            new Venda("Notebook", 2, 3500m, new DateOnly(2024, 1, 15)),
            new Venda("Mouse", 5, 50m, new DateOnly(2024, 1, 15)),
            new Venda("Notebook", 1, 3500m, new DateOnly(2024, 1, 16)),
            new Venda("Teclado", 3, 150m, new DateOnly(2024, 1, 16))
        };

        // Act
        var relatorio = _analisador.Analisar(vendas);

        // Assert
        Assert.AreEqual(4, relatorio.TotalTransacoes);
        Assert.AreEqual(11, relatorio.TotalItensVendidos); // 2+5+1+3

        var receitaEsperada = (2 * 3500) + (5 * 50) + (1 * 3500) + (3 * 150);
        Assert.AreEqual(receitaEsperada, relatorio.ReceitaTotal);

        // Notebook: 3 unidades, Mouse: 5, Teclado: 3
        Assert.AreEqual("Mouse", relatorio.ProdutoMaisVendido);
        Assert.AreEqual(5, relatorio.QuantidadeProdutoMaisVendido);

        Assert.AreEqual(new DateOnly(2024, 1, 15), relatorio.DataInicio);
        Assert.AreEqual(new DateOnly(2024, 1, 16), relatorio.DataFim);
    }

    [TestMethod]
    public void Analisar_ComListaVazia_RetornaRelatorioVazio()
    {
        // Arrange
        var vendas = Array.Empty<Venda>();

        // Act
        var relatorio = _analisador.Analisar(vendas);

        // Assert
        Assert.AreEqual(0, relatorio.TotalTransacoes);
        Assert.AreEqual(0, relatorio.TotalItensVendidos);
        Assert.AreEqual(0m, relatorio.ReceitaTotal);
        Assert.AreEqual(0m, relatorio.TicketMedio);
        Assert.AreEqual(string.Empty, relatorio.ProdutoMaisVendido);
    }

    [TestMethod]
    public void Analisar_ComUmaVenda_CalculaMetricasCorretamente()
    {
        // Arrange
        var vendas = new[]
        {
            new Venda("Monitor", 1, 1200m, new DateOnly(2024, 1, 20))
        };

        // Act
        var relatorio = _analisador.Analisar(vendas);

        // Assert
        Assert.AreEqual(1, relatorio.TotalTransacoes);
        Assert.AreEqual(1, relatorio.TotalItensVendidos);
        Assert.AreEqual(1200m, relatorio.ReceitaTotal);
        Assert.AreEqual(1200m, relatorio.TicketMedio);
        Assert.AreEqual("Monitor", relatorio.ProdutoMaisVendido);
        Assert.AreEqual(1, relatorio.QuantidadeProdutoMaisVendido);
    }
}

Arquivo: VendasAnalytics.Tests/VendaParserCsvTests.cs

using VendasAnalytics.Core.Parsers;

namespace VendasAnalytics.Tests;

[TestClass]
public class VendaParserCsvTests
{
    private VendaParserCsv _parser = null!;

    [TestInitialize]
    public void Setup()
    {
        _parser = new VendaParserCsv();
    }

    [TestMethod]
    public void PodeProcessar_LinhaValida_RetornaTrue()
    {
        var linha = "Notebook;2;3500.50;2024-01-15";
        Assert.IsTrue(_parser.PodeProcessar(linha));
    }

    [TestMethod]
    public void PodeProcessar_LinhaComComentario_RetornaFalse()
    {
        var linha = "# Este é um comentário";
        Assert.IsFalse(_parser.PodeProcessar(linha));
    }

    [TestMethod]
    public void PodeProcessar_LinhaVazia_RetornaFalse()
    {
        Assert.IsFalse(_parser.PodeProcessar(""));
        Assert.IsFalse(_parser.PodeProcessar("   "));
    }

    [TestMethod]
    public void Parse_LinhaValida_RetornaVendaCorreta()
    {
        // Arrange
        var linha = "Teclado Mecânico;3;299.90;2024-01-20";

        // Act
        var venda = _parser.Parse(linha, 1);

        // Assert
        Assert.AreEqual("Teclado Mecânico", venda.Produto);
        Assert.AreEqual(3, venda.Quantidade);
        Assert.AreEqual(299.90m, venda.PrecoUnitario);
        Assert.AreEqual(new DateOnly(2024, 1, 20), venda.Data);
        Assert.AreEqual(899.70m, venda.ValorTotal);
    }

    [TestMethod]
    [ExpectedException(typeof(FormatException))]
    public void Parse_QuantidadeInvalida_LancaExcecao()
    {
        var linha = "Produto;ABC;10.50;2024-01-20";
        _parser.Parse(linha, 1);
    }

    [TestMethod]
    [ExpectedException(typeof(FormatException))]
    public void Parse_DataInvalida_LancaExcecao()
    {
        var linha = "Produto;5;10.50;20/01/2024";
        _parser.Parse(linha, 1);
    }
}

Passo 5: Criar Dados de Exemplo e Testar

Arquivo: dados-exemplo/vendas-janeiro-2024.csv

# Arquivo de vendas - Janeiro 2024
# Formato: PRODUTO;QUANTIDADE;PRECO_UNITARIO;DATA
Notebook Dell XPS;2;4500.00;2024-01-10
Mouse Logitech;5;89.90;2024-01-10
Teclado Mecânico;3;299.99;2024-01-11
Monitor 27";1;1899.00;2024-01-11
Notebook Dell XPS;1;4500.00;2024-01-12
Mouse Logitech;8;89.90;2024-01-12
Webcam Full HD;4;199.50;2024-01-13
Hub USB-C;6;129.90;2024-01-13
Notebook Dell XPS;3;4500.00;2024-01-14
Mouse Logitech;2;89.90;2024-01-14

Passo 6: Executar e Verificar

# Build do projeto
dotnet build

# Executar testes
dotnet test

# Executar aplicação com dados de exemplo
dotnet run --project VendasAnalytics.Cli -- dados-exemplo/vendas-janeiro-2024.csv

Saída esperada:

📊 Sistema de Análise de Vendas
================================
📂 Lendo arquivo: vendas-janeiro-2024.csv
Usando parser: CSV (separador ;)
✅ 10 vendas processadas com sucesso.

📈 RELATÓRIO DE VENDAS
======================
Período: 10/01/2024 a 14/01/2024

Total de Transações: 10
Total de Itens Vendidos: 35
Receita Total: R$ 21.938,10
Ticket Médio: R$ 626,80

🏆 PRODUTO DESTAQUE
Produto: Mouse Logitech
Quantidade Vendida: 15 unidades

📄 Relatório salvo em: dados-exemplo/vendas-janeiro-2024.relatorio.txt

Desafios para Fixação

Para consolidar o aprendizado, implemente as seguintes extensões:

Desafio 1: Suporte a JSON Crie um novo parser VendaParserJson que processe arquivos no formato:

{ "produto": "Notebook", "quantidade": 2, "preco": 3500, "data": "2024-01-15" }

Desafio 2: Filtros por Data Adicione parâmetros na CLI para filtrar vendas por período:

dotnet run -- --arquivo vendas.csv --inicio 2024-01-01 --fim 2024-01-31

Desafio 3: Múltiplos Arquivos Modifique o programa para aceitar múltiplos arquivos e consolidar o relatório:

dotnet run -- vendas-jan.csv vendas-fev.csv vendas-mar.csv

Desafio 4: Exportação em Diferentes Formatos Implemente uma interface IRelatorioExporter com implementações para:

Desafio 5: Performance para Grandes Volumes Crie um benchmark comparando:

Meça tempo de execução e uso de memória para arquivos com 1 milhão de linhas.

Verificação de Aprendizado

Após completar o exercício, você deve ser capaz de responder:

  1. Por que usamos record em vez de class para Venda e RelatorioVendas?
    • Imutabilidade por padrão
    • Comparação por valor (útil em testes)
    • Sintaxe concisa
  2. Qual a vantagem da interface IVendaParser?
    • Open/Closed Principle: novos formatos sem modificar código existente
    • Testabilidade: cada parser pode ser testado isoladamente
    • Configuração flexível via DI
  3. Como o LeitorVendas seleciona o parser correto?
    • Examina a primeira linha não-vazia/não-comentário
    • Delega a decisão para cada parser via PodeProcessar()
    • Fallback para exceção se nenhum parser aceitar o formato
  4. Por que separamos o projeto em Core, Cli e Tests?
    • Core: lógica de negócio pura, reutilizável em outros contextos (web, desktop)
    • Cli: apenas interface com usuário, dependente do Core
    • Tests: verifica comportamento sem dependências de UI
  5. O que acontece se uma linha do CSV estiver mal formatada?
    • O parser lança FormatException com detalhes da linha
    • LeitorVendas captura e acumula erros
    • Linhas inválidas são ignoradas com aviso, não interrompem o processamento