Artigo 51 — Projeto Final Parte 2: Implementação
Módulo 11 · Semana 51 · Nível: Avançado
Introdução
Com o planejamento sólido do artigo anterior, 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;
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));
static::updating(fn(self $p) => $p->atualizado_em = now());
}
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
{
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()}."
);
}
$this->update([
'status' => $novoStatus,
'atualizado_em' => now(),
]);
}
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); }
}
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 criarDesdoCarrinho(
Carrinho $carrinho,
User $usuario,
Endereco $endereco,
?string $observacoes = null,
): Pedido {
if ($carrinho->estaVazio()) {
throw new \DomainException('O carrinho está vazio.');
}
// Valida estoque de todos os itens antes de começar a transação
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,
]);
// Cria os itens e decrementa o estoque
foreach ($carrinho->itens() as $item) {
$produto = Produto::find($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(),
]);
// Decrementa o estoque de forma atômica
$produto->decrement('estoque', $item->quantidade());
}
// Dispara o job de processamento assíncrono
ProcessarPedido::dispatch($pedido->id);
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 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}%")
);
}
$query->orderBy(match($request->get('ordenar', 'relevancia')) {
'preco_asc' => 'preco',
'preco_desc' => 'preco',
'nome' => 'nome',
default => 'criado_em',
}, $request->get('ordenar') === 'preco_asc' ? 'asc' : 'desc');
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;
public int $tries = 3;
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) {
$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');
});
Exercício da semana
-
Implemente
PedidoService::criarDesdoCarrinho()completo com todos os casos de erro — carrinho vazio, produto indisponível, estoque insuficiente. Escreva testes de feature que cobrem cada caso. -
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.
-
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.
-
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.
-
Desafio: implemente um relatório de vendas em
/admin/relatorios/vendascom 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.
Referências
- 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