Parcourir la source

Chiusura servizio: report PDF, preview web, UI e metriche bilancio/staff

marcofalabretti il y a 1 semaine
Parent
révision
7fca2ff696

+ 4
- 1
app/DataTables/ChiusuraServizioDataTable.php Voir le fichier

@@ -29,6 +29,9 @@ class ChiusuraServizioDataTable extends DataTable
29 29
             ->addColumn('action', function($query){
30 30
                 return view('chiusura_servizio.menu', ['entity' => $query]);
31 31
             })
32
+            ->addColumn('chiusura_at_display', function($query){
33
+                return $query->chiusura_at->format('d/m/Y H:i');
34
+            })
32 35
             ->addColumn('user_id_display', function($query){
33 36
                 if($query->user->id == Auth::user()->id){
34 37
                     return view('_partials.badge', ['success' => 'Tu']);
@@ -105,7 +108,7 @@ class ChiusuraServizioDataTable extends DataTable
105 108
     public function getColumns(): array
106 109
     {
107 110
         return [
108
-            Column::make('chiusura_at')->title('Data e ora della chiusura')->addClass('text-center')->responsivePriority(1),
111
+            Column::make('chiusura_at_display')->title('Data e ora della chiusura')->addClass('text-center')->responsivePriority(1),
109 112
             Column::make('note')->title('Note')->responsivePriority(2),
110 113
             Column::make('user_id')->title('Utente')->data('user_id_display')->addClass('text-center')->responsivePriority(1),
111 114
             Column::make('info')->title('Snapshot')->data('info_display')->addClass('text-center')->responsivePriority(1),

+ 375
- 1
app/Http/Controllers/ChiusuraServizioController.php Voir le fichier

@@ -5,6 +5,8 @@ namespace App\Http\Controllers;
5 5
 use Illuminate\Http\Request;
6 6
 use App\Models\Attivita;
7 7
 use App\Models\ChiusuraServizio;
8
+use App\Models\SaltacodaOrdine;
9
+use Barryvdh\DomPDF\Facade\Pdf;
8 10
 use App\DataTables\ChiusuraServizioDataTable;
9 11
 use App\DataTables\ChiusuraServizioDataTableEditor;
10 12
 use App\Services\Bilancio\BilancioServizioService;
@@ -12,6 +14,7 @@ use Carbon\Carbon;
12 14
 use Illuminate\Routing\Controllers\Middleware;
13 15
 use Illuminate\Support\Facades\Auth;
14 16
 use Illuminate\Support\Facades\Session;
17
+use Illuminate\Support\Facades\Log;
15 18
 use App\Services\Attivita\AttivitaService;
16 19
 
17 20
 class ChiusuraServizioController extends Controller
@@ -27,7 +30,7 @@ class ChiusuraServizioController extends Controller
27 30
   public static function middleware(): array
28 31
   {
29 32
     return [
30
-      new Middleware('permission:view-chiusura-servizio', only: ['index', 'show']),
33
+      new Middleware('permission:view-chiusura-servizio', only: ['index', 'show', 'exportPdf']),
31 34
       new Middleware('permission:create-chiusura-servizio', only: ['chiudi_servizio']),
32 35
       new Middleware('permission:create-chiusura-servizio|edit-chiusura-servizio|delete-chiusura-servizio', only: ['store', 'update', 'destroy']),
33 36
     ];
@@ -75,6 +78,23 @@ class ChiusuraServizioController extends Controller
75 78
       ? Carbon::parse($snapshot['periodo']['a'])
76 79
       : $chiusura->chiusura_at;
77 80
 
81
+    // Backfill lazy dei coperti: mantiene gli snapshot storici compatibili.
82
+    // La metrica usa forPeriod() che passa da scope perIncasso (pagamenti STAFF esclusi).
83
+    if (!array_key_exists('coperti', $snapshot) && $periodoDa && $periodoA) {
84
+      try {
85
+        $report = $bilancioServizio->forPeriod((int) $chiusura->attivita_id, $periodoDa, $periodoA);
86
+        $snapshot['coperti'] = (int) ($report['totCoperti'] ?? 0);
87
+        $chiusura->info = $snapshot;
88
+        $chiusura->save();
89
+      } catch (\Throwable $e) {
90
+        Log::warning('Impossibile backfill coperti su chiusura servizio', [
91
+          'chiusura_servizio_id' => $chiusura->id,
92
+          'attivita_id' => $chiusura->attivita_id,
93
+          'errore' => $e->getMessage(),
94
+        ]);
95
+      }
96
+    }
97
+
78 98
     $heatmap = $bilancioServizio->heatmapFromSnapshot(
79 99
       (int) $chiusura->attivita_id,
80 100
       $snapshot,
@@ -82,6 +102,120 @@ class ChiusuraServizioController extends Controller
82 102
       $periodoA
83 103
     );
84 104
 
105
+    $staffSummary = collect();
106
+    $staffTotale = 0.0;
107
+    $staffTransazioni = 0;
108
+    $staffCoperti = 0;
109
+    $staffTotPiattiOrdinati = 0;
110
+    $staffCucineRows = collect();
111
+    $staffHeatmapHourLabels = collect();
112
+    $saltacodaFunnel = [
113
+      'totale' => 0,
114
+      'pre_ordine' => 0,
115
+      'pre_carrello' => 0,
116
+      'pre_promosso' => 0,
117
+      'importato' => 0,
118
+      'tasso_to_carrello' => 0.0,
119
+      'tasso_to_importato' => 0.0,
120
+      'carico_labels' => collect(range(0, 23))->map(fn ($h) => str_pad((string) $h, 2, '0', STR_PAD_LEFT))->values()->all(),
121
+      'carico_creati' => array_fill(0, 24, 0),
122
+      'carico_importati' => array_fill(0, 24, 0),
123
+      'carico_totale_creati' => 0,
124
+      'carico_totale_importati' => 0,
125
+      'tempo_import_medio_min' => 0.0,
126
+      'tempo_import_mediana_min' => 0.0,
127
+      'tempo_import_min_min' => 0,
128
+      'tempo_import_max_min' => 0,
129
+      'tempo_import_sample' => 0,
130
+    ];
131
+
132
+    if ($periodoDa && $periodoA) {
133
+      $saltacodaByStato = SaltacodaOrdine::query()
134
+        ->where('attivita_id', (int) $chiusura->attivita_id)
135
+        ->whereBetween('created_at', [$periodoDa, $periodoA])
136
+        ->selectRaw('stato, COUNT(*) as totale')
137
+        ->groupBy('stato')
138
+        ->pluck('totale', 'stato');
139
+
140
+      $saltacodaTotale = (int) $saltacodaByStato->sum();
141
+      $saltacodaPreOrdine = (int) ($saltacodaByStato[SaltacodaOrdine::PRE_ORDINE] ?? 0);
142
+      $saltacodaPreCarrello = (int) ($saltacodaByStato[SaltacodaOrdine::PRE_CARRELLO] ?? 0);
143
+      $saltacodaPrePromosso = (int) ($saltacodaByStato[SaltacodaOrdine::PRE_PROMOSSO] ?? 0);
144
+      $saltacodaImportato = (int) ($saltacodaByStato[SaltacodaOrdine::IMPORTATO] ?? 0);
145
+      $saltacodaFunnel = [
146
+        'totale' => $saltacodaTotale,
147
+        'pre_ordine' => $saltacodaPreOrdine,
148
+        'pre_carrello' => $saltacodaPreCarrello,
149
+        'pre_promosso' => $saltacodaPrePromosso,
150
+        'importato' => $saltacodaImportato,
151
+        'tasso_to_carrello' => $saltacodaTotale > 0 ? round(($saltacodaPreCarrello / $saltacodaTotale) * 100, 1) : 0.0,
152
+        'tasso_to_importato' => $saltacodaTotale > 0 ? round(($saltacodaImportato / $saltacodaTotale) * 100, 1) : 0.0,
153
+      ];
154
+
155
+      $saltacodaCreatiByHour = SaltacodaOrdine::query()
156
+        ->where('attivita_id', (int) $chiusura->attivita_id)
157
+        ->whereBetween('created_at', [$periodoDa, $periodoA])
158
+        ->selectRaw('HOUR(created_at) as ora, COUNT(*) as totale')
159
+        ->groupBy('ora')
160
+        ->pluck('totale', 'ora');
161
+
162
+      $saltacodaImportatiByHour = SaltacodaOrdine::query()
163
+        ->where('attivita_id', (int) $chiusura->attivita_id)
164
+        ->where('stato', SaltacodaOrdine::IMPORTATO)
165
+        ->whereBetween('updated_at', [$periodoDa, $periodoA])
166
+        ->selectRaw('HOUR(updated_at) as ora, COUNT(*) as totale')
167
+        ->groupBy('ora')
168
+        ->pluck('totale', 'ora');
169
+
170
+      $durateImport = SaltacodaOrdine::query()
171
+        ->where('attivita_id', (int) $chiusura->attivita_id)
172
+        ->where('stato', SaltacodaOrdine::IMPORTATO)
173
+        ->whereBetween('created_at', [$periodoDa, $periodoA])
174
+        ->whereBetween('updated_at', [$periodoDa, $periodoA])
175
+        ->selectRaw('TIMESTAMPDIFF(MINUTE, created_at, updated_at) as minuti')
176
+        ->pluck('minuti')
177
+        ->filter(fn ($minuti) => is_numeric($minuti) && (int) $minuti >= 0)
178
+        ->map(fn ($minuti) => (int) $minuti)
179
+        ->values();
180
+
181
+      $durateArray = $durateImport->all();
182
+
183
+      $saltacodaFunnel['carico_creati'] = collect(range(0, 23))
184
+        ->map(fn ($h) => (int) ($saltacodaCreatiByHour[(string) $h] ?? $saltacodaCreatiByHour[$h] ?? 0))
185
+        ->values()
186
+        ->all();
187
+      $saltacodaFunnel['carico_importati'] = collect(range(0, 23))
188
+        ->map(fn ($h) => (int) ($saltacodaImportatiByHour[(string) $h] ?? $saltacodaImportatiByHour[$h] ?? 0))
189
+        ->values()
190
+        ->all();
191
+      $saltacodaFunnel['carico_totale_creati'] = array_sum($saltacodaFunnel['carico_creati']);
192
+      $saltacodaFunnel['carico_totale_importati'] = array_sum($saltacodaFunnel['carico_importati']);
193
+      $saltacodaFunnel['tempo_import_sample'] = $durateImport->count();
194
+      $saltacodaFunnel['tempo_import_medio_min'] = $durateImport->isNotEmpty() ? round((float) $durateImport->avg(), 1) : 0.0;
195
+      $saltacodaFunnel['tempo_import_mediana_min'] = $durateImport->isNotEmpty() ? $this->median($durateArray) : 0.0;
196
+      $saltacodaFunnel['tempo_import_min_min'] = $durateImport->isNotEmpty() ? (int) $durateImport->min() : 0;
197
+      $saltacodaFunnel['tempo_import_max_min'] = $durateImport->isNotEmpty() ? (int) $durateImport->max() : 0;
198
+
199
+      $staffReport = $bilancioServizio->forPeriodStaff((int) $chiusura->attivita_id, $periodoDa, $periodoA);
200
+      $staffSummary = $staffReport['paymentSummary'] ?? collect();
201
+      $staffTotale = (float) ($staffReport['totaleIncasso'] ?? 0);
202
+      $staffTransazioni = (int) ($staffReport['totaleTransazioni'] ?? 0);
203
+      $staffCoperti = (int) ($staffReport['totCoperti'] ?? 0);
204
+      $staffTotPiattiOrdinati = (int) ($staffReport['totPiattiOrdinati'] ?? 0);
205
+      $staffHeatmapSeries = collect($staffReport['heatmapSeries'] ?? []);
206
+      $staffCucineRows = collect($staffReport['cucineRows'] ?? [])->values()->map(function ($cucina, $index) use ($staffHeatmapSeries) {
207
+        if (!empty($cucina['ordini_per_ora'])) {
208
+          return $cucina;
209
+        }
210
+
211
+        $orario = $staffHeatmapSeries->get($index) ?? $staffHeatmapSeries->firstWhere('name', $cucina['nome'] ?? '');
212
+        $cucina['ordini_per_ora'] = $orario['data'] ?? [];
213
+
214
+        return $cucina;
215
+      });
216
+      $staffHeatmapHourLabels = $staffReport['heatmapHourLabels'] ?? collect();
217
+    }
218
+
85 219
     return view('chiusura_servizio.show', [
86 220
       'chiusura' => $chiusura,
87 221
       'snapshot' => $snapshot,
@@ -92,7 +226,16 @@ class ChiusuraServizioController extends Controller
92 226
       'totaleIncasso' => (float) ($snapshot['incasso'] ?? 0),
93 227
       'totaleTransazioni' => (int) ($snapshot['transazioni'] ?? 0),
94 228
       'totPiattiOrdinati' => (int) ($snapshot['piatti_ordinati'] ?? 0),
229
+      'totCoperti' => (int) ($snapshot['coperti'] ?? 0),
95 230
       'heatmapHourLabels' => $heatmap['heatmapHourLabels'],
231
+      'staffSummary' => $staffSummary,
232
+      'staffTotale' => $staffTotale,
233
+      'staffTransazioni' => $staffTransazioni,
234
+      'staffCoperti' => $staffCoperti,
235
+      'staffTotPiattiOrdinati' => $staffTotPiattiOrdinati,
236
+      'staffCucineRows' => $staffCucineRows,
237
+      'staffHeatmapHourLabels' => $staffHeatmapHourLabels,
238
+      'saltacodaFunnel' => $saltacodaFunnel,
96 239
     ]);
97 240
   }
98 241
 
@@ -122,4 +265,235 @@ class ChiusuraServizioController extends Controller
122 265
 
123 266
     return redirect()->back()->with('success', 'Servizio chiuso con successo');
124 267
   }
268
+
269
+  public function exportPdf(Request $request, BilancioServizioService $bilancioServizio)
270
+  {
271
+    $chiusura = ChiusuraServizio::query()
272
+      ->with(['user', 'attivita'])
273
+      ->where('attivita_id', Session::get('attivita_attuale'))
274
+      ->find($request->get('chiusura_servizio_id'));
275
+
276
+    if (!$chiusura) {
277
+      return redirect()->route('chiusura-servizio.index')->with('error', 'Chiusura non trovata');
278
+    }
279
+
280
+    $snapshot = is_array($chiusura->info) ? $chiusura->info : (json_decode($chiusura->info ?? '[]', true) ?: []);
281
+    $periodoDa = !empty($snapshot['periodo']['da']) ? Carbon::parse($snapshot['periodo']['da']) : null;
282
+    $periodoA = !empty($snapshot['periodo']['a'])
283
+      ? Carbon::parse($snapshot['periodo']['a'])
284
+      : $chiusura->chiusura_at;
285
+
286
+    if (!array_key_exists('coperti', $snapshot) && $periodoDa && $periodoA) {
287
+      try {
288
+        $report = $bilancioServizio->forPeriod((int) $chiusura->attivita_id, $periodoDa, $periodoA);
289
+        $snapshot['coperti'] = (int) ($report['totCoperti'] ?? 0);
290
+        $chiusura->info = $snapshot;
291
+        $chiusura->save();
292
+      } catch (\Throwable $e) {
293
+        Log::warning('Impossibile backfill coperti su export PDF chiusura servizio', [
294
+          'chiusura_servizio_id' => $chiusura->id,
295
+          'attivita_id' => $chiusura->attivita_id,
296
+          'errore' => $e->getMessage(),
297
+        ]);
298
+      }
299
+    }
300
+
301
+    $heatmap = $bilancioServizio->heatmapFromSnapshot(
302
+      (int) $chiusura->attivita_id,
303
+      $snapshot,
304
+      $periodoDa,
305
+      $periodoA
306
+    );
307
+
308
+    $staffSummary = collect();
309
+    $staffTotale = 0.0;
310
+    $staffTransazioni = 0;
311
+    $staffCoperti = 0;
312
+    $staffTotPiattiOrdinati = 0;
313
+    $staffCucineRows = collect();
314
+    $staffHeatmapHourLabels = collect();
315
+    $saltacodaFunnel = [
316
+      'totale' => 0,
317
+      'pre_ordine' => 0,
318
+      'pre_carrello' => 0,
319
+      'pre_promosso' => 0,
320
+      'importato' => 0,
321
+      'tasso_to_carrello' => 0.0,
322
+      'tasso_to_importato' => 0.0,
323
+      'carico_labels' => collect(range(0, 23))->map(fn ($h) => str_pad((string) $h, 2, '0', STR_PAD_LEFT))->values()->all(),
324
+      'carico_creati' => array_fill(0, 24, 0),
325
+      'carico_importati' => array_fill(0, 24, 0),
326
+      'carico_totale_creati' => 0,
327
+      'carico_totale_importati' => 0,
328
+      'tempo_import_medio_min' => 0.0,
329
+      'tempo_import_mediana_min' => 0.0,
330
+      'tempo_import_min_min' => 0,
331
+      'tempo_import_max_min' => 0,
332
+      'tempo_import_sample' => 0,
333
+    ];
334
+
335
+    if ($periodoDa && $periodoA) {
336
+      $saltacodaByStato = SaltacodaOrdine::query()
337
+        ->where('attivita_id', (int) $chiusura->attivita_id)
338
+        ->whereBetween('created_at', [$periodoDa, $periodoA])
339
+        ->selectRaw('stato, COUNT(*) as totale')
340
+        ->groupBy('stato')
341
+        ->pluck('totale', 'stato');
342
+
343
+      $saltacodaTotale = (int) $saltacodaByStato->sum();
344
+      $saltacodaPreOrdine = (int) ($saltacodaByStato[SaltacodaOrdine::PRE_ORDINE] ?? 0);
345
+      $saltacodaPreCarrello = (int) ($saltacodaByStato[SaltacodaOrdine::PRE_CARRELLO] ?? 0);
346
+      $saltacodaPrePromosso = (int) ($saltacodaByStato[SaltacodaOrdine::PRE_PROMOSSO] ?? 0);
347
+      $saltacodaImportato = (int) ($saltacodaByStato[SaltacodaOrdine::IMPORTATO] ?? 0);
348
+      $saltacodaFunnel = [
349
+        'totale' => $saltacodaTotale,
350
+        'pre_ordine' => $saltacodaPreOrdine,
351
+        'pre_carrello' => $saltacodaPreCarrello,
352
+        'pre_promosso' => $saltacodaPrePromosso,
353
+        'importato' => $saltacodaImportato,
354
+        'tasso_to_carrello' => $saltacodaTotale > 0 ? round(($saltacodaPreCarrello / $saltacodaTotale) * 100, 1) : 0.0,
355
+        'tasso_to_importato' => $saltacodaTotale > 0 ? round(($saltacodaImportato / $saltacodaTotale) * 100, 1) : 0.0,
356
+      ];
357
+
358
+      $saltacodaCreatiByHour = SaltacodaOrdine::query()
359
+        ->where('attivita_id', (int) $chiusura->attivita_id)
360
+        ->whereBetween('created_at', [$periodoDa, $periodoA])
361
+        ->selectRaw('HOUR(created_at) as ora, COUNT(*) as totale')
362
+        ->groupBy('ora')
363
+        ->pluck('totale', 'ora');
364
+
365
+      $saltacodaImportatiByHour = SaltacodaOrdine::query()
366
+        ->where('attivita_id', (int) $chiusura->attivita_id)
367
+        ->where('stato', SaltacodaOrdine::IMPORTATO)
368
+        ->whereBetween('updated_at', [$periodoDa, $periodoA])
369
+        ->selectRaw('HOUR(updated_at) as ora, COUNT(*) as totale')
370
+        ->groupBy('ora')
371
+        ->pluck('totale', 'ora');
372
+
373
+      $durateImport = SaltacodaOrdine::query()
374
+        ->where('attivita_id', (int) $chiusura->attivita_id)
375
+        ->where('stato', SaltacodaOrdine::IMPORTATO)
376
+        ->whereBetween('created_at', [$periodoDa, $periodoA])
377
+        ->whereBetween('updated_at', [$periodoDa, $periodoA])
378
+        ->selectRaw('TIMESTAMPDIFF(MINUTE, created_at, updated_at) as minuti')
379
+        ->pluck('minuti')
380
+        ->filter(fn ($minuti) => is_numeric($minuti) && (int) $minuti >= 0)
381
+        ->map(fn ($minuti) => (int) $minuti)
382
+        ->values();
383
+
384
+      $durateArray = $durateImport->all();
385
+
386
+      $saltacodaFunnel['carico_creati'] = collect(range(0, 23))
387
+        ->map(fn ($h) => (int) ($saltacodaCreatiByHour[(string) $h] ?? $saltacodaCreatiByHour[$h] ?? 0))
388
+        ->values()
389
+        ->all();
390
+      $saltacodaFunnel['carico_importati'] = collect(range(0, 23))
391
+        ->map(fn ($h) => (int) ($saltacodaImportatiByHour[(string) $h] ?? $saltacodaImportatiByHour[$h] ?? 0))
392
+        ->values()
393
+        ->all();
394
+      $saltacodaFunnel['carico_totale_creati'] = array_sum($saltacodaFunnel['carico_creati']);
395
+      $saltacodaFunnel['carico_totale_importati'] = array_sum($saltacodaFunnel['carico_importati']);
396
+      $saltacodaFunnel['tempo_import_sample'] = $durateImport->count();
397
+      $saltacodaFunnel['tempo_import_medio_min'] = $durateImport->isNotEmpty() ? round((float) $durateImport->avg(), 1) : 0.0;
398
+      $saltacodaFunnel['tempo_import_mediana_min'] = $durateImport->isNotEmpty() ? $this->median($durateArray) : 0.0;
399
+      $saltacodaFunnel['tempo_import_min_min'] = $durateImport->isNotEmpty() ? (int) $durateImport->min() : 0;
400
+      $saltacodaFunnel['tempo_import_max_min'] = $durateImport->isNotEmpty() ? (int) $durateImport->max() : 0;
401
+
402
+      $staffReport = $bilancioServizio->forPeriodStaff((int) $chiusura->attivita_id, $periodoDa, $periodoA);
403
+      $staffSummary = $staffReport['paymentSummary'] ?? collect();
404
+      $staffTotale = (float) ($staffReport['totaleIncasso'] ?? 0);
405
+      $staffTransazioni = (int) ($staffReport['totaleTransazioni'] ?? 0);
406
+      $staffCoperti = (int) ($staffReport['totCoperti'] ?? 0);
407
+      $staffTotPiattiOrdinati = (int) ($staffReport['totPiattiOrdinati'] ?? 0);
408
+      $staffHeatmapSeries = collect($staffReport['heatmapSeries'] ?? []);
409
+      $staffCucineRows = collect($staffReport['cucineRows'] ?? [])->values()->map(function ($cucina, $index) use ($staffHeatmapSeries) {
410
+        if (!empty($cucina['ordini_per_ora'])) {
411
+          return $cucina;
412
+        }
413
+
414
+        $orario = $staffHeatmapSeries->get($index) ?? $staffHeatmapSeries->firstWhere('name', $cucina['nome'] ?? '');
415
+        $cucina['ordini_per_ora'] = $orario['data'] ?? [];
416
+
417
+        return $cucina;
418
+      });
419
+      $staffHeatmapHourLabels = $staffReport['heatmapHourLabels'] ?? collect();
420
+    }
421
+
422
+    $payload = [
423
+      'chiusura' => $chiusura,
424
+      'snapshot' => $snapshot,
425
+      'periodoDa' => $periodoDa,
426
+      'periodoA' => $periodoA,
427
+      'paymentSummary' => collect($snapshot['pagamenti_per_metodo'] ?? []),
428
+      'cucineRows' => $bilancioServizio->cucineWithOrario($snapshot, $heatmap['heatmapSeries']),
429
+      'totaleIncasso' => (float) ($snapshot['incasso'] ?? 0),
430
+      'totaleTransazioni' => (int) ($snapshot['transazioni'] ?? 0),
431
+      'totPiattiOrdinati' => (int) ($snapshot['piatti_ordinati'] ?? 0),
432
+      'totCoperti' => (int) ($snapshot['coperti'] ?? 0),
433
+      'staffSummary' => $staffSummary,
434
+      'staffTotale' => $staffTotale,
435
+      'staffTransazioni' => $staffTransazioni,
436
+      'staffCoperti' => $staffCoperti,
437
+      'staffTotPiattiOrdinati' => $staffTotPiattiOrdinati,
438
+      'staffCucineRows' => $staffCucineRows,
439
+      'staffHeatmapHourLabels' => $staffHeatmapHourLabels,
440
+      'saltacodaFunnel' => $saltacodaFunnel,
441
+      'generatoIl' => now()->format('d/m/Y H:i'),
442
+    ];
443
+
444
+    if ($request->boolean('preview')) {
445
+      $payload['previewMode'] = true;
446
+      return view('chiusura_servizio.pdf', $payload);
447
+    }
448
+
449
+    $pdf = Pdf::loadView('chiusura_servizio.pdf', $payload)->setPaper('a4', 'portrait');
450
+    $dompdf = $pdf->getDomPDF();
451
+    $dompdf->render();
452
+
453
+    $canvas = $dompdf->getCanvas();
454
+    $fontMetrics = $dompdf->getFontMetrics();
455
+    $fontRegular = $fontMetrics->getFont('DejaVu Sans', 'normal');
456
+    $footerLeft = 'Generato: ' . now()->format('d/m/Y H:i');
457
+    $headerRight = 'Chiusura servizio #' . $chiusura->id;
458
+
459
+    $canvas->page_script(function ($pageNumber, $pageCount, $canvas, $fontMetrics) use ($fontRegular, $footerLeft, $headerRight) {
460
+      $width = $canvas->get_width();
461
+      $height = $canvas->get_height();
462
+
463
+      $canvas->text(28, $height - 24, $footerLeft, $fontRegular, 8, [0.4, 0.44, 0.5]);
464
+      $pageText = sprintf('%d / %d', $pageNumber, $pageCount);
465
+      $pageWidth = $fontMetrics->getTextWidth($pageText, $fontRegular, 8);
466
+      $canvas->text($width - 28 - $pageWidth, $height - 24, $pageText, $fontRegular, 8, [0.4, 0.44, 0.5]);
467
+
468
+      if ($pageNumber > 1) {
469
+        $headerWidth = $fontMetrics->getTextWidth($headerRight, $fontRegular, 8);
470
+        $canvas->text($width - 28 - $headerWidth, 22, $headerRight, $fontRegular, 8, [0.2, 0.24, 0.3]);
471
+      }
472
+    });
473
+
474
+    $filename = sprintf('chiusura-servizio-%d-%s.pdf', $chiusura->id, now()->format('Ymd-His'));
475
+
476
+    return response($dompdf->output(), 200, [
477
+      'Content-Type' => 'application/pdf',
478
+      'Content-Disposition' => 'attachment; filename="' . $filename . '"',
479
+    ]);
480
+  }
481
+
482
+  private function median(array $values): float
483
+  {
484
+    if (empty($values)) {
485
+      return 0.0;
486
+    }
487
+
488
+    sort($values);
489
+    $count = count($values);
490
+    $middle = intdiv($count, 2);
491
+
492
+    if ($count % 2 === 0) {
493
+      return round((($values[$middle - 1] + $values[$middle]) / 2), 1);
494
+    }
495
+
496
+    return round((float) $values[$middle], 1);
497
+  }
498
+
125 499
 }

+ 160
- 3
app/Services/Bilancio/BilancioServizioService.php Voir le fichier

@@ -20,9 +20,10 @@ class BilancioServizioService
20 20
             ->whereHas('ordine', function ($query) use ($attivitaId, $start, $end) {
21 21
                 $query
22 22
                     ->where('attivita_id', $attivitaId)
23
-                    ->whereBetween('created_at', [$start, $end])
24
-                    ->whereHas('pagamenti', function ($pagamentiQuery) {
25
-                        $pagamentiQuery->perIncasso();
23
+                    ->whereHas('pagamenti', function ($pagamentiQuery) use ($start, $end) {
24
+                        $pagamentiQuery
25
+                            ->whereBetween('created_at', [$start, $end])
26
+                            ->perIncasso();
26 27
                     });
27 28
             })
28 29
             ->get();
@@ -139,6 +140,12 @@ class BilancioServizioService
139 140
         $totaleIncasso = (float) $pagamenti->sum(fn ($p) => (float) ($p->importo ?? 0));
140 141
         $totaleTransazioni = $pagamenti->count();
141 142
         $totPiattiOrdinati = (int) $cucineRows->sum('tot_quantita');
143
+        $totCoperti = (int) $pagamenti
144
+            ->filter(fn ($pagamento) => !empty($pagamento->ordine_id))
145
+            ->unique('ordine_id')
146
+            ->sum(function ($pagamento) {
147
+                return $this->copertiFromOrdineInfo($pagamento->ordine?->info ?? null);
148
+            });
142 149
 
143 150
         $hourLabels = collect(range(0, 23))->map(fn ($h) => str_pad((string) $h, 2, '0', STR_PAD_LEFT))->values();
144 151
 
@@ -178,6 +185,141 @@ class BilancioServizioService
178 185
             'totaleIncasso' => $totaleIncasso,
179 186
             'totaleTransazioni' => $totaleTransazioni,
180 187
             'totPiattiOrdinati' => $totPiattiOrdinati,
188
+            'totCoperti' => $totCoperti,
189
+            'heatmapHourLabels' => $hourLabels,
190
+            'heatmapSeries' => $heatmapSeries,
191
+        ];
192
+    }
193
+
194
+    /**
195
+     * Righe ordine legate a pagamenti STAFF nel periodo (controvalore separato).
196
+     */
197
+    public function righeOrdineStaff(int $attivitaId, Carbon $start, Carbon $end)
198
+    {
199
+        return RigaOrdine::query()
200
+            ->with(['piatto.cucina', 'ordine'])
201
+            ->whereHas('ordine', function ($query) use ($attivitaId, $start, $end) {
202
+                $query
203
+                    ->where('attivita_id', $attivitaId)
204
+                    ->whereHas('pagamenti', function ($pagamentiQuery) use ($start, $end) {
205
+                        $pagamentiQuery
206
+                            ->whereBetween('created_at', [$start, $end])
207
+                            ->staffPagato();
208
+                    });
209
+            })
210
+            ->get();
211
+    }
212
+
213
+    /**
214
+     * Totali e breakdown per pagamenti STAFF su un intervallo (sezione dedicata).
215
+     */
216
+    public function forPeriodStaff(int $attivitaId, Carbon $start, Carbon $end): array
217
+    {
218
+        $righe = $this->righeOrdineStaff($attivitaId, $start, $end);
219
+
220
+        $cucineRows = $righe
221
+            ->groupBy(fn ($riga) => $riga->piatto?->cucina_id ?: 0)
222
+            ->map(function ($items, $cucinaId) {
223
+                $first = $items->first();
224
+                $cucinaNome = $first?->piatto?->cucina?->nome ?? ('Cucina #' . $cucinaId);
225
+
226
+                $piatti = $items
227
+                    ->groupBy('piatto_id')
228
+                    ->map(function ($piattoItems) {
229
+                        $firstPiatto = $piattoItems->first();
230
+                        $quantita = (int) $piattoItems->sum(fn ($r) => (int) ($r->quantita ?? 0));
231
+                        $totale = (float) $piattoItems->sum(fn ($r) => $this->totaleRigaOrdine($r));
232
+
233
+                        return [
234
+                            'nome' => $firstPiatto?->piatto?->nome ?? 'Piatto',
235
+                            'quantita' => $quantita,
236
+                            'totale' => $totale,
237
+                        ];
238
+                    })
239
+                    ->sortByDesc('quantita')
240
+                    ->values();
241
+
242
+                return [
243
+                    'id' => (int) $cucinaId,
244
+                    'nome' => $cucinaNome,
245
+                    'piatti' => $piatti,
246
+                    'tot_quantita' => (int) $piatti->sum('quantita'),
247
+                    'tot_importo' => (float) $piatti->sum('totale'),
248
+                ];
249
+            })
250
+            ->sortByDesc('tot_quantita')
251
+            ->values();
252
+
253
+        $pagamenti = Pagamento::query()
254
+            ->with(['metodo_pagamento', 'ordine.righe_ordine.piatto.cucina'])
255
+            ->where('attivita_id', $attivitaId)
256
+            ->whereBetween('created_at', [$start, $end])
257
+            ->staffPagato()
258
+            ->get();
259
+
260
+        $paymentSummary = $pagamenti
261
+            ->groupBy(fn ($pagamento) => $pagamento->metodo_pagamento?->nome
262
+                ?? $pagamento->metodo_pagamento?->tipo
263
+                ?? 'N/D')
264
+            ->map(function ($items, $metodo) {
265
+                return [
266
+                    'metodo' => (string) $metodo,
267
+                    'count' => $items->count(),
268
+                    'totale' => (float) $items->sum(fn ($p) => (float) ($p->importo ?? 0)),
269
+                ];
270
+            })
271
+            ->sortByDesc('totale')
272
+            ->values();
273
+
274
+        $totaleIncasso = (float) $pagamenti->sum(fn ($p) => (float) ($p->importo ?? 0));
275
+        $totaleTransazioni = $pagamenti->count();
276
+        $totPiattiOrdinati = (int) $cucineRows->sum('tot_quantita');
277
+        $totCoperti = (int) $pagamenti
278
+            ->filter(fn ($pagamento) => !empty($pagamento->ordine_id))
279
+            ->unique('ordine_id')
280
+            ->sum(function ($pagamento) {
281
+                return $this->copertiFromOrdineInfo($pagamento->ordine?->info ?? null);
282
+            });
283
+
284
+        $hourLabels = collect(range(0, 23))->map(fn ($h) => str_pad((string) $h, 2, '0', STR_PAD_LEFT))->values();
285
+
286
+        $heatmapSeries = $cucineRows->map(function ($cucinaRow) use ($pagamenti) {
287
+            $cucinaId = (int) ($cucinaRow['id'] ?? 0);
288
+            $data = [];
289
+            foreach (range(0, 23) as $h) {
290
+                $hourTotal = (int) $pagamenti
291
+                    ->filter(fn ($pagamento) => !empty($pagamento->created_at) && (int) $pagamento->created_at->format('H') === $h)
292
+                    ->sum(function ($pagamento) use ($cucinaId) {
293
+                        if (empty($pagamento->ordine)) {
294
+                            return 0;
295
+                        }
296
+
297
+                        return (int) $pagamento->ordine->righe_ordine->sum(function ($riga) use ($cucinaId) {
298
+                            if ((int) ($riga->piatto?->cucina_id ?? 0) !== $cucinaId) {
299
+                                return 0;
300
+                            }
301
+
302
+                            return (int) ($riga->quantita ?? 0);
303
+                        });
304
+                    });
305
+                $data[] = $hourTotal;
306
+            }
307
+
308
+            return [
309
+                'name' => (string) ($cucinaRow['nome'] ?? 'Cucina'),
310
+                'data' => $data,
311
+            ];
312
+        })->values();
313
+
314
+        return [
315
+            'periodStart' => $start,
316
+            'periodEnd' => $end,
317
+            'cucineRows' => $cucineRows,
318
+            'paymentSummary' => $paymentSummary,
319
+            'totaleIncasso' => $totaleIncasso,
320
+            'totaleTransazioni' => $totaleTransazioni,
321
+            'totPiattiOrdinati' => $totPiattiOrdinati,
322
+            'totCoperti' => $totCoperti,
181 323
             'heatmapHourLabels' => $hourLabels,
182 324
             'heatmapSeries' => $heatmapSeries,
183 325
         ];
@@ -198,6 +340,7 @@ class BilancioServizioService
198 340
             'incasso' => $report['totaleIncasso'],
199 341
             'transazioni' => $report['totaleTransazioni'],
200 342
             'piatti_ordinati' => $report['totPiattiOrdinati'],
343
+            'coperti' => $report['totCoperti'],
201 344
             'pagamenti_per_metodo' => $report['paymentSummary']->values()->all(),
202 345
             'cucine' => $report['cucineRows']->values()->map(function ($cucina, $index) use ($report) {
203 346
                 $orario = $report['heatmapSeries']->get($index);
@@ -277,4 +420,18 @@ class BilancioServizioService
277 420
 
278 421
         return 'Servizio in corso · ' . $range;
279 422
     }
423
+
424
+    private function copertiFromOrdineInfo($info): int
425
+    {
426
+        if (is_string($info)) {
427
+            $decoded = json_decode($info, true);
428
+            $info = is_array($decoded) ? $decoded : [];
429
+        }
430
+
431
+        if (!is_array($info)) {
432
+            return 0;
433
+        }
434
+
435
+        return max(0, (int) ($info['coperti'] ?? 0));
436
+    }
280 437
 }

+ 13
- 3
resources/views/bilancio/oggi.blade.php Voir le fichier

@@ -79,7 +79,7 @@ $configData = Helper::appClasses();
79 79
 
80 80
 {{-- KPI principali --}}
81 81
 <div class="row g-3 mt-1">
82
-  <div class="col-md-4">
82
+  <div class="col-md-3">
83 83
     <div class="card h-100 border-start border-4 border-primary">
84 84
       <div class="card-body">
85 85
         <div class="servizio-kpi-label mb-1">Incasso</div>
@@ -89,7 +89,7 @@ $configData = Helper::appClasses();
89 89
       </div>
90 90
     </div>
91 91
   </div>
92
-  <div class="col-md-4">
92
+  <div class="col-md-3">
93 93
     <div class="card h-100">
94 94
       <div class="card-body">
95 95
         <div class="servizio-kpi-label mb-1">Transazioni</div>
@@ -99,7 +99,7 @@ $configData = Helper::appClasses();
99 99
       </div>
100 100
     </div>
101 101
   </div>
102
-  <div class="col-md-4">
102
+  <div class="col-md-3">
103 103
     <div class="card h-100">
104 104
       <div class="card-body">
105 105
         <div class="servizio-kpi-label mb-1">Piatti ordinati</div>
@@ -109,6 +109,16 @@ $configData = Helper::appClasses();
109 109
       </div>
110 110
     </div>
111 111
   </div>
112
+  <div class="col-md-3">
113
+    <div class="card h-100">
114
+      <div class="card-body">
115
+        <div class="servizio-kpi-label mb-1">Coperti</div>
116
+        <div class="servizio-kpi-value">
117
+          {{ number_format((int) ($totCopertiOggi ?? 0), 0, ',', '.') }}
118
+        </div>
119
+      </div>
120
+    </div>
121
+  </div>
112 122
 </div>
113 123
 
114 124
 {{-- Pagamenti per metodo --}}

+ 4
- 1
resources/views/chiusura_servizio/menu.blade.php Voir le fichier

@@ -9,7 +9,10 @@ use Illuminate\Support\Facades\Auth;
9 9
     <div class="dropdown-menu">
10 10
         @if(Auth::user()->can('view-chiusura-servizio'))
11 11
         <a href="{{ route('chiusura-servizio.show', ['chiusura_servizio_id' => $entity->id]) }}" class="dropdown-item">
12
-            <i class="bx bx-eye me-1"></i> Visualizza
12
+            <i class="bx bx-show me-1"></i> Visualizza
13
+        </a>
14
+        <a href="{{ route('chiusura-servizio.export-pdf', ['chiusura_servizio_id' => $entity->id]) }}" class="dropdown-item" target="_blank" rel="noopener">
15
+            <i class="bx bx-download me-1"></i> Report PDF
13 16
         </a>
14 17
         @endif
15 18
 

+ 467
- 0
resources/views/chiusura_servizio/pdf.blade.php Voir le fichier

@@ -0,0 +1,467 @@
1
+@php
2
+  $attivitaNome = $chiusura->attivita?->nome ?? 'Attivita';
3
+  $operatore = $chiusura->user?->fullName ?? 'N/D';
4
+  $periodoLabel = (($periodoDa?->format('d/m/Y H:i') ?? 'N/D') . ' - ' . ($periodoA?->format('d/m/Y H:i') ?? 'N/D'));
5
+  $durataMinuti = ($periodoDa && $periodoA) ? $periodoDa->diffInMinutes($periodoA) : null;
6
+  $durataLabel = $durataMinuti !== null ? (intdiv($durataMinuti, 60) . 'h ' . ($durataMinuti % 60) . 'm') : 'N/D';
7
+  $paymentSummary = collect($paymentSummary ?? []);
8
+  $staffSummary = collect($staffSummary ?? []);
9
+  $cucineRows = collect($cucineRows ?? []);
10
+  $staffCucineRows = collect($staffCucineRows ?? []);
11
+@endphp
12
+<!doctype html>
13
+<html lang="it">
14
+<head>
15
+  <meta charset="utf-8">
16
+  <title>Report servizio #{{ $chiusura->id }}</title>
17
+  <style>
18
+    @page { margin: 20px 24px; }
19
+    body { font-family: DejaVu Sans, sans-serif; color: #1f2d3d; font-size: 11px; line-height: 1.35; }
20
+    .header { border-bottom: 1px solid #d7dce4; padding-bottom: 10px; margin-bottom: 12px; }
21
+    .title { font-size: 18px; font-weight: 700; margin: 0; }
22
+    .subtitle { color: #5f6b7a; margin-top: 2px; }
23
+    .meta { margin-top: 8px; color: #5f6b7a; font-size: 10px; }
24
+    .section { margin-top: 14px; }
25
+    .section h2 { font-size: 13px; margin: 0 0 8px; padding-bottom: 4px; border-bottom: 1px solid #e5e9ef; }
26
+    .kpi-grid { width: 100%; border-collapse: separate; border-spacing: 6px; table-layout: fixed; }
27
+    .kpi-cell { border: 1px solid #e1e6ee; border-radius: 6px; padding: 8px; vertical-align: top; }
28
+    .kpi-label { font-size: 10px; color: #6c7785; text-transform: uppercase; letter-spacing: .04em; }
29
+    .kpi-value { margin-top: 4px; font-size: 16px; font-weight: 700; }
30
+    .kpi-note { margin-top: 2px; font-size: 9px; color: #7a8594; }
31
+    .two-col { width: 100%; border-collapse: collapse; }
32
+    .two-col td { width: 50%; vertical-align: top; padding-right: 8px; }
33
+    .card { border: 1px solid #e1e6ee; border-radius: 6px; padding: 8px; margin-bottom: 8px; }
34
+    .card-title { font-size: 11px; font-weight: 700; margin-bottom: 4px; }
35
+    table.data { width: 100%; border-collapse: collapse; margin-top: 6px; }
36
+    table.data th { text-align: left; font-size: 9px; color: #6d7785; text-transform: uppercase; letter-spacing: .04em; border-bottom: 1px solid #dfe4ec; padding: 5px 4px; }
37
+    table.data td { border-bottom: 1px solid #edf1f6; padding: 5px 4px; }
38
+    .num { text-align: right; font-variant-numeric: tabular-nums; }
39
+    .muted { color: #6f7b8a; }
40
+    .pill { display: inline-block; padding: 2px 8px; border: 1px solid #d8dee7; border-radius: 999px; font-size: 9px; color: #586472; }
41
+    .small { font-size: 9px; color: #6f7b8a; }
42
+    .kitchen-card { border: 0; border-radius: 0; padding: 8px 0; margin-bottom: 8px; }
43
+    .kitchen-head { margin-bottom: 6px; }
44
+    .kitchen-title { font-size: 12px; font-weight: 700; }
45
+    .kitchen-meta { font-size: 9px; color: #6f7b8a; }
46
+    .caption { margin: 0 0 6px; font-size: 9px; color: #6f7b8a; }
47
+    .kitchen-page-break { page-break-before: always; }
48
+    .page-break { page-break-before: always; }
49
+    .index-list { margin: 0; padding-left: 14px; color: #334155; }
50
+    .index-list li { margin: 0 0 3px; }
51
+    .kitchen-band { border: 1px solid #dbe3ee; border-radius: 6px; background: #f4f7fb; padding: 7px 8px; margin-bottom: 6px; }
52
+    .kitchen-band__top { display: table; width: 100%; }
53
+    .kitchen-band__left, .kitchen-band__right { display: table-cell; vertical-align: middle; }
54
+    .kitchen-band__right { text-align: right; color: #5f6b7a; font-size: 9px; }
55
+    .kitchen-band__meta { margin-top: 3px; font-size: 9px; color: #5f6b7a; }
56
+    .kitchen-band__name { font-size: 12px; font-weight: 700; color: #1e2a3b; }
57
+    .kitchen-badge { display: inline-block; border: 1px solid #cfd9e6; border-radius: 999px; padding: 1px 6px; font-size: 9px; color: #415066; }
58
+    .kpi-mini { width: 100%; border-collapse: separate; border-spacing: 4px; margin-bottom: 6px; }
59
+    .kpi-mini td { border: 1px solid #e1e7f0; border-radius: 5px; padding: 5px 6px; vertical-align: top; }
60
+    .kpi-mini__label { font-size: 8px; color: #667488; text-transform: uppercase; letter-spacing: .04em; }
61
+    .kpi-mini__value { margin-top: 2px; font-size: 11px; font-weight: 700; color: #1e2a3b; }
62
+    .podium { width: 100%; border-collapse: separate; border-spacing: 4px; margin-bottom: 6px; table-layout: fixed; }
63
+    .podium td { border: 1px solid #e1e7f0; border-radius: 6px; text-align: center; padding: 6px 5px; background: #fff; }
64
+    .podium .podium-first { background: #f8fafc; border-color: #ced8e6; }
65
+    .podium-pos { font-size: 8px; color: #6a788c; text-transform: uppercase; letter-spacing: .04em; }
66
+    .podium-name { margin-top: 2px; font-size: 10px; font-weight: 700; color: #1f2d3d; }
67
+    .podium-val { margin-top: 2px; font-size: 9px; color: #4a596f; }
68
+    table.data--zebra tbody tr:nth-child(even) td { background: #fafcff; }
69
+    .insight { margin-top: 6px; padding: 5px 7px; border: 1px dashed #d8e1ed; border-radius: 5px; color: #46556b; font-size: 9px; }
70
+  </style>
71
+</head>
72
+<body>
73
+  @if(!empty($previewMode))
74
+    <div class="card" style="margin-bottom: 10px;">
75
+      <div class="card-title">Anteprima web report</div>
76
+      <p class="caption" style="margin-bottom:0;">Fonte: questa pagina mostra il layout del report prima del download PDF.</p>
77
+    </div>
78
+  @endif
79
+  <div class="header">
80
+    <p class="title">{{ $attivitaNome }}</p>
81
+    <div class="subtitle">Report servizio chiuso - Chiusura #{{ $chiusura->id }}</div>
82
+    <div class="meta">
83
+      Periodo: {{ $periodoLabel }} | Durata: {{ $durataLabel }} | Operatore: {{ $operatore }} | Generato: {{ $generatoIl ?? now()->format('d/m/Y H:i') }}
84
+    </div>
85
+  </div>
86
+
87
+  <div class="section">
88
+    <h2>Indice report</h2>
89
+    <p class="caption">Fonte: struttura del documento di chiusura servizio.</p>
90
+    <ol class="index-list">
91
+      <li>Sintesi servizio (staff escluso)</li>
92
+      <li>Pagamenti e funnel Saltacoda</li>
93
+      <li>Performance cucine (classifica)</li>
94
+      <li>Dettaglio performance per cucina (1 cucina per pagina)</li>
95
+      <li>Sintesi staff</li>
96
+      <li>Dettaglio cucine staff con piatti (1 cucina per pagina)</li>
97
+    </ol>
98
+  </div>
99
+
100
+  <div class="section">
101
+    <h2>Sintesi servizio (staff escluso)</h2>
102
+    <p class="caption">Fonte: riepilogo pagamenti per incasso e coperti da ordine.info.</p>
103
+    <table class="kpi-grid">
104
+      <tr>
105
+        <td class="kpi-cell">
106
+          <div class="kpi-label">Incasso</div>
107
+          <div class="kpi-value">EUR {{ number_format((float) ($totaleIncasso ?? 0), 2, ',', '.') }}</div>
108
+          <div class="kpi-note">Fonte: Pagamenti</div>
109
+        </td>
110
+        <td class="kpi-cell">
111
+          <div class="kpi-label">Transazioni</div>
112
+          <div class="kpi-value">{{ number_format((int) ($totaleTransazioni ?? 0), 0, ',', '.') }}</div>
113
+          <div class="kpi-note">Fonte: Pagamenti</div>
114
+        </td>
115
+        <td class="kpi-cell">
116
+          <div class="kpi-label">Piatti ordinati</div>
117
+          <div class="kpi-value">{{ number_format((int) ($totPiattiOrdinati ?? 0), 0, ',', '.') }}</div>
118
+          <div class="kpi-note">Fonte: Righe ordine</div>
119
+        </td>
120
+        <td class="kpi-cell">
121
+          <div class="kpi-label">Coperti</div>
122
+          <div class="kpi-value">{{ number_format((int) ($totCoperti ?? 0), 0, ',', '.') }}</div>
123
+          <div class="kpi-note">Fonte: ordine.info.coperti</div>
124
+        </td>
125
+      </tr>
126
+    </table>
127
+  </div>
128
+
129
+  <div class="section">
130
+    <h2>Pagamenti e funnel</h2>
131
+    <table class="two-col">
132
+      <tr>
133
+        <td>
134
+          <div class="card">
135
+            <div class="card-title">Pagamenti per metodo</div>
136
+            <p class="caption">Fonte: pagamenti filtrati per periodo e metodo.</p>
137
+            <table class="data">
138
+              <thead>
139
+                <tr><th>Metodo</th><th class="num">Transazioni</th><th class="num">Totale</th></tr>
140
+              </thead>
141
+              <tbody>
142
+                @forelse($paymentSummary as $row)
143
+                  <tr>
144
+                    <td>{{ $row['metodo'] ?? 'N/D' }}</td>
145
+                    <td class="num">{{ number_format((int) ($row['count'] ?? 0), 0, ',', '.') }}</td>
146
+                    <td class="num">EUR {{ number_format((float) ($row['totale'] ?? 0), 2, ',', '.') }}</td>
147
+                  </tr>
148
+                @empty
149
+                  <tr><td colspan="3" class="muted">Nessun pagamento nel periodo.</td></tr>
150
+                @endforelse
151
+              </tbody>
152
+            </table>
153
+          </div>
154
+        </td>
155
+        <td>
156
+          <div class="card">
157
+            <div class="card-title">Funnel Saltacoda</div>
158
+            <p class="caption">Fonte: stati Saltacoda e differenza created_at/updated_at.</p>
159
+            <p class="small">
160
+              <span class="pill">Creati: {{ number_format((int) ($saltacodaFunnel['totale'] ?? 0), 0, ',', '.') }}</span>
161
+              <span class="pill">Carrello: {{ number_format((int) ($saltacodaFunnel['pre_carrello'] ?? 0), 0, ',', '.') }}</span>
162
+              <span class="pill">Importati: {{ number_format((int) ($saltacodaFunnel['importato'] ?? 0), 0, ',', '.') }}</span>
163
+            </p>
164
+            <table class="data">
165
+              <tbody>
166
+                <tr><td>Conversione a carrello</td><td class="num">{{ number_format((float) ($saltacodaFunnel['tasso_to_carrello'] ?? 0), 1, ',', '.') }}%</td></tr>
167
+                <tr><td>Conversione a import</td><td class="num">{{ number_format((float) ($saltacodaFunnel['tasso_to_importato'] ?? 0), 1, ',', '.') }}%</td></tr>
168
+                <tr><td>Tempo medio import</td><td class="num">{{ number_format((float) ($saltacodaFunnel['tempo_import_medio_min'] ?? 0), 1, ',', '.') }} min</td></tr>
169
+                <tr><td>Tempo mediano import</td><td class="num">{{ number_format((float) ($saltacodaFunnel['tempo_import_mediana_min'] ?? 0), 1, ',', '.') }} min</td></tr>
170
+              </tbody>
171
+            </table>
172
+          </div>
173
+        </td>
174
+      </tr>
175
+    </table>
176
+  </div>
177
+
178
+  <div class="section">
179
+    <h2>Performance cucine</h2>
180
+    <p class="caption">Fonte: aggregazione righe ordine per cucina nel periodo.</p>
181
+    <table class="data">
182
+      <thead>
183
+        <tr><th>#</th><th>Cucina</th><th class="num">Qtà</th><th class="num">Incasso</th></tr>
184
+      </thead>
185
+      <tbody>
186
+        @forelse($cucineRows->sortByDesc('tot_quantita')->values() as $idx => $cucina)
187
+          <tr>
188
+            <td>{{ $idx + 1 }}</td>
189
+            <td>{{ $cucina['nome'] ?? 'Cucina' }}</td>
190
+            <td class="num">{{ number_format((int) ($cucina['tot_quantita'] ?? 0), 0, ',', '.') }}</td>
191
+            <td class="num">EUR {{ number_format((float) ($cucina['tot_importo'] ?? 0), 2, ',', '.') }}</td>
192
+          </tr>
193
+        @empty
194
+          <tr><td colspan="4" class="muted">Nessuna cucina disponibile.</td></tr>
195
+        @endforelse
196
+      </tbody>
197
+    </table>
198
+  </div>
199
+
200
+  <div class="section">
201
+    <h2>Dettaglio performance per cucina</h2>
202
+    <p class="caption">Fonte: dettaglio piatti per cucina, ordinato per quantita.</p>
203
+    @forelse($cucineRows->sortByDesc('tot_quantita')->values() as $idx => $cucina)
204
+      @php
205
+        $piattiCucina = collect($cucina['piatti'] ?? [])->sortByDesc('quantita')->values();
206
+        $totQtaCucina = (int) ($cucina['tot_quantita'] ?? 0);
207
+        $totIncassoCucina = (float) ($cucina['tot_importo'] ?? 0);
208
+        $nPiatti = $piattiCucina->count();
209
+        $topPiatto = $piattiCucina->first();
210
+        $topPiattoNome = $topPiatto['nome'] ?? 'N/D';
211
+        $topPiattoQta = (int) ($topPiatto['quantita'] ?? 0);
212
+        $topPiattoPeso = $totQtaCucina > 0 ? round(($topPiattoQta / $totQtaCucina) * 100, 1) : 0;
213
+        $ticketMedioPiatto = $totQtaCucina > 0 ? round($totIncassoCucina / $totQtaCucina, 2) : 0;
214
+        $codaLungaCount = (int) $piattiCucina->filter(fn ($p) => ($totQtaCucina > 0 ? (((int) ($p['quantita'] ?? 0) / $totQtaCucina) * 100) : 0) < 5)->count();
215
+        $podioPiatti = $piattiCucina->take(3)->values();
216
+      @endphp
217
+      <div class="kitchen-card kitchen-page-break">
218
+        <div class="kitchen-band">
219
+          <div class="kitchen-band__top">
220
+            <div class="kitchen-band__left">
221
+              <span class="kitchen-band__name">{{ $cucina['nome'] ?? 'Cucina' }}</span>
222
+              <span class="kitchen-badge">Posizione #{{ $idx + 1 }}</span>
223
+            </div>
224
+            <div class="kitchen-band__right">
225
+              Incasso totale: EUR {{ number_format($totIncassoCucina, 2, ',', '.') }}
226
+            </div>
227
+          </div>
228
+          <div class="kitchen-band__meta">
229
+            Attivita: {{ $attivitaNome }} | Chiusura #{{ $chiusura->id }} | Periodo: {{ $periodoLabel }}
230
+          </div>
231
+        </div>
232
+        <table class="kpi-mini">
233
+          <tr>
234
+            <td>
235
+              <div class="kpi-mini__label">Quantita</div>
236
+              <div class="kpi-mini__value">{{ number_format($totQtaCucina, 0, ',', '.') }}</div>
237
+            </td>
238
+            <td>
239
+              <div class="kpi-mini__label">Piatti diversi</div>
240
+              <div class="kpi-mini__value">{{ number_format($nPiatti, 0, ',', '.') }}</div>
241
+            </td>
242
+            <td>
243
+              <div class="kpi-mini__label">Ticket medio piatto</div>
244
+              <div class="kpi-mini__value">EUR {{ number_format($ticketMedioPiatto, 2, ',', '.') }}</div>
245
+            </td>
246
+            <td>
247
+              <div class="kpi-mini__label">Top piatto</div>
248
+              <div class="kpi-mini__value">{{ $topPiattoNome }}</div>
249
+            </td>
250
+          </tr>
251
+        </table>
252
+        <p class="caption">Fonte: righe ordine aggregate per cucina e piatto nel periodo.</p>
253
+        <table class="podium">
254
+          <tr>
255
+            @foreach([0, 1, 2] as $slot)
256
+              @php
257
+                $p = $podioPiatti->get($slot);
258
+                $position = $slot + 1;
259
+              @endphp
260
+              <td class="{{ $position === 1 ? 'podium-first' : '' }}">
261
+                <div class="podium-pos">{{ $position }} posto</div>
262
+                <div class="podium-name">{{ $p['nome'] ?? '-' }}</div>
263
+                <div class="podium-val">{{ number_format((int) ($p['quantita'] ?? 0), 0, ',', '.') }} qta - EUR {{ number_format((float) ($p['totale'] ?? 0), 2, ',', '.') }}</div>
264
+              </td>
265
+            @endforeach
266
+          </tr>
267
+        </table>
268
+        <table class="data data--zebra">
269
+          <thead>
270
+            <tr><th>#</th><th>Piatto</th><th class="num">Qtà</th><th class="num">Incasso</th><th class="num">Peso Qtà</th></tr>
271
+          </thead>
272
+          <tbody>
273
+            @forelse($piattiCucina as $pidx => $piatto)
274
+              @php
275
+                $qtaPiatto = (int) ($piatto['quantita'] ?? 0);
276
+                $pesoQta = $totQtaCucina > 0 ? round(($qtaPiatto / $totQtaCucina) * 100, 1) : 0;
277
+              @endphp
278
+              <tr>
279
+                <td>{{ $pidx + 1 }}</td>
280
+                <td>{{ $piatto['nome'] ?? 'Piatto' }}</td>
281
+                <td class="num">{{ number_format($qtaPiatto, 0, ',', '.') }}</td>
282
+                <td class="num">EUR {{ number_format((float) ($piatto['totale'] ?? 0), 2, ',', '.') }}</td>
283
+                <td class="num">{{ number_format($pesoQta, 1, ',', '.') }}%</td>
284
+              </tr>
285
+            @empty
286
+              <tr><td colspan="5" class="muted">Nessun piatto per questa cucina.</td></tr>
287
+            @endforelse
288
+          </tbody>
289
+        </table>
290
+        <div class="insight">
291
+          Insight: top piatto "{{ $topPiattoNome }}" pesa {{ number_format($topPiattoPeso, 1, ',', '.') }}% del volume cucina; coda lunga (piatti sotto 5% volume) = {{ number_format($codaLungaCount, 0, ',', '.') }}.
292
+        </div>
293
+      </div>
294
+    @empty
295
+      <p class="muted">Nessuna cucina disponibile.</p>
296
+    @endforelse
297
+  </div>
298
+
299
+  <div class="section page-break">
300
+    <h2>Sintesi staff</h2>
301
+    <p class="caption">Fonte: pagamenti staff separati dal servizio generale.</p>
302
+    <table class="kpi-grid">
303
+      <tr>
304
+        <td class="kpi-cell">
305
+          <div class="kpi-label">Controvalore staff</div>
306
+          <div class="kpi-value">EUR {{ number_format((float) ($staffTotale ?? 0), 2, ',', '.') }}</div>
307
+          <div class="kpi-note">Fonte: Pagamenti staff</div>
308
+        </td>
309
+        <td class="kpi-cell">
310
+          <div class="kpi-label">Transazioni staff</div>
311
+          <div class="kpi-value">{{ number_format((int) ($staffTransazioni ?? 0), 0, ',', '.') }}</div>
312
+          <div class="kpi-note">Fonte: Pagamenti staff</div>
313
+        </td>
314
+        <td class="kpi-cell">
315
+          <div class="kpi-label">Coperti staff</div>
316
+          <div class="kpi-value">{{ number_format((int) ($staffCoperti ?? 0), 0, ',', '.') }}</div>
317
+          <div class="kpi-note">Fonte: ordine.info.coperti staff</div>
318
+        </td>
319
+        <td class="kpi-cell">
320
+          <div class="kpi-label">Piatti staff</div>
321
+          <div class="kpi-value">{{ number_format((int) ($staffTotPiattiOrdinati ?? 0), 0, ',', '.') }}</div>
322
+          <div class="kpi-note">Fonte: Righe ordine staff</div>
323
+        </td>
324
+      </tr>
325
+    </table>
326
+    <table class="two-col">
327
+      <tr>
328
+        <td>
329
+          <div class="card">
330
+            <div class="card-title">Pagamenti staff per metodo</div>
331
+            <p class="caption">Fonte: pagamenti staff per periodo e metodo.</p>
332
+            <table class="data">
333
+              <thead>
334
+                <tr><th>Metodo</th><th class="num">Transazioni</th><th class="num">Totale</th></tr>
335
+              </thead>
336
+              <tbody>
337
+                @forelse($staffSummary as $row)
338
+                  <tr>
339
+                    <td>{{ $row['metodo'] ?? 'STAFF' }}</td>
340
+                    <td class="num">{{ number_format((int) ($row['count'] ?? 0), 0, ',', '.') }}</td>
341
+                    <td class="num">EUR {{ number_format((float) ($row['totale'] ?? 0), 2, ',', '.') }}</td>
342
+                  </tr>
343
+                @empty
344
+                  <tr><td colspan="3" class="muted">Nessun pagamento staff.</td></tr>
345
+                @endforelse
346
+              </tbody>
347
+            </table>
348
+          </div>
349
+        </td>
350
+        <td>
351
+          <div class="card">
352
+            <div class="card-title">Cucine staff</div>
353
+            <p class="caption">Fonte: righe ordine collegate a pagamenti staff.</p>
354
+            <table class="data">
355
+              <thead>
356
+                <tr><th>Cucina</th><th class="num">Qtà</th><th class="num">Controvalore</th></tr>
357
+              </thead>
358
+              <tbody>
359
+                @forelse($staffCucineRows->sortByDesc('tot_quantita')->values() as $cucina)
360
+                  <tr>
361
+                    <td>{{ $cucina['nome'] ?? 'Cucina' }}</td>
362
+                    <td class="num">{{ number_format((int) ($cucina['tot_quantita'] ?? 0), 0, ',', '.') }}</td>
363
+                    <td class="num">EUR {{ number_format((float) ($cucina['tot_importo'] ?? 0), 2, ',', '.') }}</td>
364
+                  </tr>
365
+                @empty
366
+                  <tr><td colspan="3" class="muted">Nessuna cucina staff.</td></tr>
367
+                @endforelse
368
+              </tbody>
369
+            </table>
370
+          </div>
371
+        </td>
372
+      </tr>
373
+    </table>
374
+
375
+    <div class="card-title">Dettaglio cucine staff con piatti</div>
376
+    <p class="caption">Fonte: dettaglio piatti per cucina in perimetro staff.</p>
377
+    @forelse($staffCucineRows->sortByDesc('tot_quantita')->values() as $idx => $cucina)
378
+      @php
379
+        $piattiStaff = collect($cucina['piatti'] ?? [])->sortByDesc('quantita')->values();
380
+        $totQtaStaffCucina = (int) ($cucina['tot_quantita'] ?? 0);
381
+        $totIncassoStaffCucina = (float) ($cucina['tot_importo'] ?? 0);
382
+        $nPiattiStaff = $piattiStaff->count();
383
+        $topPiattoStaff = $piattiStaff->first();
384
+        $topPiattoStaffNome = $topPiattoStaff['nome'] ?? 'N/D';
385
+        $topPiattoStaffQta = (int) ($topPiattoStaff['quantita'] ?? 0);
386
+        $topPiattoStaffPeso = $totQtaStaffCucina > 0 ? round(($topPiattoStaffQta / $totQtaStaffCucina) * 100, 1) : 0;
387
+        $ticketMedioStaffPiatto = $totQtaStaffCucina > 0 ? round($totIncassoStaffCucina / $totQtaStaffCucina, 2) : 0;
388
+        $codaLungaStaffCount = (int) $piattiStaff->filter(fn ($p) => ($totQtaStaffCucina > 0 ? (((int) ($p['quantita'] ?? 0) / $totQtaStaffCucina) * 100) : 0) < 5)->count();
389
+        $podioPiattiStaff = $piattiStaff->take(3)->values();
390
+      @endphp
391
+      <div class="kitchen-card kitchen-page-break">
392
+        <div class="kitchen-band">
393
+          <div class="kitchen-band__top">
394
+            <div class="kitchen-band__left">
395
+              <span class="kitchen-band__name">{{ $cucina['nome'] ?? 'Cucina' }}</span>
396
+              <span class="kitchen-badge">Posizione #{{ $idx + 1 }}</span>
397
+            </div>
398
+            <div class="kitchen-band__right">
399
+              Controvalore totale: EUR {{ number_format($totIncassoStaffCucina, 2, ',', '.') }}
400
+            </div>
401
+          </div>
402
+          <div class="kitchen-band__meta">
403
+            Attivita: {{ $attivitaNome }} | Chiusura #{{ $chiusura->id }} | Periodo: {{ $periodoLabel }}
404
+          </div>
405
+        </div>
406
+        <table class="kpi-mini">
407
+          <tr>
408
+            <td>
409
+              <div class="kpi-mini__label">Quantita</div>
410
+              <div class="kpi-mini__value">{{ number_format($totQtaStaffCucina, 0, ',', '.') }}</div>
411
+            </td>
412
+            <td>
413
+              <div class="kpi-mini__label">Piatti diversi</div>
414
+              <div class="kpi-mini__value">{{ number_format($nPiattiStaff, 0, ',', '.') }}</div>
415
+            </td>
416
+            <td>
417
+              <div class="kpi-mini__label">Controvalore medio piatto</div>
418
+              <div class="kpi-mini__value">EUR {{ number_format($ticketMedioStaffPiatto, 2, ',', '.') }}</div>
419
+            </td>
420
+            <td>
421
+              <div class="kpi-mini__label">Top piatto</div>
422
+              <div class="kpi-mini__value">{{ $topPiattoStaffNome }}</div>
423
+            </td>
424
+          </tr>
425
+        </table>
426
+        <p class="caption">Fonte: righe ordine staff aggregate per cucina e piatto nel periodo.</p>
427
+        <table class="podium">
428
+          <tr>
429
+            @foreach([0, 1, 2] as $slot)
430
+              @php
431
+                $p = $podioPiattiStaff->get($slot);
432
+                $position = $slot + 1;
433
+              @endphp
434
+              <td class="{{ $position === 1 ? 'podium-first' : '' }}">
435
+                <div class="podium-pos">{{ $position }} posto</div>
436
+                <div class="podium-name">{{ $p['nome'] ?? '-' }}</div>
437
+                <div class="podium-val">{{ number_format((int) ($p['quantita'] ?? 0), 0, ',', '.') }} qta - EUR {{ number_format((float) ($p['totale'] ?? 0), 2, ',', '.') }}</div>
438
+              </td>
439
+            @endforeach
440
+          </tr>
441
+        </table>
442
+        <table class="data data--zebra">
443
+          <thead>
444
+            <tr><th>Piatto</th><th class="num">Qtà</th><th class="num">Controvalore</th></tr>
445
+          </thead>
446
+          <tbody>
447
+            @forelse($piattiStaff as $piatto)
448
+              <tr>
449
+                <td>{{ $piatto['nome'] ?? 'Piatto' }}</td>
450
+                <td class="num">{{ number_format((int) ($piatto['quantita'] ?? 0), 0, ',', '.') }}</td>
451
+                <td class="num">EUR {{ number_format((float) ($piatto['totale'] ?? 0), 2, ',', '.') }}</td>
452
+              </tr>
453
+            @empty
454
+              <tr><td colspan="3" class="muted">Nessun piatto staff per questa cucina.</td></tr>
455
+            @endforelse
456
+          </tbody>
457
+        </table>
458
+        <div class="insight">
459
+          Insight: top piatto "{{ $topPiattoStaffNome }}" pesa {{ number_format($topPiattoStaffPeso, 1, ',', '.') }}% del volume staff cucina; coda lunga (piatti sotto 5% volume) = {{ number_format($codaLungaStaffCount, 0, ',', '.') }}.
460
+        </div>
461
+      </div>
462
+    @empty
463
+      <p class="muted">Nessuna cucina staff disponibile.</p>
464
+    @endforelse
465
+  </div>
466
+</body>
467
+</html>

+ 2073
- 244
resources/views/chiusura_servizio/show.blade.php
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 1
- 0
routes/web.php Voir le fichier

@@ -228,6 +228,7 @@ Route::group(['middleware' => ['role:superadmin']], function () {
228 228
   // Chiusura Servizio
229 229
   Route::resource('chiusura-servizio', ChiusuraServizioController::class, ['only' => ['index', 'store']]);
230 230
   Route::get('chiusura-servizio/show', [ChiusuraServizioController::class, 'show'])->name('chiusura-servizio.show');
231
+  Route::get('chiusura-servizio/export-pdf', [ChiusuraServizioController::class, 'exportPdf'])->name('chiusura-servizio.export-pdf');
231 232
   Route::post('chiusura-servizio/chiudi-servizio', [ChiusuraServizioController::class, 'chiudi_servizio'])->name('chiusura-servizio.chiudi-servizio');
232 233
 
233 234
   // Punto Operatore

Chargement…
Annuler
Enregistrer