<?php
namespace App\Services;
use App\Models\CashRegisterOpening;
use App\Models\Discount;
use App\Models\Cancellation;
use App\Models\OrderTicketLine;
use App\Models\Payment;
use App\Models\Reservation;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class RestauranteroProService
{
private string $baseUrl;
private string $apiKey;
private string $branchIdentifier;
private string $posName;
private string $posVariant;
private string $posVersion;
private int $timeout;
private int $maxRetries;
public function __construct()
{
$this->baseUrl = config('restaurantero.base_url');
$this->apiKey = config('restaurantero.api_key');
$this->branchIdentifier = config('restaurantero.branch_identifier');
$this->posName = config('restaurantero.pos_name');
$this->posVariant = config('restaurantero.pos_variant');
$this->posVersion = config('restaurantero.pos_version');
$this->timeout = config('restaurantero.timeout', 15);
$this->maxRetries = config('restaurantero.max_retries', 3);
}
/**
* Verifica si la integración está habilitada y configurada.
*/
public static function isEnabled(): bool
{
return config('restaurantero.enabled', false)
&& ! empty(config('restaurantero.api_key'))
&& ! empty(config('restaurantero.branch_identifier'));
}
/**
* Punto de entrada principal.
* Llama a este método cuando se completa un Corte Z.
* Envía todos los datos del día a Restaurantero Pro en el orden correcto.
*/
public function sendZOut(CashRegisterOpening $closing): void
{
if (! self::isEnabled()) {
return;
}
$closingId = $closing->identifier;
$date = $closing->start_at->toDateString();
Log::info('RestauranteroPro: iniciando envío de Corte Z', [
'closing_id' => $closingId,
'date' => $date,
]);
try {
// 1. Líneas de orden (primero — para que RP tenga el product mix)
$this->sendOrders($closing, $date, $closingId);
// 2. Descuentos
$this->sendDiscounts($closing, $date, $closingId);
// 3. Cancelaciones
$this->sendCancellations($closing, $date, $closingId);
// 4. Pagos por método
$this->sendPayments($closing, $date, $closingId);
// 5. Resumen de ventas
$this->sendSales($closing, $date, $closingId);
// 6. El Corte Z — este dispara el procesamiento en Restaurantero Pro
$this->sendCashRegister($closing, $date, $closingId);
Log::info('RestauranteroPro: Corte Z enviado correctamente', [
'closing_id' => $closingId,
]);
} catch (\Exception $e) {
// NUNCA dejar que un error de RP afecte al POS
Log::error('RestauranteroPro: error al enviar Corte Z', [
'closing_id' => $closingId,
'error' => $e->getMessage(),
]);
}
}
// ─── BUILDERS ─────────────────────────────────────────────────────────────
private function sendCashRegister(CashRegisterOpening $closing, string $date, string $closingId): void
{
// Construir summaries desde los summaries del CashRegisterOpening
$summaries = $closing->summaries->map(fn($s) => [
'type' => $this->mapSummaryType($s->type ?? $s->name ?? 'other'),
'name' => $s->name ?? 'Concepto',
'amount_cents' => (int) round(($s->amount ?? 0) * 100),
])->toArray();
// Calcular cash_variance desde los metadatos de caché si están disponibles
$meta = cache()->get('cash_register_opening_meta', []);
$payload = [
'data' => [
'type' => 'cash-register',
'attributes' => [
'branch_identifier' => $this->branchIdentifier,
'closing_type' => 'zout',
'start_at' => $closing->start_at->toIso8601String(),
'end_at' => $closing->end_at?->toIso8601String() ?? now()->toIso8601String(),
'status' => 'completed',
'summaries' => $summaries,
],
'meta' => [
'pos_name' => $this->posName,
'pos_version' => $this->posVersion,
'pos_variant' => $this->posVariant,
'sent_at' => now()->toIso8601String(),
'cashier_name'=> $closing->user?->name,
],
],
];
$this->post('/ingest/cash-register', $payload, $closingId);
}
private function sendSales(CashRegisterOpening $closing, string $date, string $closingId): void
{
// Obtener las reservaciones cerradas del día para calcular ventas
$openableIds = $closing->openables()
->where('openable_type', Reservation::class)
->pluck('openable_id');
$reservations = Reservation::whereIn('id', $openableIds)->get();
$totalSales = $reservations->sum('subtotal'); // en pesos
$covers = $reservations->sum(fn($r) => $r->orders()->count() > 0 ? 1 : 0);
// Si no hay reservaciones en openables, intentar calcular desde summaries
if ($totalSales == 0) {
$summaryMap = $closing->summaries->keyBy(fn($s) => strtolower($s->type ?? $s->name ?? ''));
$cashSales = $summaryMap->first(fn($s) => str_contains(strtolower($s->name ?? ''), 'efectivo'))?->amount ?? 0;
$cardSales = $summaryMap->first(fn($s) => str_contains(strtolower($s->name ?? ''), 'tarjeta'))?->amount ?? 0;
$totalSales = $cashSales + $cardSales;
}
$totalSalesCents = (int) round($totalSales * 100);
$foodSalesCents = (int) round($totalSalesCents * 0.686);
$bevSalesCents = $totalSalesCents - $foodSalesCents;
$payload = [
'data' => [
'type' => 'sales',
'attributes' => [
'branch_identifier' => $this->branchIdentifier,
'date' => $date,
'shift' => 'all_day',
'closing_type' => 'zout',
'total_sales_cents' => $totalSalesCents,
'total_sales_no_iva_cents' => (int) round($totalSalesCents / 1.16),
'food_sales_cents' => $foodSalesCents,
'beverage_sales_cents' => $bevSalesCents,
'covers' => max($covers, $reservations->count()),
'tables_served' => $reservations->count(),
],
'meta' => [
'pos_name' => $this->posName,
'pos_version' => $this->posVersion,
'sent_at' => now()->toIso8601String(),
],
],
];
$this->post('/ingest/sales', $payload, $closingId . '-sales');
}
private function sendPayments(CashRegisterOpening $closing, string $date, string $closingId): void
{
// Obtener pagos de las reservaciones del día
$openableIds = $closing->openables()
->where('openable_type', Reservation::class)
->pluck('openable_id');
$payments = Payment::query()
->whereHasMorph('paymentable', Reservation::class, fn($q) => $q->whereIn('id', $openableIds))
->whereIn('status', ['active', 'paid'])
->with('type')
->get()
->groupBy(fn($p) => $p->type?->name ?? 'other');
if ($payments->isEmpty()) {
// Fallback: construir pagos desde summaries del corte
$paymentsArray = $closing->summaries->map(fn($s) => [
'payment_type' => $this->mapSummaryType($s->type ?? $s->name ?? 'other'),
'payment_type_name' => $s->name ?? 'Otro',
'amount_cents' => (int) round(($s->amount ?? 0) * 100),
'tips_cents' => 0,
'change_cents' => 0,
'transaction_count' => 1,
])->toArray();
} else {
$paymentsArray = $payments->map(fn($group, $typeName) => [
'payment_type' => $this->mapPaymentType($typeName),
'payment_type_name' => $typeName,
'amount_cents' => (int) round($group->sum('amount') * 100),
'tips_cents' => 0,
'change_cents' => (int) round($group->sum('change') * 100),
'transaction_count' => $group->count(),
])->values()->toArray();
}
if (empty($paymentsArray)) return;
$payload = [
'data' => [
'type' => 'payments',
'attributes' => [
'branch_identifier' => $this->branchIdentifier,
'date' => $date,
'closing_type' => 'zout',
'payments' => $paymentsArray,
],
'meta' => ['pos_name' => $this->posName, 'sent_at' => now()->toIso8601String()],
],
];
$this->post('/ingest/payments', $payload, $closingId . '-payments');
}
private function sendOrders(CashRegisterOpening $closing, string $date, string $closingId): void
{
// Obtener líneas de orden de las reservaciones del día
$openableIds = $closing->openables()
->where('openable_type', Reservation::class)
->pluck('openable_id');
$lines = OrderTicketLine::query()
->whereHas('ticket.order', fn($q) =>
$q->whereHasMorph('orderable', Reservation::class, fn($r) => $r->whereIn('id', $openableIds))
)
->whereIn('status', ['closed', 'to_pay', 'sent', 'open'])
->with(['ticket.order'])
->get()
->map(fn($line) => [
'item_identifier' => null,
'item_sku' => $line->identification ?? null,
'item_name' => (string) $line->description,
'category' => null,
'category_type' => 'food',
'quantity' => (int) round($line->quantity), // mutator already divides by 100
'unit_price_cents' => (int) round($line->unit_price * 100), // mutator divides by 100, we multiply back
'subtotal_cents' => (int) round($line->subtotal * 100),
'discount_cents' => (int) round($line->discount * 100),
'net_total_cents' => (int) round($line->netSubtotal * 100),
])->toArray();
if (empty($lines)) return;
$payload = [
'data' => [
'type' => 'orders',
'attributes' => [
'branch_identifier' => $this->branchIdentifier,
'date' => $date,
'closing_type' => 'zout',
'lines' => $lines,
],
'meta' => ['pos_name' => $this->posName, 'sent_at' => now()->toIso8601String()],
],
];
$this->post('/ingest/orders', $payload, $closingId . '-orders');
}
private function sendDiscounts(CashRegisterOpening $closing, string $date, string $closingId): void
{
$openableIds = $closing->openables()
->where('openable_type', Reservation::class)
->pluck('openable_id');
$discounts = Discount::query()
->whereHasMorph('discountable', Reservation::class, fn($q) => $q->whereIn('id', $openableIds))
->whereIn('status', ['active'])
->with('user')
->get()
->map(fn($d) => [
'discount_identifier' => $d->identifier,
'type' => $d->type ?? 'fixed',
'factor_type' => $d->factor_type ?? 'fixed',
'name' => $d->name ?? 'Descuento',
'rate' => (float) $d->rate_or_fee,
'amount_cents' => (int) round($d->rate_or_fee * 100),
'applied_by' => $d->user?->name,
'authorized_by' => $d->user?->name,
'reason' => $d->description ?? null,
])->toArray();
if (empty($discounts)) return;
$payload = [
'data' => [
'type' => 'discounts',
'attributes' => [
'branch_identifier' => $this->branchIdentifier,
'date' => $date,
'closing_type' => 'zout',
'discounts' => $discounts,
],
'meta' => ['pos_name' => $this->posName, 'sent_at' => now()->toIso8601String()],
],
];
$this->post('/ingest/discounts', $payload, $closingId . '-discounts');
}
private function sendCancellations(CashRegisterOpening $closing, string $date, string $closingId): void
{
$openableIds = $closing->openables()
->where('openable_type', Reservation::class)
->pluck('openable_id');
$cancellations = Cancellation::query()
->whereHasMorph('cancellationable', OrderTicketLine::class, fn($q) =>
$q->whereHas('ticket.order', fn($r) =>
$r->whereHasMorph('orderable', Reservation::class, fn($s) => $s->whereIn('id', $openableIds))
)
)
->with(['cancelledBy', 'authorizedBy', 'cancellationable'])
->get()
->map(fn($c) => [
'cancellation_identifier' => $c->identifier,
'item_name' => (string) ($c->cancellationable?->description ?? 'Producto cancelado'),
'item_sku' => $c->cancellationable?->identification ?? null,
'quantity' => 1,
'amount_cents' => (int) round(($c->cancellationable?->subtotal ?? 0) * 100),
'reason' => $c->reason ?? null,
'cancelled_by' => $c->cancelledBy?->name,
'authorized_by' => $c->authorizedBy?->name,
'cancelled_at' => $c->created_at->toIso8601String(),
])->toArray();
if (empty($cancellations)) return;
$payload = [
'data' => [
'type' => 'cancellations',
'attributes' => [
'branch_identifier' => $this->branchIdentifier,
'date' => $date,
'closing_type' => 'zout',
'cancellations' => $cancellations,
],
'meta' => ['pos_name' => $this->posName, 'sent_at' => now()->toIso8601String()],
],
];
$this->post('/ingest/cancellations', $payload, $closingId . '-cancellations');
}
// ─── MAPPERS ──────────────────────────────────────────────────────────────
/**
* Mapea el nombre del summary del corte RC al tipo de Restaurantero Pro.
*/
private function mapSummaryType(string $type): string
{
$type = strtolower($type);
return match(true) {
str_contains($type, 'efectivo') || str_contains($type, 'cash') => 'cash_sales',
str_contains($type, 'tarjeta') || str_contains($type, 'card') => 'card_sales',
str_contains($type, 'credito') || str_contains($type, 'credit') => 'card_credit_sales',
str_contains($type, 'debito') || str_contains($type, 'debit') => 'card_debit_sales',
str_contains($type, 'transferencia') || str_contains($type, 'transfer') => 'transfer_sales',
str_contains($type, 'propina') && str_contains($type, 'pagada') => 'tips_paid_to_waiter',
str_contains($type, 'propina') || str_contains($type, 'tip') => 'tips_received',
str_contains($type, 'retencion') || str_contains($type, 'moche') => 'withholding',
str_contains($type, 'deposito') || str_contains($type, 'deposit') => 'deposits',
str_contains($type, 'diferencia') || str_contains($type, 'faltante')
|| str_contains($type, 'sobrante') || str_contains($type, 'variance') => 'cash_variance',
default => 'other',
};
}
/**
* Mapea el nombre del tipo de pago de RC al tipo de Restaurantero Pro.
*/
private function mapPaymentType(string $typeName): string
{
$type = strtolower($typeName);
return match(true) {
str_contains($type, 'efectivo') || str_contains($type, 'cash') => 'cash',
str_contains($type, 'credito') || str_contains($type, 'credit') => 'card_credit',
str_contains($type, 'debito') || str_contains($type, 'debit') => 'card_debit',
str_contains($type, 'tarjeta') || str_contains($type, 'card') => 'card',
str_contains($type, 'transfer') => 'transfer',
str_contains($type, 'rappi') => 'rappi',
str_contains($type, 'uber') => 'uber_eats',
str_contains($type, 'didi') => 'didi_food',
str_contains($type, 'vale') => 'voucher',
default => 'other',
};
}
// ─── HTTP HELPER ──────────────────────────────────────────────────────────
/**
* Envía un POST a Restaurantero Pro con reintentos automáticos.
* NUNCA lanza excepciones — solo loggea los errores.
*/
private function post(string $endpoint, array $payload, string $idempotencyKey): void
{
$attempts = 0;
$delays = [0, 5, 30]; // segundos entre reintentos
while ($attempts < $this->maxRetries) {
try {
$response = Http::withHeaders([
'X-API-Key' => $this->apiKey,
'X-Idempotency-Key' => $idempotencyKey,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
])->timeout($this->timeout)->post($this->baseUrl . $endpoint, $payload);
if ($response->successful()) {
Log::info("RestauranteroPro: {$endpoint} enviado", [
'idempotency_key' => $idempotencyKey,
'status' => $response->status(),
]);
return;
}
// 409 = ya procesado (idempotencia) — no es un error
if ($response->status() === 409) {
Log::info("RestauranteroPro: {$endpoint} ya procesado (idempotencia)", [
'idempotency_key' => $idempotencyKey,
]);
return;
}
Log::warning("RestauranteroPro: {$endpoint} HTTP {$response->status()}", [
'idempotency_key' => $idempotencyKey,
'response' => $response->json(),
]);
} catch (\Exception $e) {
Log::error("RestauranteroPro: {$endpoint} excepción (intento " . ($attempts + 1) . ")", [
'error' => $e->getMessage(),
'idempotency_key' => $idempotencyKey,
]);
}
$attempts++;
if ($attempts < $this->maxRetries && isset($delays[$attempts])) {
sleep($delays[$attempts]);
}
}
Log::critical("RestauranteroPro: {$endpoint} falló {$this->maxRetries} intentos", [
'idempotency_key' => $idempotencyKey,
]);
}
}