feat: full scaffolding — manifests, Extension, provider, SQL (7 tables), 4 helpers (Catalog, Checkout, Reservation, Fine), language files
This commit is contained in:
+2
@@ -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."
|
||||
+2
@@ -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."
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuite Library</name>
|
||||
<element>mokosuitelibrary</element>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>01.00.00</version>
|
||||
<php_minimum>8.3</php_minimum>
|
||||
<description>PLG_SYSTEM_MOKOSUITELIBRARY_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteLibrary</namespace>
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
<folder>sql</folder>
|
||||
</files>
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/plg_system_mokosuitelibrary.ini</language>
|
||||
<language tag="en-GB">en-GB/plg_system_mokosuitelibrary.sys.ini</language>
|
||||
</languages>
|
||||
<install><sql><file driver="mysql" charset="utf8">sql/install.mysql.sql</file></sql></install>
|
||||
<uninstall><sql><file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file></sql></uninstall>
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic" label="Lending Defaults">
|
||||
<field name="loan_period_days" type="number" default="14" label="Default Loan Period (days)" />
|
||||
<field name="max_renewals" type="number" default="2" label="Max Renewals" />
|
||||
<field name="grace_period_days" type="number" default="1" label="Grace Period (days)" />
|
||||
<field name="fine_per_day" type="number" default="0.25" label="Fine Per Day ($)" step="0.05" />
|
||||
<field name="max_items_per_patron" type="number" default="10" label="Max Items Per Patron" />
|
||||
<field name="reservation_hold_days" type="number" default="3" label="Reservation Hold (days)" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Moko\Plugin\System\MokoSuiteLibrary\Extension\Library;
|
||||
|
||||
return new class implements ServiceProviderInterface
|
||||
{
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->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;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Moko\Plugin\System\MokoSuiteLibrary\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
class Library extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Moko\Plugin\System\MokoSuiteLibrary\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Library catalog — search, item CRUD, availability tracking.
|
||||
*/
|
||||
class CatalogHelper
|
||||
{
|
||||
public static function search(string $query = '', array $filters = [], int $limit = 20, int $offset = 0): array
|
||||
{
|
||||
$db = Factory::getContainer()->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() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace Moko\Plugin\System\MokoSuiteLibrary\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Library checkout/return — lending transactions, renewals, overdue detection.
|
||||
*/
|
||||
class CheckoutHelper
|
||||
{
|
||||
public static function checkout(int $copyId, int $patronId, int $loanDays = 14): object
|
||||
{
|
||||
$db = Factory::getContainer()->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() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace Moko\Plugin\System\MokoSuiteLibrary\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Library fines — overdue calculation, payments, waivers.
|
||||
*/
|
||||
class FineHelper
|
||||
{
|
||||
public static function calculate(int $checkoutId, float $ratePerDay = 0.25): ?object
|
||||
{
|
||||
$db = Factory::getContainer()->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Moko\Plugin\System\MokoSuiteLibrary\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Library reservations — holds, queue management, notification on availability.
|
||||
*/
|
||||
class ReservationHelper
|
||||
{
|
||||
public static function reserve(int $itemId, int $patronId): object
|
||||
{
|
||||
$db = Factory::getContainer()->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() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuite Library</name>
|
||||
<packagename>mokosuitelibrary</packagename>
|
||||
<version>01.00.00</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<description>Library and resource lending management</description>
|
||||
<php_minimum>8.3</php_minimum>
|
||||
<dlid prefix="dlid=" suffix=""/>
|
||||
<blockChildUninstall>true</blockChildUninstall>
|
||||
<files folder="packages">
|
||||
<file type="plugin" id="plg_system_mokosuitelibrary" group="system">plg_system_mokosuitelibrary.zip</file>
|
||||
</files>
|
||||
<updateservers>
|
||||
<server type="extension" priority="1" name="Package - MokoSuite Library">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteLibrary/updates.xml</server>
|
||||
</updateservers>
|
||||
</extension>
|
||||
Reference in New Issue
Block a user