Projeto Final Parte 2: Implementação

Projeto Final Parte 2: Implementação

Sem duas constantes de timestamp, nenhum model deste projeto grava uma linha; sem uma trava no checkout, duas compras do último item fecham o estoque em -1. Este é o artigo da implementação: models, serviço de pedido, controllers, job de fila e rotas, do domínio para o HTTP.
PHP

• • 54 min de leitura

Com o planejamento sólido do artigo Projeto Final Parte 1: Planejamento e Arquitetura, agora implementamos o sistema camada por camada. A ordem importa: domínio primeiro, infraestrutura depois, HTTP por último. Isso garante que a lógica central nunca depende de detalhes de implementação.

Models Eloquent

<?php
declare(strict_types=1);

namespace App\Models;

use App\Domain\Pedido\StatusDoPedido;
use App\Domain\Produto\Dinheiro;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;

class Produto extends Model
{
    use HasFactory, SoftDeletes;

    // O schema da Parte 1 nomeia as colunas de tempo em português.
    // Sem estas duas constantes o Eloquent monta TODO insert com
    // `created_at` e `updated_at`, e o banco recusa a gravação:
    // "column updated_at does not exist".
    const CREATED_AT = 'criado_em';
    const UPDATED_AT = 'atualizado_em';

    protected $fillable = [
        'categoria_id', 'nome', 'slug', 'descricao',
        'preco', 'preco_promocional', 'estoque', 'ativo', 'atributos', 'imagens',
    ];

    protected $casts = [
        'preco'             => 'decimal:2',
        'preco_promocional' => 'decimal:2',
        'estoque'           => 'integer',
        'ativo'             => 'boolean',
        'atributos'         => 'array',
        'imagens'           => 'array',
    ];

    protected static function booted(): void
    {
        static::creating(fn(self $p) => $p->slug ??= Str::slug($p->nome));
        // o carimbo de `atualizado_em` é do Eloquent, via UPDATED_AT
    }

    public function precoAtual(): Dinheiro
    {
        $valor = $this->preco_promocional ?? $this->preco;
        return Dinheiro::dereais((float) $valor);
    }

    public function temPromocao(): bool
    {
        return $this->preco_promocional !== null
            && (float) $this->preco_promocional < (float) $this->preco;
    }

    public function estaDisponivel(): bool
    {
        return $this->ativo && $this->estoque > 0 && $this->deleted_at === null;
    }

    public function scopeDisponivel($query)
    {
        return $query->where('ativo', true)->where('estoque', '>', 0);
    }

    public function scopeDaCategoria($query, int $categoriaId)
    {
        return $query->where('categoria_id', $categoriaId);
    }

    public function categoria(): BelongsTo
    {
        return $this->belongsTo(Categoria::class);
    }

    public function itensDePedido(): HasMany
    {
        return $this->hasMany(PedidoItem::class);
    }
}

class Pedido extends Model
{
    const CREATED_AT = 'criado_em';
    const UPDATED_AT = 'atualizado_em';

    protected $fillable = [
        'usuario_id', 'endereco_id', 'status',
        'subtotal', 'desconto', 'frete', 'total', 'observacoes',
    ];

    protected $casts = [
        'status'    => StatusDoPedido::class, // cast para Enum nativo
        'subtotal'  => 'decimal:2',
        'desconto'  => 'decimal:2',
        'frete'     => 'decimal:2',
        'total'     => 'decimal:2',
    ];

    public function podeSerCancelado(): bool
    {
        return $this->status->podeTransicionarPara(StatusDoPedido::Cancelado);
    }

    public function transicionarPara(StatusDoPedido $novoStatus): void
    {
        if (!$this->status->podeTransicionarPara($novoStatus)) {
            throw new \DomainException(
                "Não é possível ir de {$this->status->label()} para {$novoStatus->label()}."
            );
        }

        // `atualizado_em` sai daqui: fora do $fillable, o Eloquent
        // descarta o valor em silêncio — a linha parecia carimbar a
        // data e não carimbava nada. Com UPDATED_AT declarado, o
        // carimbo passa a sair sozinho.
        $this->update(['status' => $novoStatus]);
    }

    public function usuario(): BelongsTo { return $this->belongsTo(User::class); }
    public function endereco(): BelongsTo { return $this->belongsTo(Endereco::class); }
    public function itens(): HasMany     { return $this->hasMany(PedidoItem::class); }
}

class PedidoItem extends Model
{
    // A tabela `pedido_itens` da Parte 1 não tem coluna de tempo
    // nenhuma. Sem esta linha o Eloquent acrescenta created_at e
    // updated_at ao insert, e o item de pedido nunca é gravado.
    public $timestamps = false;

    protected $fillable = [
        'pedido_id', 'produto_id', 'nome_produto',
        'preco_unitario', 'quantidade', 'subtotal',
    ];

    protected $casts = [
        'preco_unitario' => 'decimal:2',
        'quantidade'     => 'integer',
        'subtotal'       => 'decimal:2',
    ];

    public function produto(): BelongsTo { return $this->belongsTo(Produto::class); }
}

PedidoService — orquestrando o checkout

<?php
declare(strict_types=1);

namespace App\Services;

use App\Domain\Carrinho\Carrinho;
use App\Domain\Pedido\StatusDoPedido;
use App\Domain\Produto\Dinheiro;
use App\Jobs\ProcessarPedido;
use App\Models\Endereco;
use App\Models\Pedido;
use App\Models\PedidoItem;
use App\Models\Produto;
use App\Models\User;
use Illuminate\Support\Facades\DB;

final class PedidoService
{
    private const FRETE_GRATIS_ACIMA = 20000; // R$ 200,00 em centavos
    private const FRETE_PADRAO       = 1990;  // R$ 19,90 em centavos

    public function criarDoCarrinho(
        Carrinho $carrinho,
        User     $usuario,
        Endereco $endereco,
        ?string  $observacoes = null,
    ): Pedido {
        if ($carrinho->estaVazio()) {
            throw new \DomainException('O carrinho está vazio.');
        }

        // Esta primeira passada serve para falhar cedo e com mensagem boa.
        // Ela NÃO é a garantia: entre esta leitura e o decremento,
        // outra compra pode levar a última unidade. Quem garante é a
        // trava dentro da transação, logo adiante.
        foreach ($carrinho->itens() as $item) {
            $produto = Produto::findOrFail($item->produtoId);

            if (!$produto->estaDisponivel()) {
                throw new \DomainException(
                    "O produto \"{$produto->nome}\" não está mais disponível."
                );
            }

            if ($produto->estoque < $item->quantidade()) {
                throw new \DomainException(
                    "Estoque insuficiente para \"{$produto->nome}\": "
                    . "disponível {$produto->estoque}, solicitado {$item->quantidade()}."
                );
            }
        }

        return DB::transaction(function () use ($carrinho, $usuario, $endereco, $observacoes) {
            $subtotal = $carrinho->total();
            $frete    = $subtotal->centavos() >= self::FRETE_GRATIS_ACIMA
                ? new Dinheiro(0)
                : new Dinheiro(self::FRETE_PADRAO);
            $total    = $subtotal->somar($frete);

            // Cria o pedido
            $pedido = Pedido::create([
                'usuario_id'  => $usuario->id,
                'endereco_id' => $endereco->id,
                'status'      => StatusDoPedido::Pendente,
                'subtotal'    => $subtotal->reais(),
                'frete'       => $frete->reais(),
                'desconto'    => 0,
                'total'       => $total->reais(),
                'observacoes' => $observacoes,
            ]);

            // Trava as linhas dos produtos até o commit. Sem isso, duas
            // compras simultâneas do último item leem "estoque = 1",
            // ambas passam na validação e o estoque termina negativo.
            $produtos = Produto::whereIn(
                'id',
                array_map(fn($i) => $i->produtoId, $carrinho->itens()),
            )->lockForUpdate()->get()->keyBy('id');

            // Cria os itens e decrementa o estoque
            foreach ($carrinho->itens() as $item) {
                $produto = $produtos[$item->produtoId];

                PedidoItem::create([
                    'pedido_id'      => $pedido->id,
                    'produto_id'     => $item->produtoId,
                    'nome_produto'   => $item->nomeProduto,      // snapshot
                    'preco_unitario' => $item->precoUnitario->reais(),
                    'quantidade'     => $item->quantidade(),
                    'subtotal'       => $item->subtotal()->reais(),
                ]);

                // A condição no WHERE é a garantia final. Um
                // `decrement` solto grava estoque negativo quando duas
                // compras passam pela validação ao mesmo tempo —
                // medido: as duas gravam, e o estoque fecha em -1.
                $afetadas = Produto::whereKey($item->produtoId)
                    ->where('estoque', '>=', $item->quantidade())
                    ->decrement('estoque', $item->quantidade());

                if ($afetadas === 0) {
                    throw new \DomainException(
                        "Estoque insuficiente para \"{$produto->nome}\"."
                    );
                }
            }

            // afterCommit não é detalhe: sem ele o worker pode pegar o
            // job antes do COMMIT, e o findOrFail lá dentro não acha o
            // pedido que esta transação ainda não gravou.
            ProcessarPedido::dispatch($pedido->id)->afterCommit();

            return $pedido;
        });
    }

    public function cancelar(Pedido $pedido, User $usuario): void
    {
        if ($pedido->usuario_id !== $usuario->id && !$usuario->isAdmin()) {
            throw new \DomainException('Sem permissão para cancelar este pedido.');
        }

        DB::transaction(function () use ($pedido) {
            $pedido->transicionarPara(StatusDoPedido::Cancelado);

            // Devolve o estoque
            foreach ($pedido->itens as $item) {
                Produto::where('id', $item->produto_id)
                    ->increment('estoque', $item->quantidade);
            }
        });
    }
}

Controllers Web

<?php
declare(strict_types=1);

namespace App\Http\Controllers\Web;

use App\Http\Controllers\Controller;
use App\Models\Produto;
use App\Models\Categoria;
use App\Services\CarrinhoService;
use Illuminate\Http\Request;
use Illuminate\View\View;

class CatalogoController extends Controller
{
    public function index(Request $request): View
    {
        $query = Produto::disponivel()->with('categoria');

        if ($request->filled('categoria')) {
            $query->whereHas('categoria', fn($q) =>
                $q->where('slug', $request->categoria)
            );
        }

        if ($request->filled('busca')) {
            $termo = $request->busca;
            $query->where(fn($q) =>
                $q->where('nome', 'ilike', "%{$termo}%")
                  ->orWhere('descricao', 'ilike', "%{$termo}%")
            );
        }

        // Coluna e direção saem do MESMO mapa. Derivar a direção à
        // parte fazia 'nome' ordenar de Z para A, porque tudo que não
        // fosse preco_asc caía no 'desc'.
        [$coluna, $direcao] = match($request->get('ordenar', 'relevancia')) {
            'preco_asc'  => ['preco', 'asc'],
            'preco_desc' => ['preco', 'desc'],
            'nome'       => ['nome', 'asc'],
            default      => ['criado_em', 'desc'],
        };

        $query->orderBy($coluna, $direcao);

        return view('catalogo.index', [
            'produtos'   => $query->paginate(16)->withQueryString(),
            'categorias' => Categoria::where('ativa', true)->orderBy('nome')->get(),
        ]);
    }

    public function show(string $slug): View
    {
        $produto    = Produto::where('slug', $slug)->disponivel()->firstOrFail();
        $relacionados = Produto::disponivel()
            ->daCategoria($produto->categoria_id)
            ->where('id', '!=', $produto->id)
            ->limit(4)
            ->get();

        return view('catalogo.show', compact('produto', 'relacionados'));
    }
}

class CarrinhoController extends Controller
{
    public function __construct(private readonly CarrinhoService $carrinhoService) {}

    public function adicionar(Request $request): \Illuminate\Http\RedirectResponse
    {
        $request->validate([
            'produto_id' => ['required', 'integer', 'exists:produtos,id'],
            'quantidade' => ['required', 'integer', 'min:1', 'max:99'],
        ]);

        $produto = Produto::findOrFail($request->produto_id);

        if (!$produto->estaDisponivel()) {
            return back()->with('erro', 'Produto indisponível.');
        }

        $this->carrinhoService->adicionar(
            $produto,
            (int) $request->quantidade,
        );

        return back()->with('sucesso', "\"{$produto->nome}\" adicionado ao carrinho.");
    }

    public function index(): View
    {
        $carrinho = $this->carrinhoService->carrinho();
        return view('carrinho.index', compact('carrinho'));
    }

    public function atualizar(Request $request, int $produtoId): \Illuminate\Http\RedirectResponse
    {
        $request->validate(['quantidade' => ['required', 'integer', 'min:0', 'max:99']]);
        $this->carrinhoService->atualizar($produtoId, (int) $request->quantidade);
        return back()->with('sucesso', 'Carrinho atualizado.');
    }

    public function remover(int $produtoId): \Illuminate\Http\RedirectResponse
    {
        $this->carrinhoService->remover($produtoId);
        return back()->with('sucesso', 'Item removido do carrinho.');
    }
}

Job de processamento de pedido

<?php
declare(strict_types=1);

namespace App\Jobs;

use App\Models\Pedido;
use App\Domain\Pedido\StatusDoPedido;
use App\Mail\PedidoConfirmado;
use App\Mail\PedidoParaAdmin;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;

class ProcessarPedido implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    // 4 tentativas para os três intervalos de backoff() serem usados:
    // com $tries = 3 só há duas re-tentativas, e o último valor da
    // lista nunca entra em jogo.
    public int $tries   = 4;
    public int $timeout = 60;

    public function __construct(private readonly int $pedidoId) {}

    public function handle(): void
    {
        $pedido = Pedido::with(['usuario', 'itens', 'endereco'])->findOrFail($this->pedidoId);

        // Simula processamento de pagamento (em produção: gateway real)
        $pagamentoAprovado = $this->simularPagamento($pedido);

        if ($pagamentoAprovado) {
            // O job roda de novo sempre que o e-mail falha depois da
            // transição. Sem esta checagem, a 2ª tentativa pede
            // Confirmado -> Confirmado, que o enum recusa: o retry morre
            // em DomainException e nunca chega a reenviar o e-mail.
            if ($pedido->status !== StatusDoPedido::Confirmado) {
                $pedido->transicionarPara(StatusDoPedido::Confirmado);
            }

            // Envia email para o cliente
            Mail::to($pedido->usuario->email)
                ->send(new PedidoConfirmado($pedido));

            // Notifica o admin
            Mail::to(config('mercatto.email_admin'))
                ->send(new PedidoParaAdmin($pedido));

        } else {
            $pedido->transicionarPara(StatusDoPedido::Cancelado);

            // Devolve o estoque
            foreach ($pedido->itens as $item) {
                \App\Models\Produto::where('id', $item->produto_id)
                    ->increment('estoque', $item->quantidade);
            }
        }
    }

    private function simularPagamento(Pedido $pedido): bool
    {
        // Em produção: integração com Stripe, PagSeguro, Mercado Pago etc.
        return true;
    }

    public function backoff(): array { return [30, 60, 120]; }

    public function failed(\Throwable $e): void
    {
        \Log::error("Falha ao processar pedido {$this->pedidoId}", [
            'erro' => $e->getMessage(),
        ]);
    }
}

Rotas completas

<?php
// routes/web.php
use App\Http\Controllers\Web\{CatalogoController, CarrinhoController, CheckoutController, PedidoController};
use App\Http\Controllers\Auth\{LoginController, RegisterController};

// Públicas
Route::get('/', [CatalogoController::class, 'index'])->name('home');
Route::get('/produtos/{slug}', [CatalogoController::class, 'show'])->name('produtos.show');

// Autenticação
Route::middleware('guest')->group(function () {
    Route::get('/login',    [LoginController::class, 'create'])->name('login');
    Route::post('/login',   [LoginController::class, 'store']);
    Route::get('/register', [RegisterController::class, 'create'])->name('register');
    Route::post('/register',[RegisterController::class, 'store']);
});
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout')->middleware('auth');

// Carrinho — acesso público (guest também pode adicionar)
Route::prefix('carrinho')->name('carrinho.')->group(function () {
    Route::get('/',                           [CarrinhoController::class, 'index'])->name('index');
    Route::post('/adicionar',                 [CarrinhoController::class, 'adicionar'])->name('adicionar');
    Route::patch('/atualizar/{produtoId}',    [CarrinhoController::class, 'atualizar'])->name('atualizar');
    Route::delete('/remover/{produtoId}',     [CarrinhoController::class, 'remover'])->name('remover');
});

// Área autenticada
Route::middleware('auth')->group(function () {
    Route::get('/checkout',  [CheckoutController::class, 'index'])->name('checkout');
    Route::post('/checkout', [CheckoutController::class, 'store'])->name('checkout.store');
    Route::resource('pedidos', PedidoController::class)->only(['index', 'show']);
    Route::post('/pedidos/{pedido}/cancelar', [PedidoController::class, 'cancelar'])->name('pedidos.cancelar');
});
<?php
// routes/admin.php
Route::prefix('admin')->name('admin.')->middleware(['auth', 'role:admin'])->group(function () {
    Route::get('/', fn() => view('admin.dashboard'))->name('dashboard');
    Route::resource('produtos', Admin\ProdutoController::class);
    Route::resource('pedidos',  Admin\PedidoController::class)->only(['index', 'show', 'update']);
    Route::resource('categorias', Admin\CategoriaController::class);
    Route::get('/relatorios/vendas', [Admin\RelatorioController::class, 'vendas'])->name('relatorios.vendas');
});

Esta parte concentra um tipo específico de defeito, e nenhum deles se revela na leitura atenta do código. Os models pareciam corretos e não gravavam uma linha sequer, porque o schema da Parte 1 nomeia as colunas de tempo em português e o Eloquent insiste em created_at enquanto ninguém lhe disser o contrário. A linha que carimbava atualizado_em dentro do update() parecia zelo e era descartada em silêncio, por estar fora do $fillable. São erros que só existem no encontro do código com o banco, e é por isso que o primeiro teste que insere uma linha de verdade vale mais do que uma releitura: ele separa o model plausível do model que funciona.

O checkout ensina a outra metade. Validar o estoque antes de abrir a transação melhora a mensagem de erro e não protege coisa alguma: entre aquela leitura e o decremento cabe outra compra, e duas pessoas levando a última unidade fecham o estoque em -1 sem que nenhuma linha, isolada, pareça errada. Quem protege é a trava no banco e a condição no WHERE — a parte do sistema que não depende de quem chegou primeiro. O job segue a mesma lógica por outro caminho: ele vai rodar de novo, mais cedo ou mais tarde, e um handle() escrito como se o pedido estivesse sempre no estado inicial transforma cada re-tentativa numa falha nova, diferente da que a causou.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Implemente PedidoService::criarDoCarrinho() completo com todos os casos de erro — carrinho vazio, produto indisponível, estoque insuficiente. Escreva testes de feature que cobrem cada caso.

Ver resposta

✓ Resposta: Duas linhas decidem se o serviço aguenta produção. O lockForUpdate() mais o where('estoque', '>=', ...) no decremento: sem eles, duas compras simultâneas do último item leem "estoque = 1", ambas passam na validação e o estoque fica negativo. E o preço lido do banco, não do carrinho — o carrinho mora na sessão, território do cliente, e honrar o valor de lá é deixar o comprador definir o próprio desconto. O teste que não deixa resíduo é o que prova a transação: quando o segundo item falha, o estoque já decrementado do primeiro precisa voltar.

<?php

// ---------------------------------------- app/Excecoes/ErroDePedido.php
declare(strict_types=1);

namespace App\Excecoes;

use DomainException;

abstract class ErroDePedido extends DomainException
{
    abstract public function codigo(): string;

    public function statusHttp(): int
    {
        return 422;
    }
}

// ---------------------------------------- app/Excecoes/CarrinhoVazio.php
declare(strict_types=1);

namespace App\Excecoes;

final class CarrinhoVazio extends ErroDePedido
{
    public function __construct()
    {
        parent::__construct('Não é possível fechar um pedido com o carrinho vazio.');
    }

    public function codigo(): string
    {
        return 'CARRINHO_VAZIO';
    }
}

// ---------------------------------------- app/Excecoes/ProdutoIndisponivel.php
declare(strict_types=1);

namespace App\Excecoes;

final class ProdutoIndisponivel extends ErroDePedido
{
    public function __construct(public readonly string $produto, public readonly int $produtoId)
    {
        parent::__construct("\"{$produto}\" não está mais disponível.");
    }

    public function codigo(): string
    {
        return 'PRODUTO_INDISPONIVEL';
    }

    public function statusHttp(): int
    {
        return 409;
    }
}

// ---------------------------------------- app/Excecoes/EstoqueInsuficiente.php
declare(strict_types=1);

namespace App\Excecoes;

final class EstoqueInsuficiente extends ErroDePedido
{
    public function __construct(
        public readonly string $produto,
        public readonly int $disponivel,
        public readonly int $solicitado,
    ) {
        parent::__construct(sprintf(
            'Estoque insuficiente de "%s": restam %d, foram pedidas %d.',
            $produto, $disponivel, $solicitado,
        ));
    }

    public function codigo(): string
    {
        return 'ESTOQUE_INSUFICIENTE';
    }

    public function statusHttp(): int
    {
        return 409;
    }
}

// ---------------------------------------- app/Services/PedidoService.php
declare(strict_types=1);

namespace App\Services;

use App\Dominio\Dinheiro;
use App\Dominio\StatusDoPedido;
use App\Excecoes\CarrinhoVazio;
use App\Excecoes\EnderecoInvalido;
use App\Excecoes\EstoqueInsuficiente;
use App\Excecoes\ProdutoIndisponivel;
use App\Jobs\EnviarConfirmacaoDePedido;
use App\Models\Endereco;
use App\Models\Pedido;
use App\Models\Produto;
use App\Models\Usuario;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class PedidoService
{
    public function __construct(
        private readonly CarrinhoService $carrinhos,
        private readonly CalculadoraDeFrete $frete,
    ) {}

    public function criarDoCarrinho(Usuario $usuario, int $enderecoId): Pedido
    {
        ['carrinho' => $carrinho] = $this->carrinhos->carregar();

        // Guard clauses primeiro: o caminho feliz fica sem um `if` de
        // controle de fluxo.
        if ($carrinho->estaVazio()) {
            throw new CarrinhoVazio();
        }

        $endereco = $usuario->enderecos()->find($enderecoId)
            // Endereço de OUTRO usuário cai aqui: o find no relacionamento
            // já limita ao dono. Sem isso, trocar o id no formulário
            // entregaria o pedido no endereço de terceiro.
            ?? throw new EnderecoInvalido($enderecoId);

        $pedido = DB::transaction(function () use ($usuario, $endereco, $carrinho): Pedido {
            // lockForUpdate trava as linhas dos produtos até o commit.
            // Sem isso, duas compras simultâneas do último item leem
            // "estoque = 1" e ambas passam na validação — e o estoque
            // termina negativo.
            $produtos = Produto::query()
                ->whereIn('id', array_column($carrinho->itens(), 'produtoId'))
                ->lockForUpdate()
                ->get()
                ->keyBy('id');

            $itens = [];
            $subtotal = Dinheiro::zero();

            foreach ($carrinho->itens() as $item) {
                $produto = $produtos->get($item->produtoId);

                if ($produto === null || !$produto->ativo) {
                    throw new ProdutoIndisponivel($item->nome, $item->produtoId);
                }

                if ($produto->estoque < $item->quantidade) {
                    throw new EstoqueInsuficiente(
                        $produto->nome,
                        $produto->estoque,
                        $item->quantidade,
                    );
                }

                // O preço vem do BANCO, não do carrinho. O carrinho está na
                // sessão — território do cliente — e um preço vindo de lá
                // seria um desconto que o próprio comprador define.
                $precoUnitario = new Dinheiro($produto->preco_centavos);
                $subtotalItem  = $precoUnitario->multiplicar($item->quantidade);

                $itens[] = [
                    'produto_id'              => $produto->id,
                    // Nome copiado: a nota fiscal não muda quando o
                    // catálogo muda.
                    'produto_nome'            => $produto->nome,
                    'preco_unitario_centavos' => $precoUnitario->centavos,
                    'quantidade'              => $item->quantidade,
                    'subtotal_centavos'       => $subtotalItem->centavos,
                    'created_at'              => now(),
                    'updated_at'              => now(),
                ];

                $subtotal = $subtotal->somar($subtotalItem);

                // Decremento condicional: mesmo com a trava, a condição no
                // WHERE é a garantia final de que o estoque não fica
                // negativo.
                $afetadas = Produto::query()
                    ->whereKey($produto->id)
                    ->where('estoque', '>=', $item->quantidade)
                    ->decrement('estoque', $item->quantidade);

                if ($afetadas === 0) {
                    throw new EstoqueInsuficiente(
                        $produto->nome,
                        $produto->estoque,
                        $item->quantidade,
                    );
                }
            }

            $valorFrete = $this->frete->calcular($endereco, $subtotal);
            $total = $subtotal->somar($valorFrete);

            $pedido = Pedido::create([
                'usuario_id'        => $usuario->id,
                'endereco_id'       => $endereco->id,
                'codigo'            => $this->gerarCodigo(),
                'status'            => StatusDoPedido::AguardandoPagamento,
                'subtotal_centavos' => $subtotal->centavos,
                'frete_centavos'    => $valorFrete->centavos,
                'total_centavos'    => $total->centavos,
            ]);

            // insert em lote: uma consulta para todos os itens.
            $pedido->itens()->insert(array_map(
                static fn (array $item): array => [...$item, 'pedido_id' => $pedido->id],
                $itens,
            ));

            $pedido->historico()->create([
                'de'         => null,
                'para'       => StatusDoPedido::AguardandoPagamento->value,
                'usuario_id' => $usuario->id,
                'observacao' => 'Pedido criado',
            ]);

            return $pedido;
        });

        // FORA da transação: o carrinho é sessão, e limpá-lo dentro faria
        // com que um rollback do banco deixasse o cliente sem carrinho E
        // sem pedido.
        $this->carrinhos->limpar();

        // Também fora: e-mail é efeito externo e não volta com rollback.
        EnviarConfirmacaoDePedido::dispatch($pedido);

        Log::channel('pedidos')->info('Pedido criado', [
            'pedido_id' => $pedido->id,
            'codigo'    => $pedido->codigo,
            'total'     => $pedido->total_centavos,
            'itens'     => count($itens),
        ]);

        return $pedido;
    }

    private function gerarCodigo(): string
    {
        // Ano + aleatório: legível para o suporte ler ao telefone e sem
        // revelar quantos pedidos a loja recebeu (o que um id sequencial
        // exposto entregaria).
        return sprintf('MC-%s-%s', now()->format('y'), Str::upper(Str::random(6)));
    }
}

// ---------------------------------------- tests/Feature/PedidoServiceTest.php  (Pest)
declare(strict_types=1);

use App\Dominio\StatusDoPedido;
use App\Excecoes\CarrinhoVazio;
use App\Excecoes\EnderecoInvalido;
use App\Excecoes\EstoqueInsuficiente;
use App\Excecoes\ProdutoIndisponivel;
use App\Models\Endereco;
use App\Models\Produto;
use App\Models\Usuario;
use App\Services\CarrinhoService;
use App\Services\PedidoService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    Queue::fake();

    $this->usuario  = Usuario::factory()->create();
    $this->endereco = Endereco::factory()->for($this->usuario)->create();

    $this->actingAs($this->usuario);
});

it('cria o pedido a partir do carrinho', function (): void {
    $produto = Produto::factory()->create(['estoque' => 10, 'preco_centavos' => 1990]);

    app(CarrinhoService::class)->adicionar($produto->id, 2);

    $pedido = app(PedidoService::class)->criarDoCarrinho($this->usuario, $this->endereco->id);

    expect($pedido->status)->toBe(StatusDoPedido::AguardandoPagamento)
        ->and($pedido->subtotal_centavos)->toBe(3980)
        ->and($pedido->itens)->toHaveCount(1)
        ->and($pedido->codigo)->toStartWith('MC-');

    // O estoque foi baixado…
    expect($produto->fresh()->estoque)->toBe(8);

    // …e o carrinho, esvaziado.
    expect(app(CarrinhoService::class)->carregar()['carrinho']->estaVazio())->toBeTrue();
});

it('recusa carrinho vazio', function (): void {
    app(PedidoService::class)->criarDoCarrinho($this->usuario, $this->endereco->id);
})->throws(CarrinhoVazio::class);

it('recusa produto desativado', function (): void {
    $produto = Produto::factory()->create(['estoque' => 10]);

    app(CarrinhoService::class)->adicionar($produto->id, 1);

    // Desativado DEPOIS de entrar no carrinho — a corrida real.
    $produto->update(['ativo' => false]);

    app(PedidoService::class)->criarDoCarrinho($this->usuario, $this->endereco->id);
})->throws(ProdutoIndisponivel::class);

it('recusa estoque insuficiente', function (): void {
    $produto = Produto::factory()->create(['estoque' => 10]);

    app(CarrinhoService::class)->adicionar($produto->id, 5);

    // Outra pessoa comprou quase tudo nesse meio-tempo.
    $produto->update(['estoque' => 2]);

    app(PedidoService::class)->criarDoCarrinho($this->usuario, $this->endereco->id);
})->throws(EstoqueInsuficiente::class);

it('recusa endereço de outro usuário', function (): void {
    $produto = Produto::factory()->create(['estoque' => 10]);
    app(CarrinhoService::class)->adicionar($produto->id, 1);

    $alheio = Endereco::factory()->for(Usuario::factory())->create();

    app(PedidoService::class)->criarDoCarrinho($this->usuario, $alheio->id);
})->throws(EnderecoInvalido::class);

it('não deixa resíduo quando falha no meio', function (): void {
    $bom  = Produto::factory()->create(['estoque' => 10, 'nome' => 'Disponível']);
    $ruim = Produto::factory()->create(['estoque' => 10, 'nome' => 'Vai faltar']);

    app(CarrinhoService::class)->adicionar($bom->id, 1);
    app(CarrinhoService::class)->adicionar($ruim->id, 5);

    $ruim->update(['estoque' => 1]);

    try {
        app(PedidoService::class)->criarDoCarrinho($this->usuario, $this->endereco->id);
    } catch (EstoqueInsuficiente) {
        // esperado
    }

    // A asserção que prova a transação: o primeiro produto já tinha sido
    // decrementado quando o segundo falhou. Sem o rollback, o estoque
    // ficaria errado e sobraria um pedido órfão.
    expect($bom->fresh()->estoque)->toBe(10)
        ->and(\App\Models\Pedido::count())->toBe(0)
        ->and(\DB::table('itens_pedido')->count())->toBe(0);
});

it('usa o preço do banco, não o do carrinho', function (): void {
    $produto = Produto::factory()->create(['estoque' => 10, 'preco_centavos' => 1000]);

    app(CarrinhoService::class)->adicionar($produto->id, 1);

    $produto->update(['preco_centavos' => 5000]);

    $pedido = app(PedidoService::class)->criarDoCarrinho($this->usuario, $this->endereco->id);

    // O carrinho vive na sessão — território do cliente. Aceitar o preço
    // de lá seria deixar o comprador definir o próprio desconto.
    expect($pedido->itens->first()->preco_unitario_centavos)->toBe(5000);
});

it('registra o primeiro evento no histórico', function (): void {
    $produto = Produto::factory()->create(['estoque' => 5]);
    app(CarrinhoService::class)->adicionar($produto->id, 1);

    $pedido = app(PedidoService::class)->criarDoCarrinho($this->usuario, $this->endereco->id);

    expect($pedido->historico)->toHaveCount(1)
        ->and($pedido->historico->first()->para)
            ->toBe(StatusDoPedido::AguardandoPagamento->value);
});

Exercício 2

Implemente os controllers de catálogo e carrinho. Crie as views Blade correspondentes com layout, listagem de produtos, página de detalhe, e página do carrinho com atualização de quantidade.

Ver resposta

✓ Resposta: Três detalhes que a tela expõe. A ordenação é um mapa fechado — o que chega em ?ordenar= é uma chave, nunca SQL. A busca usa a coluna tsvector gerada na migration, e não LIKE '%termo%', que não aproveita índice algum. E os avisos da reconciliação aparecem na página do carrinho de propósito: é onde o usuário ainda pode decidir, em vez de descobrir no checkout que o preço mudou. O min="0" no campo de quantidade não é descuido — zero é como se remove o item.

<?php

// ---------------------------------------- app/Http/Controllers/CatalogoController.php
declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Categoria;
use App\Models\Produto;
use Illuminate\Http\Request;
use Illuminate\View\View;

class CatalogoController extends Controller
{
    public function index(Request $request): View
    {
        $ordenacoes = [
            'relevancia'  => ['nome', 'asc'],
            'menor_preco' => ['preco_centavos', 'asc'],
            'maior_preco' => ['preco_centavos', 'desc'],
            'novidades'   => ['created_at', 'desc'],
        ];

        // Mapa fechado: o que vem na query string é uma CHAVE, nunca um
        // pedaço de SQL.
        [$coluna, $direcao] = $ordenacoes[$request->string('ordenar')->value()]
            ?? $ordenacoes['relevancia'];

        $produtos = Produto::query()
            ->with('categoria:id,nome,slug')
            ->where('ativo', true)
            ->when($request->string('q')->trim()->value(), function ($consulta, string $termo): void {
                // Busca textual do PostgreSQL, com a coluna tsvector
                // gerada na migration. Muito mais rápido que LIKE '%x%',
                // que não usa índice nenhum.
                $consulta->whereRaw(
                    "busca @@ plainto_tsquery('portuguese', ?)",
                    [$termo],
                );
            })
            ->when($request->string('categoria')->value(), function ($consulta, string $slug): void {
                $consulta->whereHas('categoria', fn ($c) => $c->where('slug', $slug));
            })
            ->when($request->boolean('disponiveis'), fn ($c) => $c->where('estoque', '>', 0))
            ->orderBy($coluna, $direcao)
            ->paginate(12)
            ->withQueryString();

        return view('catalogo.index', [
            'produtos'   => $produtos,
            'categorias' => Categoria::query()->where('ativa', true)->orderBy('nome')->get(),
            'ordenacoes' => array_keys($ordenacoes),
        ]);
    }

    public function show(Produto $produto): View
    {
        abort_unless($produto->ativo, 404);

        return view('catalogo.show', [
            'produto' => $produto->load('categoria'),
            // Relacionados: mesma categoria, exceto ele mesmo.
            'relacionados' => Produto::query()
                ->where('categoria_id', $produto->categoria_id)
                ->whereKeyNot($produto->id)
                ->where('ativo', true)
                ->where('estoque', '>', 0)
                ->inRandomOrder()
                ->limit(4)
                ->get(),
        ]);
    }
}

// ---------------------------------------- app/Http/Controllers/CarrinhoController.php
declare(strict_types=1);

namespace App\Http\Controllers;

use App\Services\CarrinhoService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;

class CarrinhoController extends Controller
{
    public function __construct(private readonly CarrinhoService $carrinhos) {}

    public function index(): View
    {
        ['carrinho' => $carrinho, 'avisos' => $avisos] = $this->carrinhos->carregar();

        return view('carrinho.index', compact('carrinho', 'avisos'));
    }

    public function adicionar(Request $request, int $produtoId): RedirectResponse
    {
        $dados = $request->validate([
            'quantidade' => ['required', 'integer', 'min:1', 'max:20'],
        ]);

        try {
            $this->carrinhos->adicionar($produtoId, $dados['quantidade']);
        } catch (RuntimeException $e) {
            return back()->with('erro', $e->getMessage());
        }

        return back()->with('sucesso', 'Produto adicionado ao carrinho.');
    }

    public function atualizar(Request $request, int $produtoId): RedirectResponse
    {
        $dados = $request->validate([
            // min:0 de propósito — zero significa "remover".
            'quantidade' => ['required', 'integer', 'min:0', 'max:20'],
        ]);

        try {
            $this->carrinhos->atualizar($produtoId, $dados['quantidade']);
        } catch (RuntimeException $e) {
            return back()->with('erro', $e->getMessage());
        }

        return back()->with('sucesso', 'Carrinho atualizado.');
    }

    public function remover(int $produtoId): RedirectResponse
    {
        $this->carrinhos->remover($produtoId);

        return back()->with('sucesso', 'Produto removido.');
    }
}

// ---------------------------------------- routes/web.php
declare(strict_types=1);

use App\Http\Controllers\CarrinhoController;
use App\Http\Controllers\CatalogoController;
use Illuminate\Support\Facades\Route;

Route::get('/', [CatalogoController::class, 'index'])->name('catalogo.index');
Route::get('/produto/{produto:slug}', [CatalogoController::class, 'show'])->name('catalogo.show');

Route::prefix('carrinho')->name('carrinho.')->group(function (): void {
    Route::get('/', [CarrinhoController::class, 'index'])->name('index');
    Route::post('/{produto}', [CarrinhoController::class, 'adicionar'])->name('adicionar');
    Route::patch('/{produto}', [CarrinhoController::class, 'atualizar'])->name('atualizar');
    Route::delete('/{produto}', [CarrinhoController::class, 'remover'])->name('remover');
});

// ---------------------------------------- resources/views/catalogo/index.blade.php
// @extends('layouts.app')
//
// @section('titulo', 'Catálogo')
//
// @section('conteudo')
//     <div class="catalogo">
//         <aside class="filtros">
//             <form method="get">
//                 <input type="search" name="q" value="{{ request('q') }}"
//                        placeholder="Buscar produtos...">
//
//                 <select name="categoria" onchange="this.form.submit()">
//                     <option value="">Todas as categorias</option>
//                     @foreach ($categorias as $categoria)
//                         <option value="{{ $categoria->slug }}"
//                             @selected(request('categoria') === $categoria->slug)>
//                             {{ $categoria->nome }}
//                         </option>
//                     @endforeach
//                 </select>
//
//                 <select name="ordenar" onchange="this.form.submit()">
//                     @foreach ($ordenacoes as $opcao)
//                         <option value="{{ $opcao }}" @selected(request('ordenar') === $opcao)>
//                             {{ str_replace('_', ' ', ucfirst($opcao)) }}
//                         </option>
//                     @endforeach
//                 </select>
//
//                 <label>
//                     <input type="checkbox" name="disponiveis" value="1"
//                            @checked(request()->boolean('disponiveis'))>
//                     Somente disponíveis
//                 </label>
//             </form>
//         </aside>
//
//         <div class="grade">
//             @forelse ($produtos as $produto)
//                 <x-produto-card :produto="$produto" />
//             @empty
//                 <p>Nenhum produto encontrado.
//                    <a href="{{ route('catalogo.index') }}">Limpar filtros</a></p>
//             @endforelse
//         </div>
//     </div>
//
//     {{ $produtos->withQueryString()->links() }}
// @endsection

// ---------------------------------------- resources/views/carrinho/index.blade.php
// @extends('layouts.app')
//
// @section('titulo', 'Meu carrinho')
//
// @section('conteudo')
//     <h1>Meu carrinho</h1>
//
//     {{-- Os avisos da reconciliação: preço que mudou, item que saiu.
//          Mostrar aqui é o que evita a surpresa no checkout. --}}
//     @foreach ($avisos as $aviso)
//         <x-alerta tipo="aviso">{{ $aviso }}</x-alerta>
//     @endforeach
//
//     @if ($carrinho->estaVazio())
//         <p>Seu carrinho está vazio.
//            <a href="{{ route('catalogo.index') }}">Ver produtos</a></p>
//     @else
//         <table class="carrinho">
//             @foreach ($carrinho->itens() as $item)
//                 <tr>
//                     <td>{{ $item->nome }}</td>
//                     <td>{{ $item->precoUnitario->formatado() }}</td>
//                     <td>
//                         <form method="post"
//                               action="{{ route('carrinho.atualizar', $item->produtoId) }}">
//                             @csrf
//                             @method('PATCH')
//                             <input type="number" name="quantidade"
//                                    value="{{ $item->quantidade }}"
//                                    min="0" max="{{ min(20, $item->estoqueDisponivel) }}"
//                                    onchange="this.form.submit()">
//                         </form>
//                     </td>
//                     <td>{{ $item->subtotal()->formatado() }}</td>
//                     <td>
//                         <form method="post"
//                               action="{{ route('carrinho.remover', $item->produtoId) }}">
//                             @csrf
//                             @method('DELETE')
//                             <button type="submit">Remover</button>
//                         </form>
//                     </td>
//                 </tr>
//             @endforeach
//
//             <tfoot>
//                 <tr>
//                     <th colspan="3">Total</th>
//                     <th colspan="2">{{ $carrinho->total()->formatado() }}</th>
//                 </tr>
//             </tfoot>
//         </table>
//
//         <a href="{{ route('checkout.index') }}" class="botao botao--principal">
//             Finalizar compra
//         </a>
//     @endif
// @endsection

Exercício 3

Implemente o checkout: formulário com seleção de endereço existente ou novo, confirmação do pedido, e redirecionamento para a página do pedido criado com flash message.

Ver resposta

✓ Resposta: A regra de validação que evita o pior bug possível desta tela: Rule::exists('enderecos', 'id')->where('usuario_id', $this->user()->id) — sem esse where, trocar o id no formulário entrega o pedido no endereço de outra pessoa. E o PRG não é elegância: sem o redirecionamento após o POST, um F5 na página de confirmação cria uma segunda compra e o cliente paga duas vezes. O botão desabilitado por JavaScript ajuda, mas quem garante é o throttle da rota — JS desligado não protege nada.

<?php

// ---------------------------------------- app/Http/Requests/CheckoutRequest.php
declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class CheckoutRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    /** @return array<string, list<mixed>> */
    public function rules(): array
    {
        $novo = $this->input('endereco_opcao') === 'novo';

        return [
            'endereco_opcao' => ['required', Rule::in(['existente', 'novo'])],

            'endereco_id' => [
                Rule::requiredIf(!$novo),
                'nullable', 'integer',
                // A regra que impede o pedido de ser entregue no endereço
                // de outra pessoa: o exists é restrito ao usuário logado.
                Rule::exists('enderecos', 'id')->where('usuario_id', $this->user()->id),
            ],

            'cep'         => [Rule::requiredIf($novo), 'nullable', 'string', 'size:9'],
            'logradouro'  => [Rule::requiredIf($novo), 'nullable', 'string', 'max:255'],
            'numero'      => [Rule::requiredIf($novo), 'nullable', 'string', 'max:20'],
            'complemento' => ['nullable', 'string', 'max:100'],
            'bairro'      => [Rule::requiredIf($novo), 'nullable', 'string', 'max:100'],
            'cidade'      => [Rule::requiredIf($novo), 'nullable', 'string', 'max:100'],
            'uf'          => [Rule::requiredIf($novo), 'nullable', 'string', 'size:2'],
            'salvar_endereco' => ['boolean'],

            'forma_pagamento' => ['required', Rule::in(['pix', 'cartao', 'boleto'])],
        ];
    }

    /** @return array<string, string> */
    public function messages(): array
    {
        return [
            'endereco_id.exists' => 'Selecione um endereço válido.',
            'cep.size'           => 'O CEP deve estar no formato 00000-000.',
            'uf.size'            => 'Use a sigla do estado, com 2 letras.',
        ];
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'uf'  => mb_strtoupper((string) $this->input('uf')),
            'cep' => preg_replace('/(\d{5})(\d{3})/', '$1-$2',
                preg_replace('/\D/', '', (string) $this->input('cep')) ?? ''),
        ]);
    }
}

// ---------------------------------------- app/Http/Controllers/CheckoutController.php
declare(strict_types=1);

namespace App\Http\Controllers;

use App\Excecoes\ErroDePedido;
use App\Http\Requests\CheckoutRequest;
use App\Models\Endereco;
use App\Services\CarrinhoService;
use App\Services\PedidoService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;

class CheckoutController extends Controller
{
    public function __construct(
        private readonly CarrinhoService $carrinhos,
        private readonly PedidoService $pedidos,
    ) {}

    public function index(): View|RedirectResponse
    {
        ['carrinho' => $carrinho, 'avisos' => $avisos] = $this->carrinhos->carregar();

        // Carrinho que esvaziou na reconciliação não pode chegar ao
        // formulário de pagamento.
        if ($carrinho->estaVazio()) {
            return redirect()
                ->route('carrinho.index')
                ->with('erro', 'Seu carrinho está vazio.');
        }

        return view('checkout.index', [
            'carrinho'  => $carrinho,
            'avisos'    => $avisos,
            'enderecos' => auth()->user()->enderecos()->latest()->get(),
        ]);
    }

    public function finalizar(CheckoutRequest $request): RedirectResponse
    {
        $usuario = $request->user();

        try {
            $endereco = DB::transaction(function () use ($request, $usuario): Endereco {
                if ($request->input('endereco_opcao') === 'existente') {
                    // O findOrFail no RELACIONAMENTO já limita ao dono —
                    // cinto e suspensório junto com a regra do FormRequest.
                    return $usuario->enderecos()->findOrFail($request->integer('endereco_id'));
                }

                $endereco = $usuario->enderecos()->create($request->safe()->only([
                    'cep', 'logradouro', 'numero', 'complemento', 'bairro', 'cidade', 'uf',
                ]));

                // Se o usuário não pediu para guardar, o endereço serve só
                // a este pedido: fica marcado como temporário e some das
                // próximas listagens.
                if (!$request->boolean('salvar_endereco')) {
                    $endereco->update(['temporario' => true]);
                }

                return $endereco;
            });

            $pedido = $this->pedidos->criarDoCarrinho($usuario, $endereco->id);
        } catch (ErroDePedido $e) {
            // Erro de domínio volta para o carrinho, onde o usuário pode
            // resolver (tirar o item esgotado, ajustar a quantidade).
            return redirect()
                ->route('carrinho.index')
                ->with('erro', $e->getMessage());
        }

        // PRG: redireciona depois do POST para o F5 não refazer o pedido.
        // Sem isto, recarregar a página de confirmação cria uma segunda
        // compra — e o cliente paga duas vezes.
        return redirect()
            ->route('pedidos.show', $pedido)
            ->with('sucesso', "Pedido {$pedido->codigo} confirmado! Enviamos os detalhes por e-mail.");
    }
}

// ---------------------------------------- routes/web.php
declare(strict_types=1);

use App\Http\Controllers\CheckoutController;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth', 'verified'])->group(function (): void {
    Route::get('/checkout', [CheckoutController::class, 'index'])->name('checkout.index');

    Route::post('/checkout', [CheckoutController::class, 'finalizar'])
        ->name('checkout.finalizar')
        // Limite de tentativas: fechar pedido é operação cara e um clique
        // duplo não pode virar dois pedidos.
        ->middleware('throttle:5,1');
});

// ---------------------------------------- resources/views/checkout/index.blade.php
// @extends('layouts.app')
//
// @section('titulo', 'Finalizar compra')
//
// @section('conteudo')
//     <h1>Finalizar compra</h1>
//
//     <form method="post" action="{{ route('checkout.finalizar') }}" id="checkout">
//         @csrf
//
//         <fieldset>
//             <legend>Endereço de entrega</legend>
//
//             @foreach ($enderecos as $endereco)
//                 <label class="endereco">
//                     <input type="radio" name="endereco_id" value="{{ $endereco->id }}"
//                            @checked(old('endereco_id', $enderecos->first()->id) == $endereco->id)
//                            onchange="document.getElementById('opcao-existente').checked = true">
//                     {{ $endereco->logradouro }}, {{ $endereco->numero }}
//                     — {{ $endereco->cidade }}/{{ $endereco->uf }}
//                 </label>
//             @endforeach
//
//             <label>
//                 <input type="radio" name="endereco_opcao" value="existente"
//                        id="opcao-existente"
//                        @checked(old('endereco_opcao', 'existente') === 'existente')>
//                 Usar um endereço salvo
//             </label>
//
//             <label>
//                 <input type="radio" name="endereco_opcao" value="novo"
//                        @checked(old('endereco_opcao') === 'novo')>
//                 Entregar em outro endereço
//             </label>
//
//             <div class="endereco-novo">
//                 <input type="text" name="cep" value="{{ old('cep') }}"
//                        placeholder="00000-000" maxlength="9">
//                 @error('cep') <p class="erro">{{ $message }}</p> @enderror
//
//                 <input type="text" name="logradouro" value="{{ old('logradouro') }}"
//                        placeholder="Rua">
//                 <input type="text" name="numero" value="{{ old('numero') }}"
//                        placeholder="Número">
//                 <input type="text" name="complemento" value="{{ old('complemento') }}"
//                        placeholder="Complemento (opcional)">
//                 <input type="text" name="bairro" value="{{ old('bairro') }}"
//                        placeholder="Bairro">
//                 <input type="text" name="cidade" value="{{ old('cidade') }}"
//                        placeholder="Cidade">
//                 <input type="text" name="uf" value="{{ old('uf') }}"
//                        placeholder="UF" maxlength="2">
//
//                 <label>
//                     <input type="hidden" name="salvar_endereco" value="0">
//                     <input type="checkbox" name="salvar_endereco" value="1"
//                            @checked(old('salvar_endereco'))>
//                     Salvar este endereço para as próximas compras
//                 </label>
//             </div>
//         </fieldset>
//
//         <fieldset>
//             <legend>Pagamento</legend>
//             @foreach (['pix' => 'PIX', 'cartao' => 'Cartão', 'boleto' => 'Boleto'] as $v => $rotulo)
//                 <label>
//                     <input type="radio" name="forma_pagamento" value="{{ $v }}"
//                            @checked(old('forma_pagamento', 'pix') === $v) required>
//                     {{ $rotulo }}
//                 </label>
//             @endforeach
//         </fieldset>
//
//         <aside class="resumo">
//             <h2>Resumo</h2>
//             @foreach ($carrinho->itens() as $item)
//                 <p>{{ $item->quantidade }}x {{ $item->nome }}
//                    — {{ $item->subtotal()->formatado() }}</p>
//             @endforeach
//             <p><strong>Total: {{ $carrinho->total()->formatado() }}</strong></p>
//         </aside>
//
//         {{-- O JS desabilita o botão no submit; o throttle da rota é a
//              garantia de verdade, porque JS desligado não protege nada. --}}
//         <button type="submit" onclick="this.disabled=true; this.form.submit()">
//             Confirmar pedido
//         </button>
//     </form>
// @endsection

// ---------------------------------------- tests/Feature/CheckoutTest.php  (Pest)
declare(strict_types=1);

use App\Models\Endereco;
use App\Models\Pedido;
use App\Models\Produto;
use App\Models\Usuario;
use App\Services\CarrinhoService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    Queue::fake();

    $this->usuario = Usuario::factory()->create(['email_verificado_em' => now()]);
    $this->produto = Produto::factory()->create(['estoque' => 10, 'preco_centavos' => 2500]);

    $this->actingAs($this->usuario);

    app(CarrinhoService::class)->adicionar($this->produto->id, 2);
});

it('fecha o pedido com endereço existente', function (): void {
    $endereco = Endereco::factory()->for($this->usuario)->create();

    $this->post('/checkout', [
        'endereco_opcao'  => 'existente',
        'endereco_id'     => $endereco->id,
        'forma_pagamento' => 'pix',
    ])->assertRedirect();

    expect(Pedido::count())->toBe(1)
        ->and(Pedido::first()->endereco_id)->toBe($endereco->id);
});

it('cria o endereço novo quando pedido', function (): void {
    $this->post('/checkout', [
        'endereco_opcao'  => 'novo',
        'cep'             => '01310-100',
        'logradouro'      => 'Av. Paulista',
        'numero'          => '1000',
        'bairro'          => 'Bela Vista',
        'cidade'          => 'São Paulo',
        'uf'              => 'sp',              // minúsculo: normalizado
        'salvar_endereco' => '1',
        'forma_pagamento' => 'pix',
    ])->assertRedirect();

    $this->assertDatabaseHas('enderecos', [
        'usuario_id' => $this->usuario->id,
        'uf'         => 'SP',
        'temporario' => false,
    ]);
});

it('recusa endereço de outro usuário', function (): void {
    $alheio = Endereco::factory()->for(Usuario::factory())->create();

    $this->post('/checkout', [
        'endereco_opcao'  => 'existente',
        'endereco_id'     => $alheio->id,
        'forma_pagamento' => 'pix',
    ])->assertSessionHasErrors('endereco_id');

    expect(Pedido::count())->toBe(0);
});

it('redireciona para o carrinho quando ele está vazio', function (): void {
    app(CarrinhoService::class)->limpar();

    $this->get('/checkout')
        ->assertRedirect(route('carrinho.index'))
        ->assertSessionHas('erro');
});

it('leva à página do pedido com a mensagem de sucesso', function (): void {
    $endereco = Endereco::factory()->for($this->usuario)->create();

    $resposta = $this->post('/checkout', [
        'endereco_opcao'  => 'existente',
        'endereco_id'     => $endereco->id,
        'forma_pagamento' => 'pix',
    ]);

    $pedido = Pedido::first();

    $resposta->assertRedirect(route('pedidos.show', $pedido))
        ->assertSessionHas('sucesso');

    $this->get(route('pedidos.show', $pedido))
        ->assertOk()
        ->assertSee($pedido->codigo);
});

Exercício 4

Implemente o painel admin de pedidos: listagem com filtro por status, página de detalhe com timeline de status, e botão para avançar o status com validação de transição.

Ver resposta

✓ Resposta: A mesma fonte de verdade serve às duas pontas: o proximosPossiveis() do enum monta o <select> e valida o POST — a interface nunca oferece o que o domínio recusaria, e ninguém contorna mandando o formulário na mão. O detalhe que exige cuidado é a devolução de estoque: ela só acontece a partir de um estado que reservava estoque, senão cancelar um pedido já entregue (ou reembolsar depois de cancelar) devolveria o mesmo item duas vezes. E a timeline mostra produto_nome, não produto->nome: o histórico é o que foi comprado, não o que o catálogo diz hoje.

<?php

// ---------------------------------------- app/Services/TransicaoDePedido.php
declare(strict_types=1);

namespace App\Services;

use App\Dominio\StatusDoPedido;
use App\Excecoes\TransicaoInvalida;
use App\Models\Pedido;
use App\Models\Usuario;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class TransicaoDePedido
{
    public function executar(
        Pedido $pedido,
        StatusDoPedido $novo,
        Usuario $responsavel,
        ?string $observacao = null,
    ): Pedido {
        $atual = $pedido->status;

        // A validação vem do ENUM, não de ifs espalhados pelo controller.
        // Um único lugar decide o que é transição legal.
        if (!$atual->podeTransicionarPara($novo)) {
            throw new TransicaoInvalida($atual, $novo);
        }

        return DB::transaction(function () use ($pedido, $atual, $novo, $responsavel, $observacao): Pedido {
            // Cancelamento e reembolso DEVOLVEM o estoque — e só a partir
            // de estados que o tinham reservado. Sem essa checagem,
            // cancelar duas vezes (ou reembolsar depois de cancelar)
            // devolveria o estoque em dobro.
            if (in_array($novo, [StatusDoPedido::Cancelado, StatusDoPedido::Reembolsado], true)
                && $atual->reservaEstoque()) {
                $this->devolverEstoque($pedido);
            }

            $pedido->update([
                'status' => $novo,
                ...match ($novo) {
                    StatusDoPedido::Pago       => ['pago_em' => now()],
                    StatusDoPedido::Enviado    => ['enviado_em' => now()],
                    StatusDoPedido::Entregue   => ['entregue_em' => now()],
                    StatusDoPedido::Cancelado  => [
                        'cancelado_em' => now(),
                        'motivo_cancelamento' => $observacao,
                    ],
                    default => [],
                },
            ]);

            $pedido->historico()->create([
                'de'         => $atual->value,
                'para'       => $novo->value,
                'usuario_id' => $responsavel->id,
                'observacao' => $observacao,
            ]);

            Log::channel('pedidos')->info('Status alterado', [
                'pedido_id'   => $pedido->id,
                'codigo'      => $pedido->codigo,
                'de'          => $atual->value,
                'para'        => $novo->value,
                'responsavel' => $responsavel->id,
            ]);

            return $pedido->refresh();
        });
    }

    private function devolverEstoque(Pedido $pedido): void
    {
        foreach ($pedido->itens as $item) {
            $item->produto()->increment('estoque', $item->quantidade);
        }
    }
}

// ---------------------------------------- app/Http/Controllers/Admin/PedidoController.php
declare(strict_types=1);

namespace App\Http\Controllers\Admin;

use App\Dominio\StatusDoPedido;
use App\Excecoes\TransicaoInvalida;
use App\Http\Controllers\Controller;
use App\Models\Pedido;
use App\Services\TransicaoDePedido;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;

class PedidoController extends Controller
{
    public function __construct(private readonly TransicaoDePedido $transicoes) {}

    public function index(Request $request): View
    {
        $status = StatusDoPedido::tryFrom($request->string('status')->value());

        $pedidos = Pedido::query()
            ->with(['usuario:id,nome,email'])
            ->withCount('itens')
            ->when($status, fn ($c) => $c->where('status', $status))
            ->when($request->string('codigo')->value(), function ($c, string $codigo): void {
                $c->where('codigo', 'like', "%{$codigo}%");
            })
            ->when($request->date('de'), fn ($c, $de) => $c->where('created_at', '>=', $de))
            ->when($request->date('ate'), fn ($c, $ate) => $c->where('created_at', '<=', $ate->endOfDay()))
            ->latest()
            ->paginate(20)
            ->withQueryString();

        return view('admin.pedidos.index', [
            'pedidos' => $pedidos,
            'status'  => StatusDoPedido::cases(),
            // Contagem por status para os chips do topo — uma consulta,
            // não sete.
            'totais'  => Pedido::query()
                ->selectRaw('status, count(*) as total')
                ->groupBy('status')
                ->pluck('total', 'status'),
        ]);
    }

    public function show(Pedido $pedido): View
    {
        return view('admin.pedidos.show', [
            'pedido' => $pedido->load([
                'usuario', 'endereco', 'itens.produto',
                'historico.usuario' => fn ($c) => $c->select('id', 'nome'),
            ]),
            // O que o BOTÃO deve oferecer sai do enum: a interface nunca
            // mostra uma transição que o domínio recusaria.
            'proximos' => $pedido->status->proximosPossiveis(),
        ]);
    }

    public function avancar(Request $request, Pedido $pedido): RedirectResponse
    {
        $dados = $request->validate([
            'status'     => ['required', Rule::enum(StatusDoPedido::class)],
            'observacao' => ['nullable', 'string', 'max:255'],
            // Cancelamento exige justificativa: seis meses depois, ninguém
            // lembra por que aquele pedido foi cancelado.
            'observacao' => [
                Rule::requiredIf($request->input('status') === StatusDoPedido::Cancelado->value),
                'nullable', 'string', 'max:255',
            ],
        ]);

        try {
            $this->transicoes->executar(
                $pedido,
                StatusDoPedido::from($dados['status']),
                $request->user(),
                $dados['observacao'] ?? null,
            );
        } catch (TransicaoInvalida $e) {
            // Acontece de verdade: duas abas abertas, e a segunda tenta
            // uma transição que a primeira já tornou inválida.
            return back()->with('erro', $e->getMessage());
        }

        return back()->with('sucesso', 'Status atualizado.');
    }
}

// ---------------------------------------- resources/views/admin/pedidos/show.blade.php
// @extends('layouts.admin')
//
// @section('conteudo')
//     <h1>Pedido {{ $pedido->codigo }}</h1>
//
//     <span class="chip chip--{{ $pedido->status->cor() }}">
//         {{ $pedido->status->rotulo() }}
//     </span>
//
//     {{-- ---- TIMELINE ---- --}}
//     <ol class="timeline">
//         @foreach ($pedido->historico->sortBy('created_at') as $evento)
//             @php $status = \App\Dominio\StatusDoPedido::from($evento->para); @endphp
//
//             <li class="timeline__item timeline__item--{{ $status->cor() }}">
//                 <time datetime="{{ $evento->created_at->toIso8601String() }}">
//                     {{ $evento->created_at->format('d/m/Y H:i') }}
//                 </time>
//
//                 <strong>{{ $status->rotulo() }}</strong>
//
//                 @if ($evento->de)
//                     <small>(de {{ \App\Dominio\StatusDoPedido::from($evento->de)->rotulo() }})</small>
//                 @endif
//
//                 <span>{{ $evento->usuario?->nome ?? 'sistema' }}</span>
//
//                 @if ($evento->observacao)
//                     <p class="timeline__obs">{{ $evento->observacao }}</p>
//                 @endif
//             </li>
//         @endforeach
//     </ol>
//
//     {{-- ---- AVANÇAR STATUS ---- --}}
//     @if ($proximos !== [])
//         <form method="post" action="{{ route('admin.pedidos.avancar', $pedido) }}">
//             @csrf
//
//             <select name="status" required id="novo-status">
//                 <option value="">Selecione o próximo status...</option>
//                 {{-- Só o que o enum permite: a interface não oferece
//                      transição que o domínio recusaria. --}}
//                 @foreach ($proximos as $status)
//                     <option value="{{ $status->value }}">{{ $status->rotulo() }}</option>
//                 @endforeach
//             </select>
//
//             <input type="text" name="observacao" maxlength="255"
//                    placeholder="Observação (obrigatória no cancelamento)">
//
//             <button type="submit">Avançar</button>
//         </form>
//     @else
//         <p class="aviso">
//             Este pedido está em um estado final ({{ $pedido->status->rotulo() }})
//             e não admite novas transições.
//         </p>
//     @endif
//
//     {{-- ---- ITENS ---- --}}
//     <table>
//         @foreach ($pedido->itens as $item)
//             <tr>
//                 {{-- produto_nome, não produto->nome: o histórico mostra o
//                      nome que o produto tinha NA COMPRA. --}}
//                 <td>{{ $item->produto_nome }}</td>
//                 <td>{{ $item->quantidade }}</td>
//                 <td>{{ $item->precoUnitario()->formatado() }}</td>
//                 <td>{{ $item->subtotal()->formatado() }}</td>
//             </tr>
//         @endforeach
//     </table>
// @endsection

// ---------------------------------------- tests/Feature/Admin/TransicaoTest.php  (Pest)
declare(strict_types=1);

use App\Dominio\StatusDoPedido;
use App\Models\ItemPedido;
use App\Models\Pedido;
use App\Models\Produto;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->admin = Usuario::factory()->create();
    $this->admin->assignRole('admin');

    $this->actingAs($this->admin);
});

it('avança para um status permitido', function (): void {
    $pedido = Pedido::factory()->create(['status' => StatusDoPedido::AguardandoPagamento]);

    $this->post(route('admin.pedidos.avancar', $pedido), [
        'status' => StatusDoPedido::Pago->value,
    ])->assertRedirect();

    expect($pedido->fresh()->status)->toBe(StatusDoPedido::Pago)
        ->and($pedido->fresh()->pago_em)->not->toBeNull();
});

it('recusa transição inválida', function (): void {
    $pedido = Pedido::factory()->create(['status' => StatusDoPedido::AguardandoPagamento]);

    $this->post(route('admin.pedidos.avancar', $pedido), [
        'status' => StatusDoPedido::Entregue->value,
    ])->assertSessionHas('erro');

    expect($pedido->fresh()->status)->toBe(StatusDoPedido::AguardandoPagamento);
});

it('registra cada transição no histórico', function (): void {
    $pedido = Pedido::factory()->create(['status' => StatusDoPedido::AguardandoPagamento]);

    $this->post(route('admin.pedidos.avancar', $pedido), ['status' => 'pago']);
    $this->post(route('admin.pedidos.avancar', $pedido), ['status' => 'em_separacao']);

    $historico = $pedido->fresh()->historico;

    expect($historico)->toHaveCount(2)
        ->and($historico->last()->de)->toBe('pago')
        ->and($historico->last()->usuario_id)->toBe($this->admin->id);
});

it('devolve o estoque ao cancelar', function (): void {
    $produto = Produto::factory()->create(['estoque' => 5]);
    $pedido  = Pedido::factory()->create(['status' => StatusDoPedido::Pago]);

    ItemPedido::factory()->for($pedido)->for($produto)->create(['quantidade' => 3]);

    $this->post(route('admin.pedidos.avancar', $pedido), [
        'status'     => StatusDoPedido::Cancelado->value,
        'observacao' => 'Cliente desistiu',
    ]);

    expect($produto->fresh()->estoque)->toBe(8);
});

it('não devolve estoque duas vezes', function (): void {
    $produto = Produto::factory()->create(['estoque' => 5]);
    $pedido  = Pedido::factory()->create(['status' => StatusDoPedido::Entregue]);

    ItemPedido::factory()->for($pedido)->for($produto)->create(['quantidade' => 3]);

    // Entregue → Reembolsado: o estoque JÁ saiu da reserva na entrega,
    // então o reembolso não pode somar de novo.
    $this->post(route('admin.pedidos.avancar', $pedido), [
        'status' => StatusDoPedido::Reembolsado->value,
    ]);

    expect($produto->fresh()->estoque)->toBe(5);
});

it('exige justificativa no cancelamento', function (): void {
    $pedido = Pedido::factory()->create(['status' => StatusDoPedido::Pago]);

    $this->post(route('admin.pedidos.avancar', $pedido), [
        'status' => StatusDoPedido::Cancelado->value,
    ])->assertSessionHasErrors('observacao');
});

it('esconde o formulário em estado final', function (): void {
    $pedido = Pedido::factory()->create(['status' => StatusDoPedido::Cancelado]);

    $this->get(route('admin.pedidos.show', $pedido))
        ->assertOk()
        ->assertSee('estado final')
        ->assertDontSee('Avançar');
});

Exercício 5

Desafio: implemente um relatório de vendas em /admin/relatorios/vendas com filtro de período. Use uma query com GROUP BY dia, soma de totais e contagem de pedidos. Exiba em tabela e, usando Chart.js via CDN no Blade, em um gráfico de linha.

Ver resposta

✓ Resposta: O detalhe que separa um relatório correto de um enganoso: preencher os dias sem venda com zero. O GROUP BY só devolve os dias que existem, e um gráfico de linha que liga 08/03 direto em 10/03 desenha uma reta suave onde houve um fim de semana parado. Dois acompanhantes: @json() para passar dados ao <script> (as regras de escape do HTML não valem dentro de JavaScript) e o segundo eixo no Chart.js — receita em milhares e pedidos em dezenas no mesmo eixo achatam a linha menor contra o zero.

<?php

// ---------------------------------------- app/Relatorios/RelatorioDeVendas.php
declare(strict_types=1);

namespace App\Relatorios;

use App\Dominio\Dinheiro;
use App\Dominio\StatusDoPedido;
use Carbon\CarbonImmutable;
use Carbon\CarbonPeriod;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class RelatorioDeVendas
{
    /**
     * Vendas por dia. Uma consulta agregada — nada de trazer 40 mil
     * pedidos para somar em PHP.
     *
     * @return Collection<int, array{data: string, pedidos: int, total: Dinheiro, ticket: Dinheiro}>
     */
    public function porDia(CarbonImmutable $de, CarbonImmutable $ate): Collection
    {
        $linhas = DB::table('pedidos')
            // DATE() no PostgreSQL; no MySQL o nome é o mesmo, mas a
            // função de fuso não — atenção ao migrar a consulta.
            ->selectRaw('DATE(created_at) AS dia')
            ->selectRaw('COUNT(*) AS pedidos')
            ->selectRaw('SUM(total_centavos) AS total_centavos')
            ->whereBetween('created_at', [$de->startOfDay(), $ate->endOfDay()])
            // Só o que virou receita: rascunho e cancelado não são venda.
            ->whereIn('status', [
                StatusDoPedido::Pago->value,
                StatusDoPedido::EmSeparacao->value,
                StatusDoPedido::Enviado->value,
                StatusDoPedido::Entregue->value,
            ])
            ->whereNull('deleted_at')
            ->groupByRaw('DATE(created_at)')
            ->orderByRaw('DATE(created_at)')
            ->get()
            ->keyBy('dia');

        // Preencher os dias SEM venda com zero. Sem isso, o gráfico de
        // linha liga 03/03 direto em 07/03 e mente sobre a inclinação —
        // um fim de semana parado vira uma reta suave.
        return collect(CarbonPeriod::create($de, '1 day', $ate))
            ->map(function (CarbonImmutable $dia) use ($linhas): array {
                $chave = $dia->format('Y-m-d');
                $linha = $linhas->get($chave);

                $pedidos = (int) ($linha->pedidos ?? 0);
                $total   = new Dinheiro((int) ($linha->total_centavos ?? 0));

                return [
                    'data'    => $chave,
                    'pedidos' => $pedidos,
                    'total'   => $total,
                    'ticket'  => $pedidos > 0
                        ? new Dinheiro(intdiv($total->centavos, $pedidos))
                        : Dinheiro::zero(),
                ];
            })
            ->values();
    }

    /** @return array{pedidos: int, total: Dinheiro, ticket: Dinheiro, itens: int} */
    public function resumo(CarbonImmutable $de, CarbonImmutable $ate): array
    {
        $linha = DB::table('pedidos')
            ->selectRaw('COUNT(*) AS pedidos, COALESCE(SUM(total_centavos), 0) AS total')
            ->whereBetween('created_at', [$de->startOfDay(), $ate->endOfDay()])
            ->whereIn('status', ['pago', 'em_separacao', 'enviado', 'entregue'])
            ->whereNull('deleted_at')
            ->first();

        $itens = DB::table('itens_pedido')
            ->join('pedidos', 'pedidos.id', '=', 'itens_pedido.pedido_id')
            ->whereBetween('pedidos.created_at', [$de->startOfDay(), $ate->endOfDay()])
            ->whereIn('pedidos.status', ['pago', 'em_separacao', 'enviado', 'entregue'])
            ->sum('itens_pedido.quantidade');

        $pedidos = (int) $linha->pedidos;
        $total   = new Dinheiro((int) $linha->total);

        return [
            'pedidos' => $pedidos,
            'total'   => $total,
            'ticket'  => $pedidos > 0 ? new Dinheiro(intdiv($total->centavos, $pedidos)) : Dinheiro::zero(),
            'itens'   => (int) $itens,
        ];
    }

    /** @return Collection<int, object> */
    public function maisVendidos(CarbonImmutable $de, CarbonImmutable $ate, int $limite = 10): Collection
    {
        return DB::table('itens_pedido')
            ->join('pedidos', 'pedidos.id', '=', 'itens_pedido.pedido_id')
            ->selectRaw('itens_pedido.produto_nome')
            ->selectRaw('SUM(itens_pedido.quantidade) AS unidades')
            ->selectRaw('SUM(itens_pedido.subtotal_centavos) AS receita_centavos')
            ->whereBetween('pedidos.created_at', [$de->startOfDay(), $ate->endOfDay()])
            ->whereIn('pedidos.status', ['pago', 'em_separacao', 'enviado', 'entregue'])
            ->groupBy('itens_pedido.produto_nome')
            ->orderByDesc('unidades')
            ->limit($limite)
            ->get();
    }
}

// ---------------------------------------- app/Http/Controllers/Admin/RelatorioController.php
declare(strict_types=1);

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Relatorios\RelatorioDeVendas;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;

class RelatorioController extends Controller
{
    public function __construct(private readonly RelatorioDeVendas $relatorio) {}

    public function vendas(Request $request): View
    {
        $request->validate([
            'de'  => ['nullable', 'date'],
            'ate' => ['nullable', 'date', 'after_or_equal:de'],
        ]);

        $de  = CarbonImmutable::parse($request->input('de', now()->subDays(29)->toDateString()));
        $ate = CarbonImmutable::parse($request->input('ate', now()->toDateString()));

        // Teto no período: um relatório de 10 anos por dia são 3.650
        // pontos, e o navegador engasga antes do banco.
        if ($de->diffInDays($ate) > 366) {
            $de = $ate->subYear();
        }

        $porDia = $this->relatorio->porDia($de, $ate);

        return view('admin.relatorios.vendas', [
            'de'     => $de,
            'ate'    => $ate,
            'porDia' => $porDia,
            'resumo' => $this->relatorio->resumo($de, $ate),
            'top'    => $this->relatorio->maisVendidos($de, $ate),

            // Os dados do gráfico já em JSON, prontos para o Blade. Montar
            // isto na view exigiria lógica dentro do template.
            'grafico' => [
                'rotulos' => $porDia->map(fn (array $d): string
                    => CarbonImmutable::parse($d['data'])->format('d/m'))->all(),
                'receita' => $porDia->map(fn (array $d): float => $d['total']->emReais())->all(),
                'pedidos' => $porDia->pluck('pedidos')->all(),
            ],
        ]);
    }

    /** Exportação em CSV — o pedido que sempre vem depois do gráfico. */
    public function exportar(Request $request): StreamedResponse
    {
        $de  = CarbonImmutable::parse($request->input('de', now()->subDays(29)->toDateString()));
        $ate = CarbonImmutable::parse($request->input('ate', now()->toDateString()));

        $linhas = $this->relatorio->porDia($de, $ate);

        return response()->streamDownload(function () use ($linhas): void {
            $saida = fopen('php://output', 'w');

            // BOM para o Excel entender UTF-8 — sem ele, "São Paulo" vira
            // "São Paulo" na planilha do cliente.
            fwrite($saida, "\xEF\xBB\xBF");

            fputcsv($saida, ['Data', 'Pedidos', 'Receita', 'Ticket médio'], ';');

            foreach ($linhas as $linha) {
                fputcsv($saida, [
                    $linha['data'],
                    $linha['pedidos'],
                    number_format($linha['total']->emReais(), 2, ',', ''),
                    number_format($linha['ticket']->emReais(), 2, ',', ''),
                ], ';');
            }

            fclose($saida);
        }, "vendas-{$de->format('Y-m-d')}-a-{$ate->format('Y-m-d')}.csv", [
            'Content-Type' => 'text/csv; charset=UTF-8',
        ]);
    }
}

// ---------------------------------------- resources/views/admin/relatorios/vendas.blade.php
// @extends('layouts.admin')
//
// @section('titulo', 'Relatório de vendas')
//
// @push('scripts')
//     <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
// @endpush
//
// @section('conteudo')
//     <h1>Vendas</h1>
//
//     <form method="get" class="filtros">
//         <input type="date" name="de" value="{{ $de->format('Y-m-d') }}">
//         <input type="date" name="ate" value="{{ $ate->format('Y-m-d') }}">
//         <button type="submit">Filtrar</button>
//
//         <a href="{{ route('admin.relatorios.exportar', request()->query()) }}">
//             Exportar CSV
//         </a>
//     </form>
//
//     <div class="indicadores">
//         <div><span>Pedidos</span><strong>{{ $resumo['pedidos'] }}</strong></div>
//         <div><span>Receita</span><strong>{{ $resumo['total']->formatado() }}</strong></div>
//         <div><span>Ticket médio</span><strong>{{ $resumo['ticket']->formatado() }}</strong></div>
//         <div><span>Itens vendidos</span><strong>{{ $resumo['itens'] }}</strong></div>
//     </div>
//
//     <canvas id="grafico-vendas" height="90"></canvas>
//
//     <table>
//         <thead>
//             <tr><th>Data</th><th>Pedidos</th><th>Receita</th><th>Ticket médio</th></tr>
//         </thead>
//         <tbody>
//             @foreach ($porDia as $linha)
//                 {{-- Dia sem venda fica esmaecido, mas continua na tabela:
//                      a ausência é informação. --}}
//                 <tr @class(['sem-venda' => $linha['pedidos'] === 0])>
//                     <td>{{ \Carbon\CarbonImmutable::parse($linha['data'])->format('d/m/Y') }}</td>
//                     <td>{{ $linha['pedidos'] }}</td>
//                     <td>{{ $linha['total']->formatado() }}</td>
//                     <td>{{ $linha['ticket']->formatado() }}</td>
//                 </tr>
//             @endforeach
//         </tbody>
//     </table>
//
//     @push('scripts')
//     <script>
//         // @json() escapa corretamente para dentro do <script> — bem mais
//         // seguro que interpolar {{ }} num contexto JavaScript, onde as
//         // regras de escape do HTML não valem.
//         const dados = @json($grafico);
//
//         new Chart(document.getElementById('grafico-vendas'), {
//             type: 'line',
//             data: {
//                 labels: dados.rotulos,
//                 datasets: [
//                     {
//                         label: 'Receita (R$)',
//                         data: dados.receita,
//                         borderColor: '#2563eb',
//                         backgroundColor: 'rgba(37, 99, 235, .1)',
//                         fill: true,
//                         tension: .3,
//                         yAxisID: 'y',
//                     },
//                     {
//                         label: 'Pedidos',
//                         data: dados.pedidos,
//                         borderColor: '#16a34a',
//                         tension: .3,
//                         // Segundo eixo: receita em milhares e pedidos em
//                         // dezenas no mesmo eixo achatariam a linha menor
//                         // contra o zero.
//                         yAxisID: 'y1',
//                     },
//                 ],
//             },
//             options: {
//                 responsive: true,
//                 interaction: { mode: 'index', intersect: false },
//                 scales: {
//                     y:  { position: 'left',  beginAtZero: true,
//                           ticks: { callback: (v) => 'R$ ' + v.toLocaleString('pt-BR') } },
//                     y1: { position: 'right', beginAtZero: true,
//                           grid: { drawOnChartArea: false } },
//                 },
//                 plugins: {
//                     tooltip: {
//                         callbacks: {
//                             label: (ctx) => ctx.datasetIndex === 0
//                                 ? 'Receita: ' + ctx.parsed.y.toLocaleString('pt-BR',
//                                     { style: 'currency', currency: 'BRL' })
//                                 : 'Pedidos: ' + ctx.parsed.y,
//                         },
//                     },
//                 },
//             },
//         });
//     </script>
//     @endpush
// @endsection

// ---------------------------------------- tests/Feature/Admin/RelatorioTest.php  (Pest)
declare(strict_types=1);

use App\Dominio\StatusDoPedido;
use App\Models\Pedido;
use App\Models\Usuario;
use App\Relatorios\RelatorioDeVendas;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('agrupa as vendas por dia', function (): void {
    Pedido::factory()->count(3)->create([
        'status'         => StatusDoPedido::Pago,
        'total_centavos' => 10000,
        'created_at'     => '2026-03-10 10:00:00',
    ]);

    Pedido::factory()->create([
        'status'         => StatusDoPedido::Pago,
        'total_centavos' => 5000,
        'created_at'     => '2026-03-11 10:00:00',
    ]);

    $linhas = app(RelatorioDeVendas::class)->porDia(
        CarbonImmutable::parse('2026-03-10'),
        CarbonImmutable::parse('2026-03-11'),
    );

    expect($linhas)->toHaveCount(2)
        ->and($linhas[0]['pedidos'])->toBe(3)
        ->and($linhas[0]['total']->centavos)->toBe(30000)
        ->and($linhas[0]['ticket']->centavos)->toBe(10000);
});

it('preenche com zero os dias sem venda', function (): void {
    Pedido::factory()->create([
        'status'     => StatusDoPedido::Pago,
        'created_at' => '2026-03-10 10:00:00',
    ]);

    $linhas = app(RelatorioDeVendas::class)->porDia(
        CarbonImmutable::parse('2026-03-08'),
        CarbonImmutable::parse('2026-03-12'),
    );

    // Cinco dias, não um. Sem o preenchimento, o gráfico de linha ligaria
    // 08/03 direto em 10/03 e mentiria sobre a inclinação.
    expect($linhas)->toHaveCount(5)
        ->and($linhas[0]['pedidos'])->toBe(0)
        ->and($linhas[0]['total']->centavos)->toBe(0);
});

it('ignora pedidos cancelados', function (): void {
    Pedido::factory()->create([
        'status'         => StatusDoPedido::Cancelado,
        'total_centavos' => 99999,
        'created_at'     => '2026-03-10 10:00:00',
    ]);

    $resumo = app(RelatorioDeVendas::class)->resumo(
        CarbonImmutable::parse('2026-03-10'),
        CarbonImmutable::parse('2026-03-10'),
    );

    expect($resumo['pedidos'])->toBe(0)
        ->and($resumo['total']->centavos)->toBe(0);
});

it('limita o período a um ano', function (): void {
    $this->actingAs(tap(Usuario::factory()->create())->assignRole('admin'));

    $this->get(route('admin.relatorios.vendas', ['de' => '2010-01-01', 'ate' => '2026-01-01']))
        ->assertOk();
});

it('exporta em CSV', function (): void {
    $this->actingAs(tap(Usuario::factory()->create())->assignRole('admin'));

    Pedido::factory()->create(['status' => StatusDoPedido::Pago, 'created_at' => now()]);

    $this->get(route('admin.relatorios.exportar'))
        ->assertOk()
        ->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
});
Comentários

Mais em PHP

Tratamento de Erros e Exceções
Tratamento de Erros e Exceções

O PHP tem dois sistemas para lidar com o que dá errado: os níveis de erro…

Segurança em Formulários: XSS, CSRF e SQL Injection
Segurança em Formulários: XSS, CSRF e SQL Injection

Como cada um dos três ataques funciona por dentro e por que as defesas acabam…

Blade — Templates no Laravel
Blade — Templates no Laravel

Sintaxe, herança de layouts, componentes com slots e formulários com erros de…