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
- Laravel Queues (Jobs): https://laravel.com/docs/queues
- Laravel Mail: https://laravel.com/docs/mail
- Laravel Transactions: https://laravel.com/docs/database#database-transactions
- Eloquent Scopes: https://laravel.com/docs/eloquent#query-scopes
- Laravel — testes HTTP, para cobrir o fluxo completo da rota ate a resposta: https://laravel.com/docs/http-tests
- Laravel — agendamento de tarefas, com a unica entrada de cron que o projeto precisa: https://laravel.com/docs/scheduling
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');
});