977 lines
45 KiB
PHP
977 lines
45 KiB
PHP
<?php
|
|
|
|
/**
|
|
* ShopService
|
|
* Logica del negozio della scuola in un unico posto: catalogo, classi valide,
|
|
* posti liberi, situazione dell'allieva. Usata da:
|
|
* - shop.php (negozio dell'allieva nella webapp)
|
|
* - in seguito: checkout, API pubblica per WordPress (apiwp), app mobile
|
|
*
|
|
* Tutti i metodi ricevono $idschool esplicito: funziona anche senza sessione.
|
|
* Posti occupati = prenotazioni 'booked' + 'pending' della lezione (come BookingRules).
|
|
*/
|
|
|
|
if (!class_exists('DBHandlerSelect')) {
|
|
require_once __DIR__ . '/db-functions.php';
|
|
}
|
|
|
|
class ShopService
|
|
{
|
|
/** Giorni di calendario considerati per i posti delle classi fisse */
|
|
const LOOKAHEAD_DAYS = 120;
|
|
/** Lezioni singole/prova acquistabili nei prossimi N giorni */
|
|
const SINGLE_DAYS = 21;
|
|
/** Massimo di lezioni future lette per ogni classe */
|
|
const MAX_OCCURRENCES = 30;
|
|
|
|
const GIORNI = ['domenica', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato'];
|
|
const MESI = [1 => 'gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno', 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre'];
|
|
|
|
/**
|
|
* Catalogo completo, pronto per json_encode (lo stesso formato servirà all'API WordPress).
|
|
*
|
|
* @param int $idschool
|
|
* @param int|null $userId allieva loggata (per prova già usata, tessera, pacchetti attivi)
|
|
* @param bool $onlineOnly true = solo prodotti "acquistabili online"
|
|
*/
|
|
public static function catalog(int $idschool, ?int $userId = null, bool $onlineOnly = true): array
|
|
{
|
|
$pdo = self::pdo();
|
|
|
|
// ---- Prodotti e opzioni
|
|
$st = $pdo->prepare(
|
|
"SELECT * FROM products WHERE idschool = ? AND active = 'Y'" . ($onlineOnly ? " AND visible_online = 'Y'" : "") . "
|
|
ORDER BY FIELD(kind, 'lessons', 'membership', 'voucher'), sort_order, title"
|
|
);
|
|
$st->execute([$idschool]);
|
|
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
$variantsBy = [];
|
|
$st = $pdo->prepare("SELECT * FROM product_variants WHERE idschool = ? AND active = 'Y' ORDER BY sort_order, idvariant");
|
|
$st->execute([$idschool]);
|
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $v) {
|
|
$variantsBy[(int) $v['idproduct']][] = $v;
|
|
}
|
|
|
|
$linksBy = [];
|
|
$st = $pdo->prepare("SELECT idproduct, idservice, idservicecategory FROM product_services WHERE idschool = ?");
|
|
$st->execute([$idschool]);
|
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $l) {
|
|
$linksBy[(int) $l['idproduct']][] = $l;
|
|
}
|
|
|
|
$galleryBy = [];
|
|
$st = $pdo->prepare("SELECT idproduct, filename FROM product_images WHERE idschool = ? ORDER BY sort_order, idimage");
|
|
$st->execute([$idschool]);
|
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $g) {
|
|
$galleryBy[(int) $g['idproduct']][] = $g['filename'];
|
|
}
|
|
|
|
$variantLabel = [];
|
|
$st = $pdo->prepare("SELECT v.idvariant, v.label, p.title FROM product_variants v JOIN products p ON p.idproduct = v.idproduct WHERE v.idschool = ?");
|
|
$st->execute([$idschool]);
|
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
|
$variantLabel[(int) $r['idvariant']] = $r['title'] . ' · ' . $r['label'];
|
|
}
|
|
|
|
// ---- Classi della scuola
|
|
$showTeacher = SchoolSettings::get('show_teacher', 'N', $idschool) === 'Y';
|
|
$st = $pdo->prepare(
|
|
"SELECT s.idservice, s.servicename, s.day, s.time, s.category, s.maxcapacity, s.colorclass, s.classduration,
|
|
TRIM(CONCAT(COALESCE(em.nameemployee, ''), ' ', COALESCE(em.surnameemployee, ''))) AS teacher
|
|
FROM service s
|
|
LEFT JOIN employee em ON em.idemployee = s.idteacher AND em.idschool = s.idschool
|
|
WHERE s.idschool = ?"
|
|
);
|
|
$st->execute([$idschool]);
|
|
$servicesAll = [];
|
|
$byCategory = [];
|
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $s) {
|
|
$servicesAll[(int) $s['idservice']] = $s;
|
|
$byCategory[(int) $s['category']][] = (int) $s['idservice'];
|
|
}
|
|
|
|
$st = $pdo->prepare("SELECT idservicecategory, namecategory FROM servicecategory WHERE idschool = ? ORDER BY namecategory");
|
|
$st->execute([$idschool]);
|
|
$catNames = [];
|
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
|
|
$catNames[(int) $c['idservicecategory']] = $c['namecategory'];
|
|
}
|
|
|
|
// ---- Situazione dell'allieva
|
|
$user = self::userContext($idschool, $userId);
|
|
|
|
// ---- Costruzione prodotti
|
|
$products = [];
|
|
$usedSvcs = [];
|
|
$usedCats = [];
|
|
foreach ($rows as $p) {
|
|
$pid = (int) $p['idproduct'];
|
|
if (empty($variantsBy[$pid])) {
|
|
continue; // nessuna opzione attiva = non acquistabile
|
|
}
|
|
$svc = [];
|
|
foreach ($linksBy[$pid] ?? [] as $l) {
|
|
if (!empty($l['idservice']) && isset($servicesAll[(int) $l['idservice']])) {
|
|
$svc[] = (int) $l['idservice'];
|
|
}
|
|
if (!empty($l['idservicecategory'])) {
|
|
$svc = array_merge($svc, $byCategory[(int) $l['idservicecategory']] ?? []);
|
|
}
|
|
}
|
|
$svc = array_values(array_unique($svc));
|
|
if ($p['kind'] === 'lessons' && !$svc) {
|
|
continue; // pacchetto senza classi: non vendibile
|
|
}
|
|
|
|
$cats = [];
|
|
foreach ($svc as $sid) {
|
|
$cid = (int) $servicesAll[$sid]['category'];
|
|
if (isset($catNames[$cid])) {
|
|
$cats[$cid] = true;
|
|
}
|
|
}
|
|
$cats = array_keys($cats);
|
|
if ($p['kind'] === 'lessons') {
|
|
$usedSvcs = array_merge($usedSvcs, $svc);
|
|
$usedCats = array_merge($usedCats, $cats);
|
|
}
|
|
|
|
$variants = [];
|
|
foreach ($variantsBy[$pid] as $v) {
|
|
$variants[] = [
|
|
'id' => (int) $v['idvariant'],
|
|
'label' => $v['label'],
|
|
'lessons' => $v['lessons'] !== null ? (int) $v['lessons'] : null,
|
|
'price' => (float) $v['price'],
|
|
'compare' => $v['compare_price'] !== null ? (float) $v['compare_price'] : null,
|
|
'note' => $v['price_note'],
|
|
'vmode' => $v['validity_mode'],
|
|
'vvalue' => $v['validity_value'] !== null ? (int) $v['validity_value'] : null,
|
|
'vunit' => $v['validity_unit'],
|
|
'vstart' => $v['validity_start'],
|
|
'target' => $v['target_idvariant'] !== null ? (int) $v['target_idvariant'] : null,
|
|
'targetLabel' => $v['target_idvariant'] !== null ? ($variantLabel[(int) $v['target_idvariant']] ?? '') : '',
|
|
];
|
|
}
|
|
|
|
$mode = $p['kind'] === 'lessons' ? $p['booking_mode'] : $p['kind'];
|
|
$products[] = [
|
|
'id' => $pid,
|
|
'slug' => $p['slug'],
|
|
'kind' => $p['kind'],
|
|
'mode' => $mode, // fixed | free | single | membership | voucher
|
|
'group' => self::group($p), // per i filtri del catalogo
|
|
'slots' => (int) $p['fixed_slots'],
|
|
'weekly' => $p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
|
|
'maxres' => $p['max_reschedule'] !== null ? (int) $p['max_reschedule'] : null,
|
|
'once' => $p['once_per_student'] === 'Y',
|
|
'title' => $p['title'],
|
|
'subtitle' => (string) $p['subtitle'],
|
|
'badge' => (string) $p['badge'],
|
|
'cover' => (string) $p['cover_image'],
|
|
'gallery' => $galleryBy[$pid] ?? [],
|
|
'description' => (string) $p['description'], // HTML già ripulito al salvataggio
|
|
'suitable' => (string) $p['suitable_for'],
|
|
'requirements' => (string) $p['requirements'],
|
|
'pros' => self::lines($p['pros']),
|
|
'cons' => self::lines($p['cons']),
|
|
'services' => $svc,
|
|
'cats' => $cats,
|
|
'variants' => $variants,
|
|
'blocked' => ($p['once_per_student'] === 'Y' && in_array($pid, $user['usedProducts'], true)) ? 'used' : null,
|
|
];
|
|
}
|
|
|
|
// ---- Calendario e posti delle classi usate dai pacchetti
|
|
$services = self::servicesWithOccurrences($idschool, array_values(array_unique($usedSvcs)), $servicesAll, $showTeacher, $userId);
|
|
|
|
// ---- Discipline (categorie) presenti nel catalogo
|
|
$categories = [];
|
|
foreach (array_unique($usedCats) as $cid) {
|
|
$categories[] = ['id' => $cid, 'name' => $catNames[$cid]];
|
|
}
|
|
usort($categories, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
|
|
|
// ---- Date utili (tessere)
|
|
$seasonStart = max(1, min(12, (int) SchoolSettings::get('membership_season_start', 9, $idschool)));
|
|
$today = new DateTimeImmutable('today');
|
|
$y = (int) $today->format('Y');
|
|
$endYear = ((int) $today->format('n') >= $seasonStart) ? $y + 1 : $y;
|
|
$seasonEnd = (new DateTimeImmutable(sprintf('%04d-%02d-01', $endYear, $seasonStart)))->modify('-1 day');
|
|
|
|
// Prodotto tessera da proporre se serve
|
|
$user['membershipProduct'] = null;
|
|
foreach ($products as $p) {
|
|
if ($p['kind'] === 'membership') {
|
|
$user['membershipProduct'] = $p['id'];
|
|
break;
|
|
}
|
|
}
|
|
unset($user['usedProducts']);
|
|
|
|
return [
|
|
'today' => $today->format('Y-m-d'),
|
|
'yearEnd' => $y . '-12-31',
|
|
'seasonEnd' => $seasonEnd->format('Y-m-d'),
|
|
'singleDays' => self::SINGLE_DAYS,
|
|
'products' => $products,
|
|
'services' => (object) $services,
|
|
'categories' => $categories,
|
|
'user' => $user,
|
|
];
|
|
}
|
|
|
|
/** Gruppo del catalogo: single | fixed | free | membership | voucher */
|
|
public static function group(array $p): string
|
|
{
|
|
return $p['kind'] === 'lessons' ? (string) $p['booking_mode'] : (string) $p['kind'];
|
|
}
|
|
|
|
/**
|
|
* Situazione dell'allieva nella scuola: prodotti "una volta" già acquistati,
|
|
* tessera valida, pacchetti attivi.
|
|
*/
|
|
public static function userContext(int $idschool, ?int $userId): array
|
|
{
|
|
$required = SchoolSettings::get('membership_required', 'N', $idschool) === 'Y';
|
|
$ctx = [
|
|
'logged' => (bool) $userId,
|
|
'membershipRequired' => $required,
|
|
'hasMembership' => false,
|
|
'membershipUntil' => null,
|
|
'packages' => [],
|
|
'usedProducts' => [],
|
|
];
|
|
if (!$userId) {
|
|
return $ctx;
|
|
}
|
|
$pdo = self::pdo();
|
|
|
|
$st = $pdo->prepare(
|
|
"SELECT DISTINCT idproduct FROM orderbook
|
|
WHERE iduser = ? AND idschool = ? AND idproduct IS NOT NULL
|
|
AND (payment_status IS NULL OR payment_status NOT IN ('cancelled', 'refunded'))"
|
|
);
|
|
$st->execute([$userId, $idschool]);
|
|
$ctx['usedProducts'] = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
|
|
|
|
$st = $pdo->prepare(
|
|
"SELECT MAX(valid_to) FROM memberships
|
|
WHERE iduser = ? AND idschool = ? AND status = 'active' AND valid_from <= CURDATE() AND valid_to >= CURDATE()"
|
|
);
|
|
$st->execute([$userId, $idschool]);
|
|
$until = $st->fetchColumn();
|
|
$ctx['hasMembership'] = (bool) $until;
|
|
$ctx['membershipUntil'] = $until ?: null;
|
|
|
|
// Pacchetti attivi: non scaduti e con lezioni rimaste
|
|
$st = $pdo->prepare(
|
|
"SELECT ob.idorderbook, ob.product_name, ob.nticket, ob.quantityclass, ob.expireon, ob.payment_status,
|
|
s.servicename,
|
|
(SELECT COUNT(*) FROM bookingclass b
|
|
WHERE b.idorder = ob.idorderbook AND b.idschool = ob.idschool
|
|
AND b.status <> 'cancelled' AND b.is_gift = 'N') AS used
|
|
FROM orderbook ob
|
|
LEFT JOIN service s ON s.idservice = ob.idservice
|
|
WHERE ob.iduser = ? AND ob.idschool = ?
|
|
AND (ob.expireon IS NULL OR ob.expireon >= CURDATE())
|
|
AND (ob.payment_status IS NULL OR ob.payment_status NOT IN ('cancelled', 'refunded'))
|
|
ORDER BY ob.expireon IS NULL, ob.expireon"
|
|
);
|
|
$st->execute([$userId, $idschool]);
|
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $o) {
|
|
$tot = (int) $o['nticket'] > 0 ? (int) $o['nticket'] : (int) $o['quantityclass'];
|
|
$left = $tot > 0 ? max(0, $tot - (int) $o['used']) : null;
|
|
if ($left === 0) {
|
|
continue;
|
|
}
|
|
$ctx['packages'][] = [
|
|
'title' => (string) ($o['product_name'] ?: $o['servicename'] ?: 'Pacchetto'),
|
|
'left' => $left,
|
|
'expire' => $o['expireon'],
|
|
'pending' => $o['payment_status'] === 'pending',
|
|
];
|
|
}
|
|
return $ctx;
|
|
}
|
|
|
|
/**
|
|
* Lezioni future delle classi indicate, con posti liberi.
|
|
* @return array idservice => [name, day, teacher, cap, cat, occ => [[id, date, time, label, free, mine]]]
|
|
*/
|
|
public static function servicesWithOccurrences(int $idschool, array $ids, array $servicesAll, bool $showTeacher, ?int $userId = null): array
|
|
{
|
|
$out = [];
|
|
foreach ($ids as $sid) {
|
|
if (!isset($servicesAll[$sid])) {
|
|
continue;
|
|
}
|
|
$s = $servicesAll[$sid];
|
|
$out[$sid] = [
|
|
'name' => $s['servicename'],
|
|
'day' => trim($s['day'] . ' ' . $s['time']),
|
|
'teacher' => $showTeacher ? trim((string) $s['teacher']) : '',
|
|
'cap' => ($s['maxcapacity'] !== null && (int) $s['maxcapacity'] > 0) ? (int) $s['maxcapacity'] : null,
|
|
'cat' => (int) $s['category'],
|
|
'color' => preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($s['colorclass'] ?? '')) ? strtolower($s['colorclass']) : null,
|
|
'minutes' => (int) round((float) ($s['classduration'] ?? 0) * ((float) ($s['classduration'] ?? 0) <= 5 ? 60 : 1)),
|
|
'occ' => [],
|
|
];
|
|
}
|
|
if (!$out) {
|
|
return $out;
|
|
}
|
|
|
|
$ph = implode(',', array_fill(0, count($out), '?'));
|
|
$st = self::pdo()->prepare(
|
|
"SELECT ss.idserviceschedule, ss.idservice, ss.dateschedule,
|
|
(SELECT COUNT(*) FROM bookingclass b
|
|
WHERE b.idserviceschedule = ss.idserviceschedule AND b.idschool = ss.idschool
|
|
AND b.status IN ('booked', 'pending')) AS taken
|
|
" . ($userId ? ", (SELECT COUNT(*) FROM bookingclass b2
|
|
WHERE b2.idserviceschedule = ss.idserviceschedule AND b2.idschool = ss.idschool
|
|
AND b2.iduser = ? AND b2.status <> 'cancelled') AS mine" : ", 0 AS mine") . "
|
|
FROM serviceschedule ss
|
|
WHERE ss.idschool = ?
|
|
AND ss.idservice IN ($ph)
|
|
AND ss.dateschedule > NOW()
|
|
AND ss.dateschedule < CURDATE() + INTERVAL " . (int) self::LOOKAHEAD_DAYS . " DAY
|
|
AND NOT EXISTS (SELECT 1 FROM dayoff d WHERE d.idschool = ss.idschool AND d.dayoffdate = DATE(ss.dateschedule))
|
|
ORDER BY ss.dateschedule"
|
|
);
|
|
$args = $userId ? [$userId, $idschool] : [$idschool];
|
|
$st->execute(array_merge($args, array_keys($out)));
|
|
|
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $o) {
|
|
$sid = (int) $o['idservice'];
|
|
if (count($out[$sid]['occ']) >= self::MAX_OCCURRENCES) {
|
|
continue;
|
|
}
|
|
$ts = strtotime($o['dateschedule']);
|
|
$cap = $out[$sid]['cap'];
|
|
$out[$sid]['occ'][] = [
|
|
'id' => (int) $o['idserviceschedule'],
|
|
'date' => date('Y-m-d', $ts),
|
|
'time' => date('H:i', $ts),
|
|
'label' => self::dateLabel($ts),
|
|
'free' => $cap === null ? null : max(0, $cap - (int) $o['taken']),
|
|
'mine' => (int) $o['mine'] > 0,
|
|
];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/** "Martedì 13 ottobre · 18:00" */
|
|
public static function dateLabel(int $ts): string
|
|
{
|
|
return ucfirst(self::GIORNI[(int) date('w', $ts)]) . ' ' . date('j', $ts) . ' ' . self::MESI[(int) date('n', $ts)] . ' · ' . date('H:i', $ts);
|
|
}
|
|
|
|
public static function lines(?string $t): array
|
|
{
|
|
return array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', (string) $t))));
|
|
}
|
|
|
|
/* ==================================================================
|
|
* CHECKOUT
|
|
* ================================================================ */
|
|
|
|
/** Metodi di pagamento attivi per la scuola */
|
|
public static function paymentMethods(int $idschool): array
|
|
{
|
|
$bank = SchoolSettings::get('bank_enabled', 'N', $idschool) === 'Y'
|
|
&& trim((string) SchoolSettings::get('bank_holder', '', $idschool)) !== ''
|
|
&& trim((string) SchoolSettings::get('bank_iban', '', $idschool)) !== '';
|
|
// La carta arriverà con il collegamento Stripe Connect della scuola
|
|
$card = SchoolSettings::get('stripe_charges_enabled', 'N', $idschool) === 'Y'
|
|
&& trim((string) SchoolSettings::get('stripe_account_id', '', $idschool)) !== '';
|
|
return ['bank' => $bank, 'card' => $card];
|
|
}
|
|
|
|
/**
|
|
* Ricontrolla lato server la scelta arrivata dal negozio.
|
|
* $in: idvariant, services (array di idservice), idschedule, recipient_name, recipient_email, recipient_message
|
|
* @return array product, variant, services, schedule, recipient
|
|
* @throws RuntimeException con messaggio per l'allieva
|
|
*/
|
|
public static function selection(int $idschool, int $userId, array $in): array
|
|
{
|
|
$pdo = self::pdo();
|
|
$st = $pdo->prepare(
|
|
"SELECT v.*, p.kind, p.title, p.booking_mode, p.fixed_slots, p.weekly_limit, p.max_reschedule,
|
|
p.once_per_student, p.active AS p_active, p.visible_online, p.slug
|
|
FROM product_variants v
|
|
JOIN products p ON p.idproduct = v.idproduct AND p.idschool = v.idschool
|
|
WHERE v.idvariant = ? AND v.idschool = ?"
|
|
);
|
|
$st->execute([(int) ($in['idvariant'] ?? 0), $idschool]);
|
|
$row = $st->fetch(PDO::FETCH_ASSOC);
|
|
if (!$row || $row['active'] !== 'Y' || $row['p_active'] !== 'Y' || $row['visible_online'] !== 'Y') {
|
|
throw new RuntimeException('Questo prodotto non è più disponibile.');
|
|
}
|
|
$pid = (int) $row['idproduct'];
|
|
$mode = $row['kind'] === 'lessons' ? $row['booking_mode'] : $row['kind'];
|
|
|
|
if ($row['once_per_student'] === 'Y') {
|
|
$st = $pdo->prepare(
|
|
"SELECT 1 FROM orderbook WHERE iduser = ? AND idschool = ? AND idproduct = ?
|
|
AND (payment_status IS NULL OR payment_status NOT IN ('cancelled', 'refunded')) LIMIT 1"
|
|
);
|
|
$st->execute([$userId, $idschool, $pid]);
|
|
if ($st->fetchColumn()) {
|
|
throw new RuntimeException('Hai già utilizzato questo prodotto: si può acquistare una sola volta.');
|
|
}
|
|
}
|
|
|
|
$allowed = self::productServiceIds($idschool, $pid);
|
|
$sel = [
|
|
'product' => $row,
|
|
'mode' => $mode,
|
|
'variant' => $row,
|
|
'services' => [],
|
|
'schedule' => null,
|
|
'recipient' => null,
|
|
];
|
|
|
|
if ($mode === 'fixed') {
|
|
$slots = (int) $row['fixed_slots'] === 2 ? 2 : 1;
|
|
$ids = array_values(array_unique(array_filter(array_map('intval', (array) ($in['services'] ?? [])))));
|
|
if (count($ids) !== $slots) {
|
|
throw new RuntimeException($slots === 2 ? 'Scegli le tue due classi.' : 'Scegli la tua classe.');
|
|
}
|
|
$weeks = $row['lessons'] ? (int) ceil((int) $row['lessons'] / $slots) : 8;
|
|
foreach ($ids as $sid) {
|
|
if (!in_array($sid, $allowed, true)) {
|
|
throw new RuntimeException('Una delle classi scelte non è inclusa in questo prodotto.');
|
|
}
|
|
$free = self::fixedFree($idschool, $sid, $weeks);
|
|
if ($free === -1) {
|
|
throw new RuntimeException('Una delle classi scelte non ha lezioni in calendario.');
|
|
}
|
|
if ($free === 0) {
|
|
throw new RuntimeException('Una delle classi scelte è al completo. Torna indietro e scegline un\'altra.');
|
|
}
|
|
}
|
|
$sel['services'] = $ids;
|
|
}
|
|
|
|
if ($mode === 'single') {
|
|
$st = $pdo->prepare(
|
|
"SELECT ss.*, s.servicename, s.maxcapacity FROM serviceschedule ss
|
|
JOIN service s ON s.idservice = ss.idservice
|
|
WHERE ss.idserviceschedule = ? AND ss.idschool = ? AND ss.dateschedule > NOW()"
|
|
);
|
|
$st->execute([(int) ($in['idschedule'] ?? 0), $idschool]);
|
|
$sch = $st->fetch(PDO::FETCH_ASSOC);
|
|
if (!$sch || !in_array((int) $sch['idservice'], $allowed, true)) {
|
|
throw new RuntimeException('La lezione scelta non è più disponibile.');
|
|
}
|
|
$st = $pdo->prepare("SELECT 1 FROM bookingclass WHERE idserviceschedule = ? AND iduser = ? AND idschool = ? AND status <> 'cancelled' LIMIT 1");
|
|
$st->execute([(int) $sch['idserviceschedule'], $userId, $idschool]);
|
|
if ($st->fetchColumn()) {
|
|
throw new RuntimeException('Sei già iscritta a questa lezione.');
|
|
}
|
|
if ((int) $sch['maxcapacity'] > 0 && self::occupied($idschool, (int) $sch['idserviceschedule']) >= (int) $sch['maxcapacity']) {
|
|
throw new RuntimeException('La lezione scelta è al completo. Torna indietro e scegli un\'altra data.');
|
|
}
|
|
$sel['schedule'] = $sch;
|
|
}
|
|
|
|
if ($mode === 'voucher') {
|
|
$name = trim((string) ($in['recipient_name'] ?? ''));
|
|
$email = trim((string) ($in['recipient_email'] ?? ''));
|
|
if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
throw new RuntimeException('Indica nome ed email di chi riceve il buono.');
|
|
}
|
|
if (empty($row['target_idvariant'])) {
|
|
throw new RuntimeException('Questo buono non è configurato correttamente. Contatta la scuola.');
|
|
}
|
|
$sel['recipient'] = [
|
|
'name' => mb_substr($name, 0, 191),
|
|
'email' => mb_substr($email, 0, 191),
|
|
'message' => mb_substr(trim((string) ($in['recipient_message'] ?? '')), 0, 500),
|
|
];
|
|
}
|
|
return $sel;
|
|
}
|
|
|
|
/** idservice validi per un prodotto (classi singole + categorie intere) */
|
|
public static function productServiceIds(int $idschool, int $idproduct): array
|
|
{
|
|
$st = self::pdo()->prepare(
|
|
"SELECT DISTINCT s.idservice
|
|
FROM product_services ps
|
|
JOIN service s ON s.idschool = ps.idschool
|
|
AND (s.idservice = ps.idservice OR (ps.idservicecategory IS NOT NULL AND s.category = ps.idservicecategory))
|
|
WHERE ps.idproduct = ? AND ps.idschool = ?"
|
|
);
|
|
$st->execute([$idproduct, $idschool]);
|
|
return array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
|
|
}
|
|
|
|
public static function occupied(int $idschool, int $idschedule): int
|
|
{
|
|
$st = self::pdo()->prepare(
|
|
"SELECT COUNT(*) FROM bookingclass WHERE idserviceschedule = ? AND idschool = ? AND status IN ('booked', 'pending')"
|
|
);
|
|
$st->execute([$idschedule, $idschool]);
|
|
return (int) $st->fetchColumn();
|
|
}
|
|
|
|
/**
|
|
* Posti liberi minimi sulle prossime $weeks lezioni di una classe.
|
|
* null = senza limite, -1 = nessuna lezione in calendario
|
|
*/
|
|
public static function fixedFree(int $idschool, int $idservice, int $weeks): ?int
|
|
{
|
|
$pdo = self::pdo();
|
|
$st = $pdo->prepare("SELECT maxcapacity FROM service WHERE idservice = ? AND idschool = ?");
|
|
$st->execute([$idservice, $idschool]);
|
|
$cap = (int) $st->fetchColumn();
|
|
|
|
$st = $pdo->prepare(
|
|
"SELECT (SELECT COUNT(*) FROM bookingclass b WHERE b.idserviceschedule = ss.idserviceschedule
|
|
AND b.idschool = ss.idschool AND b.status IN ('booked', 'pending')) AS taken
|
|
FROM serviceschedule ss
|
|
WHERE ss.idservice = ? AND ss.idschool = ? AND ss.dateschedule > NOW()
|
|
AND NOT EXISTS (SELECT 1 FROM dayoff d WHERE d.idschool = ss.idschool AND d.dayoffdate = DATE(ss.dateschedule))
|
|
ORDER BY ss.dateschedule
|
|
LIMIT " . max(1, $weeks)
|
|
);
|
|
$st->execute([$idservice, $idschool]);
|
|
$taken = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
|
|
if (!$taken) {
|
|
return -1;
|
|
}
|
|
return $cap > 0 ? max(0, $cap - max($taken)) : null;
|
|
}
|
|
|
|
/**
|
|
* Verifica un codice sconto per l'opzione scelta (l'utente è sempre quello della sessione).
|
|
* @return array idcoupon, code, discount
|
|
* @throws RuntimeException
|
|
*/
|
|
public static function checkCoupon(int $idschool, int $userId, string $code, array $variant): array
|
|
{
|
|
$code = strtoupper(trim($code));
|
|
$price = (float) $variant['price'];
|
|
$pdo = self::pdo();
|
|
|
|
$st = $pdo->prepare("SELECT * FROM coupons WHERE idschool = ? AND code = ? AND active = 'Y' LIMIT 1");
|
|
$st->execute([$idschool, $code]);
|
|
$c = $st->fetch(PDO::FETCH_ASSOC);
|
|
$today = date('Y-m-d');
|
|
if (!$c || ($c['valid_from'] && $today < $c['valid_from']) || ($c['valid_to'] && $today > $c['valid_to'])) {
|
|
throw new RuntimeException('Codice non valido o scaduto.');
|
|
}
|
|
|
|
$st = $pdo->prepare("SELECT idproduct, idvariant FROM coupon_products WHERE idcoupon = ? AND idschool = ?");
|
|
$st->execute([(int) $c['idcoupon'], $idschool]);
|
|
$limits = $st->fetchAll(PDO::FETCH_ASSOC);
|
|
if ($limits) {
|
|
$ok = false;
|
|
foreach ($limits as $l) {
|
|
if (
|
|
(int) $l['idvariant'] === (int) $variant['idvariant']
|
|
|| ((int) $l['idproduct'] === (int) $variant['idproduct'] && empty($l['idvariant']))
|
|
) {
|
|
$ok = true;
|
|
}
|
|
}
|
|
if (!$ok) {
|
|
throw new RuntimeException('Questo codice non vale su questo prodotto.');
|
|
}
|
|
}
|
|
if ($c['min_amount'] !== null && $price < (float) $c['min_amount']) {
|
|
throw new RuntimeException('Codice valido per acquisti da ' . number_format((float) $c['min_amount'], 2, ',', '.') . ' €.');
|
|
}
|
|
if ($c['max_uses'] !== null) {
|
|
$st = $pdo->prepare("SELECT COUNT(*) FROM coupon_redemptions WHERE idcoupon = ? AND idschool = ?");
|
|
$st->execute([(int) $c['idcoupon'], $idschool]);
|
|
if ((int) $st->fetchColumn() >= (int) $c['max_uses']) {
|
|
throw new RuntimeException('Codice esaurito.');
|
|
}
|
|
}
|
|
if ($c['max_uses_per_user'] !== null) {
|
|
$st = $pdo->prepare("SELECT COUNT(*) FROM coupon_redemptions WHERE idcoupon = ? AND idschool = ? AND iduser = ?");
|
|
$st->execute([(int) $c['idcoupon'], $idschool, $userId]);
|
|
if ((int) $st->fetchColumn() >= (int) $c['max_uses_per_user']) {
|
|
throw new RuntimeException('Hai già usato questo codice.');
|
|
}
|
|
}
|
|
if ($c['new_students_only'] === 'Y') {
|
|
$st = $pdo->prepare("SELECT COUNT(*) FROM orderbook WHERE iduser = ? AND idschool = ?");
|
|
$st->execute([$userId, $idschool]);
|
|
if ((int) $st->fetchColumn() > 0) {
|
|
throw new RuntimeException('Codice riservato alle nuove allieve.');
|
|
}
|
|
}
|
|
$value = (float) $c['discount_value'];
|
|
$discount = $c['discount_type'] === 'fixed' ? min($value, $price) : round($price * min($value, 100) / 100, 2);
|
|
return ['idcoupon' => (int) $c['idcoupon'], 'code' => $c['code'], 'discount' => $discount];
|
|
}
|
|
|
|
/** Scadenza della tessera per un'opzione (Y-m-d o null) */
|
|
public static function membershipEnd(int $idschool, array $variant, ?string $from = null): ?string
|
|
{
|
|
$from = $from ?? date('Y-m-d');
|
|
if ($variant['validity_mode'] === 'calendar_year') {
|
|
return substr($from, 0, 4) . '-12-31';
|
|
}
|
|
if ($variant['validity_mode'] === 'season') {
|
|
$start = max(1, min(12, (int) SchoolSettings::get('membership_season_start', 9, $idschool)));
|
|
$y = (int) substr($from, 0, 4);
|
|
$endYear = ((int) substr($from, 5, 2) >= $start) ? $y + 1 : $y;
|
|
return (new DateTimeImmutable(sprintf('%04d-%02d-01', $endYear, $start)))->modify('-1 day')->format('Y-m-d');
|
|
}
|
|
if (!empty($variant['validity_value'])) {
|
|
return self::addPeriod($from, (int) $variant['validity_value'], (string) $variant['validity_unit'])->modify('-1 day')->format('Y-m-d');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public static function addPeriod(string $from, int $n, string $unit): DateTimeImmutable
|
|
{
|
|
$d = new DateTimeImmutable($from);
|
|
if ($unit === 'days') {
|
|
return $d->modify('+' . $n . ' days');
|
|
}
|
|
if ($unit === 'weeks') {
|
|
return $d->modify('+' . ($n * 7) . ' days');
|
|
}
|
|
// mesi senza sforare (31 gennaio + 1 mese = fine febbraio)
|
|
$y = (int) $d->format('Y');
|
|
$m = (int) $d->format('n') + $n;
|
|
$y += intdiv($m - 1, 12);
|
|
$m = (($m - 1) % 12) + 1;
|
|
$last = (int) (new DateTimeImmutable(sprintf('%04d-%02d-01', $y, $m)))->format('t');
|
|
return $d->setDate($y, $m, min((int) $d->format('j'), $last));
|
|
}
|
|
|
|
/**
|
|
* Crea l'ordine (orderbook) e tutto ciò che ne deriva, in un'unica transazione.
|
|
* - classe fissa: orderbook_services (le prenotazioni le crea la scuola alla propagazione)
|
|
* - lezione singola: prenotazione subito (bonifico = 'pending', carta = 'booked')
|
|
* - buono regalo: gift_vouchers in attesa del pagamento
|
|
* - tessera: la riga in memberships nasce alla conferma del pagamento
|
|
* @param string $method 'bank' | 'stripe'
|
|
* @return int idorderbook
|
|
*/
|
|
public static function createOrder(int $idschool, int $userId, array $sel, string $method, ?array $coupon): int
|
|
{
|
|
$pdo = self::pdo();
|
|
$p = $sel['product'];
|
|
$v = $sel['variant'];
|
|
$mode = $sel['mode'];
|
|
$paid = $method === 'stripe';
|
|
|
|
$st = $pdo->prepare("SELECT first_name, last_name, email FROM auth_users WHERE id = ?");
|
|
$st->execute([$userId]);
|
|
$u = $st->fetch(PDO::FETCH_ASSOC) ?: ['first_name' => '', 'last_name' => '', 'email' => ''];
|
|
|
|
$price = round((float) $v['price'], 2);
|
|
$discount = $coupon ? min(round((float) $coupon['discount'], 2), $price) : 0.0;
|
|
$total = round($price - $discount, 2);
|
|
$lessons = $v['lessons'] !== null ? (int) $v['lessons'] : null;
|
|
$today = date('Y-m-d');
|
|
|
|
// Scadenza
|
|
$expire = null;
|
|
if ($mode === 'free' && $v['validity_mode'] === 'duration' && $v['validity_value'] && $v['validity_start'] === 'purchase') {
|
|
$expire = self::addPeriod($today, (int) $v['validity_value'], (string) $v['validity_unit'])->format('Y-m-d');
|
|
} elseif ($mode === 'single' && $sel['schedule']) {
|
|
$expire = substr($sel['schedule']['dateschedule'], 0, 10);
|
|
} elseif ($mode === 'membership') {
|
|
$expire = self::membershipEnd($idschool, $v, $today);
|
|
}
|
|
|
|
// Recuperi: dal prodotto, altrimenti dai parametri della scuola per quel numero di lezioni
|
|
$maxres = $p['max_reschedule'] !== null ? (int) $p['max_reschedule'] : null;
|
|
if ($maxres === null && $lessons && in_array($mode, ['fixed', 'free'], true)) {
|
|
$st = $pdo->prepare("SELECT reschedulenumber FROM expiryparameter WHERE quantityclass = ? AND idschool = ? LIMIT 1");
|
|
$st->execute([$lessons, $idschool]);
|
|
$r = $st->fetchColumn();
|
|
$maxres = $r !== false ? (int) $r : null;
|
|
}
|
|
if ($mode === 'single') {
|
|
$maxres = 0;
|
|
}
|
|
|
|
$idservice = null;
|
|
if ($mode === 'fixed') {
|
|
$idservice = $sel['services'][0];
|
|
} elseif ($mode === 'single') {
|
|
$idservice = (int) $sel['schedule']['idservice'];
|
|
}
|
|
|
|
$snapshot = [
|
|
'mode' => $mode,
|
|
'fixed_slots' => (int) $p['fixed_slots'],
|
|
'weekly_limit' => $p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
|
|
'max_reschedule' => $maxres,
|
|
'lessons' => $lessons,
|
|
'validity_mode' => $v['validity_mode'],
|
|
'validity_value' => $v['validity_value'] !== null ? (int) $v['validity_value'] : null,
|
|
'validity_unit' => $v['validity_unit'],
|
|
'validity_start' => $v['validity_start'],
|
|
'services' => $mode === 'free' || $mode === 'single' ? self::productServiceIds($idschool, (int) $p['idproduct']) : $sel['services'],
|
|
'price' => $price,
|
|
];
|
|
|
|
$pdo->beginTransaction();
|
|
try {
|
|
// Lezione singola: blocca la riga della lezione e ricontrolla il posto
|
|
if ($mode === 'single') {
|
|
$st = $pdo->prepare("SELECT ss.idserviceschedule, s.maxcapacity FROM serviceschedule ss JOIN service s ON s.idservice = ss.idservice WHERE ss.idserviceschedule = ? AND ss.idschool = ? FOR UPDATE");
|
|
$st->execute([(int) $sel['schedule']['idserviceschedule'], $idschool]);
|
|
$lock = $st->fetch(PDO::FETCH_ASSOC);
|
|
if (!$lock || ((int) $lock['maxcapacity'] > 0 && self::occupied($idschool, (int) $lock['idserviceschedule']) >= (int) $lock['maxcapacity'])) {
|
|
throw new RuntimeException('Nel frattempo la lezione si è riempita. Scegli un\'altra data.');
|
|
}
|
|
}
|
|
|
|
$pdo->prepare(
|
|
"INSERT INTO orderbook
|
|
(order_date_created, order_billing_email, order_total, product_name, line_total, quantity,
|
|
idservice, is_openflexy, iduser, nticket, expireon, status, quantityclass,
|
|
first_name, last_name, maxreschedule, reprogrammed, idschool,
|
|
idproduct, idvariant, booking_mode, weekly_limit, rules_snapshot, source, payment_status,
|
|
amount, discount_amount, idcoupon)
|
|
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
)->execute([
|
|
$today,
|
|
$u['email'],
|
|
number_format($total, 2, '.', ''),
|
|
mb_substr($p['title'] . ' · ' . $v['label'], 0, 200),
|
|
number_format($total, 2, '.', ''),
|
|
$idservice,
|
|
$mode === 'free' ? 'Y' : 'N',
|
|
$userId,
|
|
$lessons,
|
|
$expire,
|
|
$paid ? ($mode === 'fixed' ? 'pending' : 'completed') : 'on-hold',
|
|
$lessons,
|
|
$u['first_name'],
|
|
$u['last_name'],
|
|
$maxres,
|
|
$idschool,
|
|
(int) $p['idproduct'],
|
|
(int) $v['idvariant'],
|
|
in_array($mode, ['fixed', 'free', 'single'], true) ? $mode : null,
|
|
$p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
|
|
json_encode($snapshot, JSON_UNESCAPED_UNICODE),
|
|
$method,
|
|
$paid ? 'paid' : 'pending',
|
|
$total,
|
|
$discount > 0 ? $discount : null,
|
|
$coupon ? (int) $coupon['idcoupon'] : null,
|
|
]);
|
|
$idorder = (int) $pdo->lastInsertId();
|
|
|
|
if ($mode === 'fixed') {
|
|
$ins = $pdo->prepare("INSERT INTO orderbook_services (idorderbook, idschool, idservice, slot) VALUES (?, ?, ?, ?)");
|
|
foreach ($sel['services'] as $i => $sid) {
|
|
$ins->execute([$idorder, $idschool, $sid, $i + 1]);
|
|
}
|
|
}
|
|
|
|
if ($mode === 'single') {
|
|
$pdo->prepare(
|
|
"INSERT INTO bookingclass (idserviceschedule, bookingstart, status, idservice, iduser, idorder, cancellation_token, idschool)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
)->execute([
|
|
(int) $sel['schedule']['idserviceschedule'],
|
|
$sel['schedule']['dateschedule'],
|
|
$paid ? 'booked' : 'pending',
|
|
(int) $sel['schedule']['idservice'],
|
|
$userId,
|
|
$idorder,
|
|
bin2hex(random_bytes(16)),
|
|
$idschool,
|
|
]);
|
|
}
|
|
|
|
if ($mode === 'voucher') {
|
|
$codeSt = $pdo->prepare("SELECT 1 FROM gift_vouchers WHERE idschool = ? AND code = ?");
|
|
do {
|
|
$code = 'YB-' . self::randomCode(4) . '-' . self::randomCode(4);
|
|
$codeSt->execute([$idschool, $code]);
|
|
} while ($codeSt->fetchColumn());
|
|
$vExpire = $v['validity_value'] ? self::addPeriod($today, (int) $v['validity_value'], (string) $v['validity_unit'])->format('Y-m-d') : null;
|
|
$pdo->prepare(
|
|
"INSERT INTO gift_vouchers (idschool, code, idvariant, buyer_iduser, buyer_name, buyer_email, recipient_name, recipient_email,
|
|
message, source, amount, expires_at, status)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
)->execute([
|
|
$idschool,
|
|
$code,
|
|
(int) $v['target_idvariant'],
|
|
$userId,
|
|
trim($u['first_name'] . ' ' . $u['last_name']),
|
|
$u['email'],
|
|
$sel['recipient']['name'],
|
|
$sel['recipient']['email'],
|
|
$sel['recipient']['message'] ?: null,
|
|
$method,
|
|
$total,
|
|
$vExpire,
|
|
$paid ? 'active' : 'pending',
|
|
]);
|
|
// collega il buono all'ordine (per attivarlo alla conferma del pagamento)
|
|
$pdo->prepare("UPDATE orderbook SET cod = ? WHERE idorderbook = ?")->execute([$code, $idorder]);
|
|
}
|
|
|
|
if ($mode === 'membership' && $paid) {
|
|
$pdo->prepare("INSERT INTO memberships (idschool, iduser, idorderbook, valid_from, valid_to, status) VALUES (?, ?, ?, ?, ?, 'active')")
|
|
->execute([$idschool, $userId, $idorder, $today, $expire ?? '2099-12-31']);
|
|
}
|
|
|
|
if ($coupon) {
|
|
$pdo->prepare("INSERT INTO coupon_redemptions (idcoupon, idschool, iduser, idorderbook, discount_amount) VALUES (?, ?, ?, ?, ?)")
|
|
->execute([(int) $coupon['idcoupon'], $idschool, $userId, $idorder, $discount]);
|
|
}
|
|
|
|
$pdo->commit();
|
|
return $idorder;
|
|
} catch (Throwable $ex) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $ex;
|
|
}
|
|
}
|
|
|
|
/** Dati del bonifico per un ordine: intestatario, IBAN, banca, causale, scadenza */
|
|
public static function bankInfo(int $idschool, array $order): array
|
|
{
|
|
$tpl = trim((string) SchoolSettings::get('bank_reference', '', $idschool)) ?: 'Ordine {ordine} - {nome}';
|
|
$iban = strtoupper(preg_replace('/\s+/', '', (string) SchoolSettings::get('bank_iban', '', $idschool)));
|
|
$days = max(1, (int) SchoolSettings::get('bank_due_days', 5, $idschool));
|
|
$created = $order['order_date_created'] ?: date('Y-m-d');
|
|
return [
|
|
'holder' => (string) SchoolSettings::get('bank_holder', '', $idschool),
|
|
'iban' => trim(chunk_split($iban, 4, ' ')),
|
|
'bank' => (string) SchoolSettings::get('bank_name', '', $idschool),
|
|
'reference' => mb_substr(strtr($tpl, ['{ordine}' => (string) $order['idorderbook'], '{nome}' => trim($order['first_name'] . ' ' . $order['last_name'])]), 0, 140),
|
|
'due' => date('Y-m-d', strtotime($created . ' +' . $days . ' days')),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* La scuola conferma il pagamento (bonifico ricevuto) di un ordine in attesa.
|
|
* - ordine pagato
|
|
* - lezione singola: la prenotazione passa da 'pending' a 'booked'
|
|
* - tessera: nasce la riga in memberships
|
|
* - buono regalo: diventa attivo
|
|
* Idempotente: false se l'ordine non è (più) in attesa.
|
|
*/
|
|
public static function markPaid(int $idschool, int $idorder): bool
|
|
{
|
|
$pdo = self::pdo();
|
|
$pdo->beginTransaction();
|
|
try {
|
|
$st = $pdo->prepare(
|
|
"SELECT ob.*, p.kind FROM orderbook ob LEFT JOIN products p ON p.idproduct = ob.idproduct
|
|
WHERE ob.idorderbook = ? AND ob.idschool = ? FOR UPDATE"
|
|
);
|
|
$st->execute([$idorder, $idschool]);
|
|
$o = $st->fetch(PDO::FETCH_ASSOC);
|
|
if (!$o || $o['payment_status'] !== 'pending') {
|
|
$pdo->rollBack();
|
|
return false;
|
|
}
|
|
|
|
// Classe fissa: 'pending' = da propagare (stesso significato degli ordini storici)
|
|
$pdo->prepare("UPDATE orderbook SET payment_status = 'paid', status = ? WHERE idorderbook = ? AND idschool = ?")
|
|
->execute([$o['booking_mode'] === 'fixed' ? 'pending' : 'completed', $idorder, $idschool]);
|
|
|
|
if ($o['booking_mode'] === 'single') {
|
|
$pdo->prepare("UPDATE bookingclass SET status = 'booked' WHERE idorder = ? AND idschool = ? AND status = 'pending'")
|
|
->execute([$idorder, $idschool]);
|
|
}
|
|
if ($o['kind'] === 'membership') {
|
|
$pdo->prepare("INSERT INTO memberships (idschool, iduser, idorderbook, valid_from, valid_to, status) VALUES (?, ?, ?, CURDATE(), ?, 'active')")
|
|
->execute([$idschool, (int) $o['iduser'], $idorder, $o['expireon'] ?: '2099-12-31']);
|
|
}
|
|
if (!empty($o['cod'])) {
|
|
$pdo->prepare("UPDATE gift_vouchers SET status = 'active' WHERE idschool = ? AND code = ? AND status = 'pending'")
|
|
->execute([$idschool, $o['cod']]);
|
|
}
|
|
$pdo->commit();
|
|
return true;
|
|
} catch (Throwable $ex) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $ex;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Annulla un ordine in attesa di pagamento (dall'allieva o dalla scuola):
|
|
* libera il posto della lezione singola, annulla il buono e l'uso del coupon.
|
|
* $userId: se indicato, solo se l'ordine è suo.
|
|
*/
|
|
public static function cancelPending(int $idschool, int $idorder, ?int $userId = null): bool
|
|
{
|
|
$pdo = self::pdo();
|
|
$pdo->beginTransaction();
|
|
try {
|
|
$sql = "SELECT * FROM orderbook WHERE idorderbook = ? AND idschool = ?" . ($userId ? " AND iduser = ?" : "") . " FOR UPDATE";
|
|
$args = $userId ? [$idorder, $idschool, $userId] : [$idorder, $idschool];
|
|
$st = $pdo->prepare($sql);
|
|
$st->execute($args);
|
|
$o = $st->fetch(PDO::FETCH_ASSOC);
|
|
if (!$o || $o['payment_status'] !== 'pending') {
|
|
$pdo->rollBack();
|
|
return false;
|
|
}
|
|
$pdo->prepare("UPDATE orderbook SET payment_status = 'cancelled', status = 'cancelled' WHERE idorderbook = ? AND idschool = ?")
|
|
->execute([$idorder, $idschool]);
|
|
$pdo->prepare("UPDATE bookingclass SET status = 'cancelled' WHERE idorder = ? AND idschool = ? AND status <> 'cancelled'")
|
|
->execute([$idorder, $idschool]);
|
|
$pdo->prepare("DELETE FROM coupon_redemptions WHERE idorderbook = ? AND idschool = ?")->execute([$idorder, $idschool]);
|
|
if (!empty($o['cod'])) {
|
|
$pdo->prepare("UPDATE gift_vouchers SET status = 'cancelled' WHERE idschool = ? AND code = ? AND status = 'pending'")
|
|
->execute([$idschool, $o['cod']]);
|
|
}
|
|
$pdo->commit();
|
|
return true;
|
|
} catch (Throwable $ex) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $ex;
|
|
}
|
|
}
|
|
|
|
/** Ordini su cui la scuola deve agire: da pagare + da propagare (contatore in sidebar) */
|
|
public static function pendingCount(int $idschool): int
|
|
{
|
|
$st = self::pdo()->prepare(
|
|
"SELECT COUNT(*) FROM orderbook
|
|
WHERE idschool = ?
|
|
AND (payment_status = 'pending'
|
|
OR (status = 'pending' AND (payment_status IS NULL OR payment_status = 'paid')))"
|
|
);
|
|
$st->execute([$idschool]);
|
|
return (int) $st->fetchColumn();
|
|
}
|
|
|
|
private static function randomCode(int $len): string
|
|
{
|
|
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
$out = '';
|
|
for ($i = 0; $i < $len; $i++) {
|
|
$out .= $chars[random_int(0, strlen($chars) - 1)];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
private static function pdo(): PDO
|
|
{
|
|
return DBHandlerSelect::getInstance()->getConnection();
|
|
}
|
|
}
|