From fffdd32f64b35a6a86669855bec1aa9551f4f9a7 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 11:01:13 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20full=20scaffolding=20=E2=80=94=20manife?= =?UTF-8?q?sts,=20Extension,=20provider,=20SQL=20(7=20tables),=204=20helpe?= =?UTF-8?q?rs=20(Catalog,=20Checkout,=20Reservation,=20Fine),=20language?= =?UTF-8?q?=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../en-GB/plg_system_mokosuitelibrary.ini | 2 + .../en-GB/plg_system_mokosuitelibrary.sys.ini | 2 + .../mokosuitelibrary.xml | 39 ++++ .../services/provider.php | 27 +++ .../sql/install.mysql.sql | 122 ++++++++++ .../sql/uninstall.mysql.sql | 11 + .../src/Extension/Library.php | 18 ++ .../src/Helper/CatalogHelper.php | 86 +++++++ .../src/Helper/CheckoutHelper.php | 211 ++++++++++++++++++ .../src/Helper/FineHelper.php | 135 +++++++++++ .../src/Helper/ReservationHelper.php | 93 ++++++++ source/pkg_mokosuitelibrary.xml | 22 ++ 12 files changed, 768 insertions(+) create mode 100644 source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.ini create mode 100644 source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.sys.ini create mode 100644 source/packages/plg_system_mokosuitelibrary/mokosuitelibrary.xml create mode 100644 source/packages/plg_system_mokosuitelibrary/services/provider.php create mode 100644 source/packages/plg_system_mokosuitelibrary/sql/install.mysql.sql create mode 100644 source/packages/plg_system_mokosuitelibrary/sql/uninstall.mysql.sql create mode 100644 source/packages/plg_system_mokosuitelibrary/src/Extension/Library.php create mode 100644 source/packages/plg_system_mokosuitelibrary/src/Helper/CatalogHelper.php create mode 100644 source/packages/plg_system_mokosuitelibrary/src/Helper/CheckoutHelper.php create mode 100644 source/packages/plg_system_mokosuitelibrary/src/Helper/FineHelper.php create mode 100644 source/packages/plg_system_mokosuitelibrary/src/Helper/ReservationHelper.php create mode 100644 source/pkg_mokosuitelibrary.xml diff --git a/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.ini b/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.ini new file mode 100644 index 0000000..77b0768 --- /dev/null +++ b/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITELIBRARY="System - MokoSuite Library" +PLG_SYSTEM_MOKOSUITELIBRARY_DESC="Library and resource lending — catalog, checkout, returns, reservations, overdue tracking, fines." diff --git a/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.sys.ini b/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.sys.ini new file mode 100644 index 0000000..77b0768 --- /dev/null +++ b/source/packages/plg_system_mokosuitelibrary/language/en-GB/plg_system_mokosuitelibrary.sys.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITELIBRARY="System - MokoSuite Library" +PLG_SYSTEM_MOKOSUITELIBRARY_DESC="Library and resource lending — catalog, checkout, returns, reservations, overdue tracking, fines." diff --git a/source/packages/plg_system_mokosuitelibrary/mokosuitelibrary.xml b/source/packages/plg_system_mokosuitelibrary/mokosuitelibrary.xml new file mode 100644 index 0000000..75e9ae9 --- /dev/null +++ b/source/packages/plg_system_mokosuitelibrary/mokosuitelibrary.xml @@ -0,0 +1,39 @@ + + + System - MokoSuite Library + mokosuitelibrary + Moko Consulting + 2026-06-23 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 01.00.00 + 8.3 + PLG_SYSTEM_MOKOSUITELIBRARY_DESC + Moko\Plugin\System\MokoSuiteLibrary + + src + services + language + sql + + + en-GB/plg_system_mokosuitelibrary.ini + en-GB/plg_system_mokosuitelibrary.sys.ini + + sql/install.mysql.sql + sql/uninstall.mysql.sql + + +
+ + + + + + +
+
+
+
diff --git a/source/packages/plg_system_mokosuitelibrary/services/provider.php b/source/packages/plg_system_mokosuitelibrary/services/provider.php new file mode 100644 index 0000000..ebc2fc6 --- /dev/null +++ b/source/packages/plg_system_mokosuitelibrary/services/provider.php @@ -0,0 +1,27 @@ +set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new Library($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuitelibrary')); + $plugin->setApplication(Factory::getApplication()); + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_system_mokosuitelibrary/sql/install.mysql.sql b/source/packages/plg_system_mokosuitelibrary/sql/install.mysql.sql new file mode 100644 index 0000000..af3f273 --- /dev/null +++ b/source/packages/plg_system_mokosuitelibrary/sql/install.mysql.sql @@ -0,0 +1,122 @@ +-- +-- MokoSuite Library Tables +-- + +CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_items` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL, + `subtitle` VARCHAR(255) NOT NULL DEFAULT '', + `isbn` VARCHAR(20) NOT NULL DEFAULT '', + `barcode` VARCHAR(50) NOT NULL DEFAULT '', + `item_type` ENUM('book','dvd','equipment','tool','game','periodical','digital') NOT NULL DEFAULT 'book', + `category` VARCHAR(100) NOT NULL DEFAULT '', + `author` VARCHAR(255) NOT NULL DEFAULT '', + `publisher` VARCHAR(255) NOT NULL DEFAULT '', + `publish_year` SMALLINT UNSIGNED DEFAULT NULL, + `description` TEXT, + `cover_image` VARCHAR(500) NOT NULL DEFAULT '', + `total_copies` INT UNSIGNED NOT NULL DEFAULT 1, + `available_copies` INT UNSIGNED NOT NULL DEFAULT 1, + `published` TINYINT NOT NULL DEFAULT 1, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_isbn` (`isbn`), + KEY `idx_barcode` (`barcode`), + KEY `idx_type` (`item_type`), + KEY `idx_category` (`category`), + KEY `idx_author` (`author`(100)), + FULLTEXT `ft_search` (`title`, `subtitle`, `author`, `description`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_copies` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `item_id` INT UNSIGNED NOT NULL, + `copy_number` INT UNSIGNED NOT NULL DEFAULT 1, + `barcode` VARCHAR(50) NOT NULL DEFAULT '', + `condition_grade` ENUM('new','good','fair','poor','withdrawn') NOT NULL DEFAULT 'good', + `location` VARCHAR(100) NOT NULL DEFAULT '', + `status` ENUM('available','checked_out','on_hold','in_repair','lost','withdrawn') NOT NULL DEFAULT 'available', + `notes` TEXT, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_barcode` (`barcode`), + KEY `idx_item` (`item_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_patrons` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `contact_id` INT DEFAULT NULL, + `card_number` VARCHAR(50) NOT NULL DEFAULT '', + `name` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NOT NULL DEFAULT '', + `phone` VARCHAR(50) NOT NULL DEFAULT '', + `max_items` INT UNSIGNED NOT NULL DEFAULT 10, + `status` ENUM('active','suspended','expired') NOT NULL DEFAULT 'active', + `suspended_reason` VARCHAR(255) NOT NULL DEFAULT '', + `membership_expires` DATE DEFAULT NULL, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_card` (`card_number`), + KEY `idx_contact` (`contact_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_checkouts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `copy_id` INT UNSIGNED NOT NULL, + `patron_id` INT UNSIGNED NOT NULL, + `checked_out_at` DATETIME NOT NULL, + `due_date` DATE NOT NULL, + `returned_at` DATETIME DEFAULT NULL, + `renewals` INT UNSIGNED NOT NULL DEFAULT 0, + `status` ENUM('active','returned','overdue','lost') NOT NULL DEFAULT 'active', + `created_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_copy` (`copy_id`), + KEY `idx_patron` (`patron_id`), + KEY `idx_due` (`due_date`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_reservations` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `item_id` INT UNSIGNED NOT NULL, + `patron_id` INT UNSIGNED NOT NULL, + `position` INT UNSIGNED NOT NULL DEFAULT 1, + `status` ENUM('waiting','ready','fulfilled','cancelled','expired') NOT NULL DEFAULT 'waiting', + `notified_at` DATETIME DEFAULT NULL, + `expires_at` DATETIME DEFAULT NULL, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_item` (`item_id`, `position`), + KEY `idx_patron` (`patron_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_fines` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `patron_id` INT UNSIGNED NOT NULL, + `checkout_id` INT UNSIGNED DEFAULT NULL, + `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `paid` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `reason` VARCHAR(255) NOT NULL DEFAULT 'overdue', + `status` ENUM('outstanding','paid','waived') NOT NULL DEFAULT 'outstanding', + `waived_reason` VARCHAR(255) NOT NULL DEFAULT '', + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_patron` (`patron_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_fine_payments` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `fine_id` INT UNSIGNED NOT NULL, + `amount` DECIMAL(10,2) NOT NULL, + `method` ENUM('cash','card','online','waiver') NOT NULL DEFAULT 'cash', + `paid_at` DATETIME NOT NULL, + `received_by` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_fine` (`fine_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/packages/plg_system_mokosuitelibrary/sql/uninstall.mysql.sql b/source/packages/plg_system_mokosuitelibrary/sql/uninstall.mysql.sql new file mode 100644 index 0000000..bd6253d --- /dev/null +++ b/source/packages/plg_system_mokosuitelibrary/sql/uninstall.mysql.sql @@ -0,0 +1,11 @@ +-- +-- MokoSuite Library — Uninstall +-- + +DROP TABLE IF EXISTS `#__mokosuitelibrary_fine_payments`; +DROP TABLE IF EXISTS `#__mokosuitelibrary_fines`; +DROP TABLE IF EXISTS `#__mokosuitelibrary_reservations`; +DROP TABLE IF EXISTS `#__mokosuitelibrary_checkouts`; +DROP TABLE IF EXISTS `#__mokosuitelibrary_copies`; +DROP TABLE IF EXISTS `#__mokosuitelibrary_patrons`; +DROP TABLE IF EXISTS `#__mokosuitelibrary_items`; diff --git a/source/packages/plg_system_mokosuitelibrary/src/Extension/Library.php b/source/packages/plg_system_mokosuitelibrary/src/Extension/Library.php new file mode 100644 index 0000000..ada50d1 --- /dev/null +++ b/source/packages/plg_system_mokosuitelibrary/src/Extension/Library.php @@ -0,0 +1,18 @@ +get(DatabaseInterface::class); + + $q = $db->getQuery(true) + ->select('i.*, i.available_copies > 0 AS is_available') + ->from($db->quoteName('#__mokosuitelibrary_items', 'i')) + ->where($db->quoteName('i.published') . ' = 1') + ->order('i.title ASC'); + + if (!empty($query)) { + $q->where('MATCH(i.title, i.subtitle, i.author, i.description) AGAINST (' . $db->quote($query) . ' IN BOOLEAN MODE)'); + } + + if (!empty($filters['item_type'])) { + $q->where($db->quoteName('i.item_type') . ' = ' . $db->quote($filters['item_type'])); + } + + if (!empty($filters['category'])) { + $q->where($db->quoteName('i.category') . ' = ' . $db->quote($filters['category'])); + } + + if (isset($filters['available_only']) && $filters['available_only']) { + $q->where($db->quoteName('i.available_copies') . ' > 0'); + } + + $db->setQuery($q, $offset, $limit); + + return $db->loadAssocList() ?: []; + } + + public static function getItem(int $itemId): ?object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('i.*') + ->from($db->quoteName('#__mokosuitelibrary_items', 'i')) + ->where($db->quoteName('i.id') . ' = ' . (int) $itemId); + + $db->setQuery($query); + $item = $db->loadObject(); + + if ($item) { + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitelibrary_copies')) + ->where($db->quoteName('item_id') . ' = ' . (int) $itemId) + ->order('copy_number ASC'); + + $db->setQuery($query); + $item->copies = $db->loadObjectList() ?: []; + } + + return $item; + } + + public static function getAvailableCopies(int $itemId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitelibrary_copies')) + ->where($db->quoteName('item_id') . ' = ' . (int) $itemId) + ->where($db->quoteName('status') . ' = ' . $db->quote('available')); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitelibrary/src/Helper/CheckoutHelper.php b/source/packages/plg_system_mokosuitelibrary/src/Helper/CheckoutHelper.php new file mode 100644 index 0000000..400cf94 --- /dev/null +++ b/source/packages/plg_system_mokosuitelibrary/src/Helper/CheckoutHelper.php @@ -0,0 +1,211 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $dueDate = Factory::getDate('+' . $loanDays . ' days')->format('Y-m-d'); + $userId = Factory::getApplication()->getIdentity()->id; + + $db->transactionStart(); + + try { + // Lock copy row to prevent double-checkout + $query = $db->getQuery(true) + ->select('status') + ->from($db->quoteName('#__mokosuitelibrary_copies')) + ->where($db->quoteName('id') . ' = ' . (int) $copyId) + ->forUpdate(); + + $db->setQuery($query); + $status = $db->loadResult(); + + if ($status !== 'available') { + throw new \RuntimeException('Copy is not available for checkout'); + } + + // Create checkout record + $checkout = (object) [ + 'copy_id' => (int) $copyId, + 'patron_id' => (int) $patronId, + 'checked_out_at' => $now, + 'due_date' => $dueDate, + 'status' => 'active', + 'created_by' => $userId, + ]; + + $db->insertObject('#__mokosuitelibrary_checkouts', $checkout, 'id'); + + // Update copy status + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitelibrary_copies')) + ->set($db->quoteName('status') . ' = ' . $db->quote('checked_out')) + ->where($db->quoteName('id') . ' = ' . (int) $copyId); + + $db->setQuery($query); + $db->execute(); + + // Decrement available count on item + $query = $db->getQuery(true) + ->select('item_id') + ->from($db->quoteName('#__mokosuitelibrary_copies')) + ->where($db->quoteName('id') . ' = ' . (int) $copyId); + + $db->setQuery($query); + $itemId = $db->loadResult(); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitelibrary_items')) + ->set($db->quoteName('available_copies') . ' = ' . $db->quoteName('available_copies') . ' - 1') + ->where($db->quoteName('id') . ' = ' . (int) $itemId) + ->where($db->quoteName('available_copies') . ' > 0'); + + $db->setQuery($query); + $db->execute(); + + $db->transactionCommit(); + + return $checkout; + } catch (\Exception $e) { + $db->transactionRollback(); + throw $e; + } + } + + public static function checkin(int $copyId): ?object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $db->transactionStart(); + + try { + // Find active checkout for this copy + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitelibrary_checkouts')) + ->where($db->quoteName('copy_id') . ' = ' . (int) $copyId) + ->where($db->quoteName('status') . ' IN (' . $db->quote('active') . ', ' . $db->quote('overdue') . ')') + ->forUpdate(); + + $db->setQuery($query); + $checkout = $db->loadObject(); + + if (!$checkout) { + $db->transactionRollback(); + return null; + } + + // Mark returned + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitelibrary_checkouts')) + ->set($db->quoteName('returned_at') . ' = ' . $db->quote($now)) + ->set($db->quoteName('status') . ' = ' . $db->quote('returned')) + ->where($db->quoteName('id') . ' = ' . (int) $checkout->id); + + $db->setQuery($query); + $db->execute(); + + // Update copy status + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitelibrary_copies')) + ->set($db->quoteName('status') . ' = ' . $db->quote('available')) + ->where($db->quoteName('id') . ' = ' . (int) $copyId); + + $db->setQuery($query); + $db->execute(); + + // Increment available count + $query = $db->getQuery(true) + ->select('item_id') + ->from($db->quoteName('#__mokosuitelibrary_copies')) + ->where($db->quoteName('id') . ' = ' . (int) $copyId); + + $db->setQuery($query); + $itemId = $db->loadResult(); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitelibrary_items')) + ->set($db->quoteName('available_copies') . ' = ' . $db->quoteName('available_copies') . ' + 1') + ->where($db->quoteName('id') . ' = ' . (int) $itemId); + + $db->setQuery($query); + $db->execute(); + + // Calculate fine if overdue + if ($checkout->due_date < Factory::getDate()->format('Y-m-d')) { + FineHelper::calculate((int) $checkout->id); + } + + $db->transactionCommit(); + + return $checkout; + } catch (\Exception $e) { + $db->transactionRollback(); + throw $e; + } + } + + public static function renew(int $checkoutId, int $additionalDays = 14): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitelibrary_checkouts')) + ->where($db->quoteName('id') . ' = ' . (int) $checkoutId) + ->where($db->quoteName('status') . ' = ' . $db->quote('active')); + + $db->setQuery($query); + $checkout = $db->loadObject(); + + if (!$checkout || $checkout->renewals >= 2) { + return false; + } + + $newDue = Factory::getDate($checkout->due_date . ' +' . $additionalDays . ' days')->format('Y-m-d'); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitelibrary_checkouts')) + ->set($db->quoteName('due_date') . ' = ' . $db->quote($newDue)) + ->set($db->quoteName('renewals') . ' = ' . $db->quoteName('renewals') . ' + 1') + ->where($db->quoteName('id') . ' = ' . (int) $checkoutId); + + $db->setQuery($query); + $db->execute(); + + return true; + } + + public static function getOverdue(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $today = Factory::getDate()->format('Y-m-d'); + + $query = $db->getQuery(true) + ->select('c.*, p.name AS patron_name, p.email AS patron_email, i.title AS item_title') + ->from($db->quoteName('#__mokosuitelibrary_checkouts', 'c')) + ->join('INNER', $db->quoteName('#__mokosuitelibrary_copies', 'cp') . ' ON cp.id = c.copy_id') + ->join('INNER', $db->quoteName('#__mokosuitelibrary_items', 'i') . ' ON i.id = cp.item_id') + ->join('INNER', $db->quoteName('#__mokosuitelibrary_patrons', 'p') . ' ON p.id = c.patron_id') + ->where($db->quoteName('c.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('c.due_date') . ' < ' . $db->quote($today)) + ->order('c.due_date ASC'); + + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitelibrary/src/Helper/FineHelper.php b/source/packages/plg_system_mokosuitelibrary/src/Helper/FineHelper.php new file mode 100644 index 0000000..faf3bd9 --- /dev/null +++ b/source/packages/plg_system_mokosuitelibrary/src/Helper/FineHelper.php @@ -0,0 +1,135 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitelibrary_checkouts')) + ->where($db->quoteName('id') . ' = ' . (int) $checkoutId); + + $db->setQuery($query); + $checkout = $db->loadObject(); + + if (!$checkout) { + return null; + } + + $dueDate = new \DateTime($checkout->due_date); + $returnDate = $checkout->returned_at + ? new \DateTime($checkout->returned_at) + : new \DateTime(); + + $daysOverdue = max(0, (int) $dueDate->diff($returnDate)->days); + + if ($daysOverdue <= 0) { + return null; + } + + $amount = round($daysOverdue * $ratePerDay, 2); + + $fine = (object) [ + 'patron_id' => (int) $checkout->patron_id, + 'checkout_id' => (int) $checkoutId, + 'amount' => $amount, + 'reason' => 'overdue', + 'status' => 'outstanding', + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitelibrary_fines', $fine, 'id'); + + return $fine; + } + + public static function getPatronBalance(int $patronId): float + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('COALESCE(SUM(amount - paid), 0)') + ->from($db->quoteName('#__mokosuitelibrary_fines')) + ->where($db->quoteName('patron_id') . ' = ' . (int) $patronId) + ->where($db->quoteName('status') . ' = ' . $db->quote('outstanding')); + + $db->setQuery($query); + + return (float) $db->loadResult(); + } + + public static function recordPayment(int $fineId, float $amount, string $method = 'cash'): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $userId = Factory::getApplication()->getIdentity()->id; + + $payment = (object) [ + 'fine_id' => (int) $fineId, + 'amount' => $amount, + 'method' => $method, + 'paid_at' => $now, + 'received_by' => $userId, + ]; + + $db->insertObject('#__mokosuitelibrary_fine_payments', $payment, 'id'); + + // Update paid amount on fine + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitelibrary_fines')) + ->set($db->quoteName('paid') . ' = ' . $db->quoteName('paid') . ' + ' . (float) $amount) + ->where($db->quoteName('id') . ' = ' . (int) $fineId); + + $db->setQuery($query); + $db->execute(); + + // Check if fully paid + $query = $db->getQuery(true) + ->select('amount, paid') + ->from($db->quoteName('#__mokosuitelibrary_fines')) + ->where($db->quoteName('id') . ' = ' . (int) $fineId); + + $db->setQuery($query); + $fine = $db->loadObject(); + + if ($fine && $fine->paid >= $fine->amount) { + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitelibrary_fines')) + ->set($db->quoteName('status') . ' = ' . $db->quote('paid')) + ->where($db->quoteName('id') . ' = ' . (int) $fineId); + + $db->setQuery($query); + $db->execute(); + } + + return true; + } + + public static function waive(int $fineId, string $reason): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitelibrary_fines')) + ->set($db->quoteName('status') . ' = ' . $db->quote('waived')) + ->set($db->quoteName('waived_reason') . ' = ' . $db->quote($reason)) + ->where($db->quoteName('id') . ' = ' . (int) $fineId); + + $db->setQuery($query); + $db->execute(); + + return true; + } +} diff --git a/source/packages/plg_system_mokosuitelibrary/src/Helper/ReservationHelper.php b/source/packages/plg_system_mokosuitelibrary/src/Helper/ReservationHelper.php new file mode 100644 index 0000000..cf3cb28 --- /dev/null +++ b/source/packages/plg_system_mokosuitelibrary/src/Helper/ReservationHelper.php @@ -0,0 +1,93 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + // Get next position in queue + $query = $db->getQuery(true) + ->select('COALESCE(MAX(position), 0) + 1') + ->from($db->quoteName('#__mokosuitelibrary_reservations')) + ->where($db->quoteName('item_id') . ' = ' . (int) $itemId) + ->where($db->quoteName('status') . ' IN (' . $db->quote('waiting') . ', ' . $db->quote('ready') . ')'); + + $db->setQuery($query); + $position = (int) $db->loadResult(); + + $reservation = (object) [ + 'item_id' => (int) $itemId, + 'patron_id' => (int) $patronId, + 'position' => $position, + 'status' => 'waiting', + 'created' => $now, + ]; + + $db->insertObject('#__mokosuitelibrary_reservations', $reservation, 'id'); + + return $reservation; + } + + public static function fulfillNext(int $itemId, int $holdDays = 3): ?object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + $expires = Factory::getDate('+' . $holdDays . ' days')->toSql(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitelibrary_reservations')) + ->where($db->quoteName('item_id') . ' = ' . (int) $itemId) + ->where($db->quoteName('status') . ' = ' . $db->quote('waiting')) + ->order('position ASC') + ->setLimit(1); + + $db->setQuery($query); + $reservation = $db->loadObject(); + + if (!$reservation) { + return null; + } + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitelibrary_reservations')) + ->set($db->quoteName('status') . ' = ' . $db->quote('ready')) + ->set($db->quoteName('notified_at') . ' = ' . $db->quote($now)) + ->set($db->quoteName('expires_at') . ' = ' . $db->quote($expires)) + ->where($db->quoteName('id') . ' = ' . (int) $reservation->id); + + $db->setQuery($query); + $db->execute(); + + return $reservation; + } + + public static function getQueue(int $itemId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('r.*, p.name AS patron_name') + ->from($db->quoteName('#__mokosuitelibrary_reservations', 'r')) + ->join('INNER', $db->quoteName('#__mokosuitelibrary_patrons', 'p') . ' ON p.id = r.patron_id') + ->where($db->quoteName('r.item_id') . ' = ' . (int) $itemId) + ->where($db->quoteName('r.status') . ' IN (' . $db->quote('waiting') . ', ' . $db->quote('ready') . ')') + ->order('r.position ASC'); + + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } +} diff --git a/source/pkg_mokosuitelibrary.xml b/source/pkg_mokosuitelibrary.xml new file mode 100644 index 0000000..36bd91b --- /dev/null +++ b/source/pkg_mokosuitelibrary.xml @@ -0,0 +1,22 @@ + + + Package - MokoSuite Library + mokosuitelibrary + 01.00.00 + 2026-06-23 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + Library and resource lending management + 8.3 + + true + + plg_system_mokosuitelibrary.zip + + + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteLibrary/updates.xml + +