Repository navigation
Expand file tree
/
Copy pathCopyController.php
More file actions
669 lines (609 loc) · 31.9 KB
/
Copy pathCopyController.php
File metadata and controls
669 lines (609 loc) · 31.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
<?php
declare(strict_types=1);
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Controllers\ReservationManager;
use App\Models\CopyRepository;
use App\Services\ReservationReassignmentService;
use App\Support\DataIntegrity;
use App\Support\SecureLogger;
use mysqli;
class CopyController
{
/**
* SECURITY: Validate and sanitize HTTP_REFERER to prevent open redirect
*/
private function safeReferer(?string $default = null): string
{
// Delegate to the single audited implementation. localPath() uses only
// the referer's path (never its scheme/host), which is strictly safer
// than the previous same-host comparison and port-agnostic.
return \App\Support\RefererGuard::localPath(
(string) ($_SERVER['HTTP_REFERER'] ?? ''),
// Admin routes are fixed English literals — never routed through the
// i18n system (CLAUDE.md rule #4 / decision #145).
$default ?? '/admin/books'
);
}
private function adminBookPath(int $bookId): string
{
// Fixed admin literal, not an i18n route (CLAUDE.md rule #4).
return '/admin/books/' . $bookId;
}
/**
* Whether a copy is currently "held" by any HOLDING commitment: an active loan
* (prenotato/da_ritirare/in_corso/in_ritardo) or a copy-bound pending
* reservation (attivo=0, stato='pendente', copia_id NOT NULL). Single source of
* truth for the copy-availability predicate used across byCode()/updateCopy().
*/
private function isCopyHeld(\mysqli $db, int $copyId): bool
{
$stmt = $db->prepare("
SELECT 1 FROM prestiti
WHERE copia_id = ?
AND ( (attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo'))
OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL) )
LIMIT 1
");
$stmt->bind_param('i', $copyId);
$stmt->execute();
$held = (bool) $stmt->get_result()->fetch_row();
$stmt->close();
return $held;
}
/**
* Resolve a copy by its numero_inventario (per-copy code) and report whether
* it is loanable right now. Returns JSON:
* {found:false} when no such code exists
* {found:true, copy_id, libro_id, titolo, sottotitolo, stato, available:bool}
*
* "available" mirrors the loan-availability rules used elsewhere: a copy is
* loanable now only if its state is 'disponibile' AND no active/holding loan
* (or copy-bound pending reservation) currently holds it.
*/
public function byCode(Request $request, Response $response, mysqli $db): Response
{
$params = $request->getQueryParams();
$rawCode = $params['code'] ?? '';
$code = is_string($rawCode) ? trim($rawCode) : '';
if ($code === '') {
$response->getBody()->write((string) json_encode(['found' => false]));
return $response->withHeader('Content-Type', 'application/json');
}
// `copie` has no deleted_at — filter the soft-delete on the joined book.
$stmt = $db->prepare("
SELECT c.id AS copy_id, c.libro_id, c.stato, l.titolo, l.sottotitolo
FROM copie c
JOIN libri l ON l.id = c.libro_id
WHERE c.numero_inventario = ? AND l.deleted_at IS NULL
LIMIT 1
");
$stmt->bind_param('s', $code);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$row) {
$response->getBody()->write((string) json_encode(['found' => false]));
return $response->withHeader('Content-Type', 'application/json');
}
$copyId = (int) $row['copy_id'];
$available = false;
if ($row['stato'] === 'disponibile') {
$available = !$this->isCopyHeld($db, $copyId);
}
$response->getBody()->write((string) json_encode([
'found' => true,
'copy_id' => $copyId,
'libro_id' => (int) $row['libro_id'],
'titolo' => $row['titolo'],
'sottotitolo' => (string) ($row['sottotitolo'] ?? ''),
'stato' => $row['stato'],
'available' => $available,
]));
return $response->withHeader('Content-Type', 'application/json');
}
/**
* Aggiorna lo stato di una singola copia
*/
public function updateCopy(Request $request, Response $response, mysqli $db, int $copyId): Response
{
$data = (array) $request->getParsedBody();
// CSRF validated by CsrfMiddleware
$statoInput = $data['stato'] ?? 'disponibile';
$noteInput = $data['note'] ?? '';
if (!is_string($statoInput) || !is_string($noteInput)) {
$_SESSION['error_message'] = __('Impossibile aggiornare la copia senza lasciare dati incoerenti. Nessuna modifica è stata salvata.');
return $response->withHeader('Location', $this->safeReferer())->withStatus(302);
}
$stato = $statoInput;
$note = $this->sanitizeNote($noteInput);
// Validazione stato (deve corrispondere all'enum in copie.stato)
// 'prestato'/'prenotato' are owned by the loan/reservation system. They
// remain valid only so an existing loan-owned state can be preserved
// while staff update the copy note; transitions into them are rejected
// explicitly after loading the current row.
$statiValidi = ['disponibile', 'prestato', 'prenotato', 'manutenzione', 'in_restauro', 'perso', 'danneggiato', 'in_trasferimento'];
if (!in_array($stato, $statiValidi, true)) {
$_SESSION['error_message'] = __('Stato non valido.');
return $response->withHeader('Location', $this->safeReferer())->withStatus(302);
}
// Recupera la copia per ottenere il libro_id
$stmt = $db->prepare("SELECT libro_id, numero_inventario, stato, note FROM copie WHERE id = ?");
$stmt->bind_param('i', $copyId);
$stmt->execute();
$result = $stmt->get_result();
$copy = $result->fetch_assoc();
$stmt->close();
if (!$copy) {
$_SESSION['error_message'] = __('Copia non trovata.');
return $response->withHeader('Location', $this->safeReferer())->withStatus(302);
}
$libroId = (int) $copy['libro_id'];
$statoCorrente = $copy['stato'];
if ($stato === 'prenotato' && $statoCorrente !== 'prenotato') {
$_SESSION['error_message'] = __('Per prenotare una copia, utilizza il sistema Prestiti. Non è possibile impostare manualmente lo stato "Prenotato".');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
if ($statoCorrente === 'prenotato' && $stato === 'disponibile') {
$_SESSION['error_message'] = __('Per annullare o spostare una prenotazione, utilizza il sistema Prestiti.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
// Prestito "in carico" su questa copia (in_corso/in_ritardo): usato per la
// chiusura automatica quando la copia torna 'disponibile'.
$stmt = $db->prepare("
SELECT id, note
FROM prestiti
WHERE copia_id = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')
");
$stmt->bind_param('i', $copyId);
$stmt->execute();
$prestito = $stmt->get_result()->fetch_assoc();
$stmt->close();
// La copia è "trattenuta" da QUALSIASI impegno HOLDING — prestito attivo
// (prenotato/da_ritirare/in_corso/in_ritardo) o pendente-con-copia? Blocca il
// passaggio a stati non prestabili senza prima liberarla (I10/BUG7a/D12):
// anche un ritiro in attesa o una prenotazione futura trattengono la copia.
$copyHeld = $this->isCopyHeld($db, $copyId);
// GESTIONE CAMBIO STATO -> "PRESTATO"
// Non permettere cambio diretto a "prestato", deve usare il sistema prestiti
if ($stato === 'prestato' && $statoCorrente !== 'prestato') {
$_SESSION['error_message'] = __('Per prestare una copia, utilizza il sistema Prestiti dalla sezione dedicata. Non è possibile impostare manualmente lo stato "Prestato".');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
// GESTIONE CAMBIO STATO DA "PRESTATO" A "DISPONIBILE"
// Se c'è un prestito in carico e si vuole rendere disponibile, delega al
// SOLO flusso canonico di restituzione. Questo garantisce insieme: ordine
// lock libro->prestito, riassegnazione copia, promozione coda, ricalcolo,
// wishlist e mail di conferma, senza una seconda state machine divergente.
if ($prestito && $statoCorrente === 'prestato' && $stato === 'disponibile') {
// processReturn ALWAYS overwrites prestiti.note with the value passed.
// The copy-edit form's `note` is a note about the COPY, not the loan —
// forwarding it blindly (or an empty string) would silently wipe the
// loan's own note. Preserve the loan note unless the operator actually
// typed a return note here.
$returnNote = ($note !== '') ? $note : (string) ($prestito['note'] ?? '');
$delegated = $request->withParsedBody([
'stato' => 'restituito',
'note' => $returnNote,
'redirect_to' => $this->adminBookPath($libroId),
'csrf_token' => $data['csrf_token'] ?? '',
]);
return (new PrestitiController())->processReturn($delegated, $response, $db, (int) $prestito['id']);
} else {
// GESTIONE ALTRI STATI
// A copy physically outside the library must go through the return
// workflow (which records lost/damaged outcomes). Future holds on a
// copy still in the library may instead be reassigned atomically below.
if ($copyHeld && $prestito) {
$_SESSION['error_message'] = __('La copia è fisicamente in prestito: registra prima la restituzione o l’esito perso/danneggiato dal sistema Prestiti.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
// L'aggiornamento avviene sotto lock del libro (ordine di lock canonico,
// come store/approveLoan) con ri-verifica HOLDING atomica: così una
// creazione prestito/prenotazione concorrente non può inserirsi tra il
// check e l'UPDATE lasciando la copia non-prestabile ma ancora impegnata.
$db->begin_transaction();
try {
// Lock + soft-delete guard: su un libro rimosso dal catalogo NON si
// committa stato operativo sulle copie (fail-closed, AND deleted_at IS NULL).
$lockBook = $db->prepare("SELECT id FROM libri WHERE id = ? AND deleted_at IS NULL FOR UPDATE");
$lockBook->bind_param('i', $libroId);
$lockBook->execute();
$bookLocked = (bool) $lockBook->get_result()->fetch_row();
$lockBook->close();
if (!$bookLocked) {
$db->rollback();
$_SESSION['error_message'] = __('Libro non trovato o non più disponibile.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
// Recheck only physical possession after the book lock. Scheduled
// holds are deliberately allowed and will be moved below.
$physicalStmt = $db->prepare("SELECT 1 FROM prestiti WHERE copia_id = ? AND attivo = 1 AND stato IN ('in_corso','in_ritardo') LIMIT 1 FOR UPDATE");
$physicalStmt->bind_param('i', $copyId);
$physicalStmt->execute();
$physicallyOut = (bool) $physicalStmt->get_result()->fetch_row();
$physicalStmt->close();
if ($physicallyOut) {
$db->rollback();
$_SESSION['error_message'] = __('La copia è fisicamente in prestito: usa il flusso di restituzione.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
$stmt = $db->prepare("UPDATE copie SET stato = ?, note = ?, updated_at = NOW() WHERE id = ?");
$stmt->bind_param('ssi', $stato, $note, $copyId);
$stmt->execute();
$stmt->close();
$reassignmentService = new \App\Services\ReservationReassignmentService($db);
$reassignmentService->setExternalTransaction(true);
$reservationManager = null;
if (in_array($stato, ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento'], true)) {
// One copy may host several non-overlapping future holds.
for ($guard = 0; $guard < 1000; $guard++) {
$held = $db->prepare("
SELECT 1 FROM prestiti
WHERE copia_id = ? AND (
(attivo = 1 AND stato IN ('prenotato','da_ritirare'))
OR (attivo = 0 AND stato = 'pendente' AND origine = 'prenotazione')
)
LIMIT 1
");
$held->bind_param('i', $copyId);
$held->execute();
$stillHeld = (bool) $held->get_result()->fetch_row();
$held->close();
if (!$stillHeld) {
break;
}
$reassignmentService->reassignOnCopyLost($copyId);
}
} elseif ($stato === 'disponibile') {
$reassignmentService->reassignOnReturn($copyId);
$reservationManager = new \App\Controllers\ReservationManager($db);
$reservationManager->setExternalTransaction(true);
for ($guard = 0; $guard < 1000 && $reservationManager->processBookAvailability($libroId); $guard++) {
// Promote every date-eligible reservation allowed by capacity.
}
}
$integrity = new \App\Support\DataIntegrity($db);
if (!$integrity->recalculateBookAvailability($libroId, insideTransaction: true)) {
throw new \RuntimeException('Impossibile ricalcolare la disponibilità del libro.');
}
$db->commit();
} catch (\Throwable $e) {
$db->rollback();
SecureLogger::error(__('Errore gestione cambio stato copia'), [
'copia_id' => $copyId,
'stato' => $stato,
'error' => $e->getMessage()
]);
$_SESSION['error_message'] = __('Impossibile aggiornare la copia senza lasciare dati incoerenti. Nessuna modifica è stata salvata.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
try {
$reassignmentService->flushDeferredNotifications();
if ($reservationManager !== null) {
$reservationManager->flushDeferredNotifications();
}
} catch (\Throwable $e) {
SecureLogger::warning(__('Invio notifica cambio stato copia fallito'), ['error' => $e->getMessage()]);
}
\App\Support\ActivityLog::recordBookEvent(
$db,
$libroId,
'aggiornamento',
'copy',
'copy.updated',
[
'numero_inventario' => $copy['numero_inventario'] ?? null,
'stato' => $copy['stato'] ?? null,
'note' => $copy['note'] ?? null,
],
[
'numero_inventario' => $copy['numero_inventario'] ?? null,
'stato' => $stato,
'note' => $note,
],
entityId: $copyId,
source: 'manual'
);
}
if (!isset($_SESSION['success_message'])) {
$_SESSION['success_message'] = __('Stato della copia aggiornato con successo.');
}
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
/**
* Crea una o più copie fisiche per un libro, direttamente dalla scheda.
*
* A copy is never *created* in a loan state here — 'prestato'/'prenotato'
* belong to the Prestiti system — but creating an available copy may promote a
* waiting reservation, which sets that new copy to 'prenotato' before commit.
* A copy created out of circulation ('perso'/'danneggiato'/'manutenzione'/
* 'in_restauro'/'in_trasferimento') is excluded from copie_totali by the
* availability recalculation, so marking a copy lost reduces the book's total.
*/
public function createCopy(Request $request, Response $response, mysqli $db, int $bookId): Response
{
$data = (array) $request->getParsedBody();
// CSRF validated by CsrfMiddleware
// Only physical statuses a copy can be created in — loan states are
// managed by the Prestiti system, never set here.
$statoInput = $data['stato'] ?? 'disponibile';
$noteInput = $data['note'] ?? '';
$numeroInput = $data['numero_inventario'] ?? '';
$quantitaInput = $data['quantita'] ?? '1';
if (!is_string($statoInput) || !is_string($noteInput) || !is_string($numeroInput)) {
$_SESSION['error_message'] = __('Impossibile aggiungere la copia.');
return $response->withHeader('Location', url($this->adminBookPath($bookId)))->withStatus(302);
}
$quantita = is_int($quantitaInput)
? $quantitaInput
: (is_string($quantitaInput) && preg_match('/^[1-9]\d*$/D', $quantitaInput) === 1
? (int) $quantitaInput
: 0);
if ($quantita < 1 || $quantita > 100) {
$_SESSION['error_message'] = __('Numero di copie non valido.');
return $response->withHeader('Location', url($this->adminBookPath($bookId)))->withStatus(302);
}
$stato = $statoInput;
$statiValidi = ['disponibile', 'manutenzione', 'in_restauro', 'perso', 'danneggiato', 'in_trasferimento'];
if (!in_array($stato, $statiValidi, true)) {
$_SESSION['error_message'] = __('Stato non valido.');
return $response->withHeader('Location', url($this->adminBookPath($bookId)))->withStatus(302);
}
$note = $this->sanitizeNote($noteInput);
// Inventory code: honour an explicit value (must be unique), otherwise
// auto-allocate the next collision-free "{base}-C{N}" like book creation.
$numero = trim($numeroInput);
if ($numero !== '') {
$numero = trim((string) preg_replace('/[\x00-\x1F]/', '', $numero));
if (mb_strlen($numero) > 100) {
$numero = mb_substr($numero, 0, 100);
}
}
if ($quantita > 1 && $numero !== '') {
$_SESSION['error_message'] = __('Per aggiungere più copie, lascia vuoto il numero di inventario: verrà assegnato automaticamente a ogni copia.');
return $response->withHeader('Location', url($this->adminBookPath($bookId)))->withStatus(302);
}
$repo = new CopyRepository($db);
$reassignmentService = null;
$reservationManager = null;
$transactionStarted = false;
try {
// Keep the same canonical lock order used by circulation writes:
// book first, then copies/loans. Copy creation, queue processing and
// derived counters must become visible as one atomic change.
if (!$db->begin_transaction()) {
throw new \RuntimeException('Unable to begin the physical-copy creation transaction.');
}
$transactionStarted = true;
$stmt = $db->prepare("SELECT id, numero_inventario FROM libri WHERE id = ? AND deleted_at IS NULL FOR UPDATE");
$stmt->bind_param('i', $bookId);
$stmt->execute();
$book = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$book) {
$db->rollback();
$transactionStarted = false;
$_SESSION['error_message'] = __('Libro non trovato.');
return $response->withHeader('Location', $this->safeReferer())->withStatus(302);
}
// Re-evaluate after sanitisation: an explicit value made only of
// control characters must fall back to automatic allocation.
if ($numero === '') {
$base = CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null);
$newCopyIds = $repo->createManyForBookWithIdsAndNote(
$bookId,
$base,
$quantita,
$stato,
$note !== '' ? $note : null
);
} elseif ($repo->inventoryCodeExists($numero)) {
$db->rollback();
$transactionStarted = false;
$_SESSION['error_message'] = __('Esiste già una copia con questo numero di inventario.');
return $response->withHeader('Location', url($this->adminBookPath($bookId)))->withStatus(302);
} else {
$newCopyId = $repo->create($bookId, $numero, $stato, $note !== '' ? $note : null);
$newCopyIds = $newCopyId > 0 ? [$newCopyId] : [];
}
if (count($newCopyIds) !== $quantita) {
throw new \RuntimeException('Unable to create every requested physical copy.');
}
// A newly available copy is new circulation capacity. Mirror the
// existing book-edit path: first repair blocked copy assignments,
// then promote the next eligible wait-list entry.
if ($stato === 'disponibile') {
$reassignmentService = new ReservationReassignmentService($db);
$reassignmentService->setExternalTransaction(true);
foreach ($newCopyIds as $newCopyId) {
$reassignmentService->reassignOnNewCopy($bookId, $newCopyId);
}
$reservationManager = new ReservationManager($db);
$reservationManager->setExternalTransaction(true);
for ($guard = 0; $guard < 1000 && $reservationManager->processBookAvailability($bookId); $guard++) {
// Promote every date-eligible reservation allowed by the new capacity.
}
}
$integrity = new DataIntegrity($db);
if (!$integrity->recalculateBookAvailability($bookId, insideTransaction: true)) {
throw new \RuntimeException('Unable to recalculate book availability.');
}
if (!$db->commit()) {
throw new \RuntimeException('Unable to commit physical-copy creation.');
}
$transactionStarted = false;
} catch (\Throwable $e) {
if ($transactionStarted) {
$db->rollback();
}
SecureLogger::error('[CopyController] createCopy failed', ['book' => $bookId, 'error' => $e->getMessage()]);
$_SESSION['error_message'] = (int) $e->getCode() === 1062
? __('Esiste già una copia con questo numero di inventario.')
: ($quantita === 1 ? __('Impossibile aggiungere la copia.') : __('Impossibile aggiungere le copie.'));
return $response->withHeader('Location', url($this->adminBookPath($bookId)))->withStatus(302);
}
// Notifications are deliberately emitted only after the transaction that
// made the new assignment/promotion durable.
try {
$reassignmentService?->flushDeferredNotifications();
$reservationManager?->flushDeferredNotifications();
} catch (\Throwable $e) {
SecureLogger::warning(__('Invio notifica nuova copia fallito'), ['error' => $e->getMessage()]);
}
\App\Support\ActivityLog::recordBookEvent(
$db,
$bookId,
'inserimento',
'copy',
'copy.created',
[],
[
'quantita' => $quantita,
'stato' => $stato,
'note' => $note,
'copia_id' => $newCopyIds,
],
entityId: count($newCopyIds) === 1 ? (int) $newCopyIds[0] : null,
source: 'manual'
);
$_SESSION['success_message'] = $quantita === 1
? __('Copia aggiunta con successo.')
: sprintf(__('%d copie aggiunte con successo.'), $quantita);
return $response->withHeader('Location', url($this->adminBookPath($bookId)))->withStatus(302);
}
/**
* Normalize an administrator-entered copy note consistently in create/edit.
*/
private function sanitizeNote(string $note): string
{
$note = trim($note);
if ($note === '') {
return '';
}
// Keep tab/newline for multi-line notes, drop other control characters.
$note = (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $note);
return mb_strlen($note) > 500 ? mb_substr($note, 0, 500) : $note;
}
/**
* Elimina una singola copia
*/
public function deleteCopy(Request $request, Response $response, mysqli $db, int $copyId): Response
{
// CSRF validated by CsrfMiddleware
// Resolve the parent first; the transaction below then follows the
// canonical circulation lock order (book -> copy -> loans).
$stmt = $db->prepare('SELECT libro_id, numero_inventario, stato, note FROM copie WHERE id = ?');
$stmt->bind_param('i', $copyId);
$stmt->execute();
$copy = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$copy) {
$_SESSION['error_message'] = __('Copia non trovata.');
return $response->withHeader('Location', $this->safeReferer())->withStatus(302);
}
$libroId = (int) $copy['libro_id'];
$transactionStarted = false;
try {
if (!$db->begin_transaction()) {
throw new \RuntimeException('Unable to begin copy-delete transaction.');
}
$transactionStarted = true;
$lockBook = $db->prepare('SELECT id FROM libri WHERE id = ? AND deleted_at IS NULL FOR UPDATE');
$lockBook->bind_param('i', $libroId);
$lockBook->execute();
$bookExists = (bool) $lockBook->get_result()->fetch_row();
$lockBook->close();
if (!$bookExists) {
$db->rollback();
$transactionStarted = false;
$_SESSION['error_message'] = __('Libro non trovato o non più disponibile.');
return $response->withHeader('Location', $this->safeReferer())->withStatus(302);
}
// Re-read under lock: state and commitments may have changed since
// the initial parent lookup.
$stmt = $db->prepare('SELECT stato FROM copie WHERE id = ? AND libro_id = ? FOR UPDATE');
$stmt->bind_param('ii', $copyId, $libroId);
$stmt->execute();
$lockedCopy = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$lockedCopy) {
$db->rollback();
$transactionStarted = false;
$_SESSION['error_message'] = __('Copia non trovata.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
// A copy with any current/future commitment or historical loan is
// retained permanently; operators can only move it out of circulation.
if ($this->isCopyHeld($db, $copyId)) {
$db->rollback();
$transactionStarted = false;
$_SESSION['error_message'] = __('Impossibile eliminare una copia attualmente impegnata in un prestito o una prenotazione.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
if (!in_array($lockedCopy['stato'], ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento'], true)) {
$db->rollback();
$transactionStarted = false;
$_SESSION['error_message'] = __('Puoi eliminare solo copie fuori circolazione (perse, danneggiate, in manutenzione, in restauro o in trasferimento). Prima modifica lo stato della copia.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
$stmt = $db->prepare('SELECT 1 FROM prestiti WHERE copia_id = ? LIMIT 1 FOR UPDATE');
$stmt->bind_param('i', $copyId);
$stmt->execute();
$hasHistory = (bool) $stmt->get_result()->fetch_row();
$stmt->close();
if ($hasHistory) {
$db->rollback();
$transactionStarted = false;
$_SESSION['error_message'] = __('Impossibile eliminare la copia: ha uno storico prestiti. Puoi metterla fuori circolazione cambiandone lo stato.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
$stmt = $db->prepare('DELETE FROM copie WHERE id = ?');
$stmt->bind_param('i', $copyId);
$stmt->execute();
$deleted = $stmt->affected_rows;
$stmt->close();
if ($deleted !== 1) {
throw new \RuntimeException('Physical copy delete did not affect exactly one row.');
}
if (!(new DataIntegrity($db))->recalculateBookAvailability($libroId, insideTransaction: true)) {
throw new \RuntimeException('Unable to recalculate availability after copy delete.');
}
if (!$db->commit()) {
throw new \RuntimeException('Unable to commit copy-delete transaction.');
}
$transactionStarted = false;
} catch (\Throwable $e) {
if ($transactionStarted) {
try {
$db->rollback();
} catch (\Throwable $rollbackError) {
// best-effort
}
}
SecureLogger::error('[CopyController] deleteCopy failed', ['copy' => $copyId, 'error' => $e->getMessage()]);
$_SESSION['error_message'] = (int) $e->getCode() === 1451
? __('Impossibile eliminare la copia: ha uno storico prestiti. Puoi metterla fuori circolazione cambiandone lo stato.')
: __('Impossibile eliminare la copia.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
\App\Support\ActivityLog::recordBookEvent(
$db,
$libroId,
'cancellazione',
'copy',
'copy.deleted',
[
'numero_inventario' => $copy['numero_inventario'] ?? null,
'stato' => $copy['stato'] ?? null,
'note' => $copy['note'] ?? null,
],
[],
entityId: $copyId,
source: 'manual'
);
$_SESSION['success_message'] = __('Copia eliminata con successo.');
return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302);
}
}