Files
alrahma_sunday_school/app/Services/StudentBookIssueService.php
T
root d906a915d6
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s
add refund logic and fix books inventory logic
2026-08-22 13:44:25 -04:00

404 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use CodeIgniter\Database\BaseConnection;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
final class StudentBookIssueService
{
public function __construct(private readonly BaseConnection $db)
{
}
/**
* Atomically distributes one copy of a book to each selected student.
* Existing active issues are skipped; every new issue snapshots the price.
*
* @param list<int> $studentIds
* @return array{issued:int,skipped:int,issue_ids:list<int>,unit_charge_price_cents:int,on_hand:int}
*/
public function distributeBatch(
int $inventoryItemId,
array $studentIds,
int $classSectionId,
int $schoolYearId,
string $schoolYear,
?int $actorId,
?string $note = null,
?string $issuedAt = null,
?string $batchKey = null
): array {
$this->assertTables();
$schoolYear = trim($schoolYear);
if ($inventoryItemId <= 0 || $classSectionId <= 0 || $schoolYearId <= 0 || $schoolYear === '') {
throw new InvalidArgumentException('Book, class section, and school year are required.');
}
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0)));
if ($studentIds === []) {
return ['issued' => 0, 'skipped' => 0, 'issue_ids' => [], 'unit_charge_price_cents' => 0, 'on_hand' => $this->legacyOnHand($inventoryItemId)];
}
$issuedAt = $issuedAt !== null ? trim($issuedAt) : date('Y-m-d H:i:s');
if (! preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $issuedAt)) {
throw new InvalidArgumentException('Issue time must use Y-m-d H:i:s.');
}
$batchKey = trim((string) $batchKey);
if ($batchKey === '') {
$batchKey = hash('sha256', implode('|', [
$schoolYear, $inventoryItemId, $classSectionId, $actorId ?? 0,
$issuedAt, implode(',', $studentIds),
]));
}
$this->db->transBegin();
try {
$book = $this->db->query(
'SELECT id, type, name, is_active FROM inventory_items WHERE id = ? FOR UPDATE',
[$inventoryItemId]
)->getRowArray();
if ($book === null || ($book['type'] ?? '') !== 'book' || (int) ($book['is_active'] ?? 1) !== 1) {
throw new InvalidArgumentException('The selected book is missing or inactive.');
}
$itemYear = $this->db->query(
'SELECT * FROM inventory_item_years WHERE inventory_item_id = ? AND school_year_id = ? AND school_year = ? FOR UPDATE',
[$inventoryItemId, $schoolYearId, $schoolYear]
)->getRowArray();
if ($itemYear === null || ($itemYear['status'] ?? '') !== 'open') {
throw new InvalidArgumentException('The selected book does not have an open inventory record for this school year.');
}
$priceCents = (int) ($itemYear['charge_price_cents'] ?? 0);
if ($priceCents <= 0 || (int) ($itemYear['price_confirmed'] ?? 0) !== 1) {
throw new InvalidArgumentException('Enter and confirm this books school-year charge price before distribution.');
}
$enrollments = [];
$toIssue = [];
$skipped = 0;
foreach ($studentIds as $studentId) {
$existing = $this->db->table('student_book_issues')
->select('id')
->where('student_id', $studentId)
->where('inventory_item_year_id', (int) $itemYear['id'])
->where('status', 'issued')
->get(1)
->getRowArray();
if ($existing !== null) {
$skipped++;
continue;
}
$enrollment = $this->db->table('enrollments')
->select('id, parent_id, class_section_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
if ($enrollment === null || (int) ($enrollment['parent_id'] ?? 0) <= 0) {
throw new InvalidArgumentException('Student #' . $studentId . ' has no valid enrollment for ' . $schoolYear . '.');
}
if ((int) ($enrollment['class_section_id'] ?? 0) > 0
&& (int) $enrollment['class_section_id'] !== $classSectionId) {
throw new InvalidArgumentException('Student #' . $studentId . ' is not enrolled in the selected class section.');
}
$enrollments[$studentId] = $enrollment;
$toIssue[] = $studentId;
}
$onHand = $this->onHandForItemYear((int) $itemYear['id'], (int) $itemYear['opening_quantity']);
if (count($toIssue) > $onHand) {
throw new InvalidArgumentException('Not enough stock: need ' . count($toIssue) . ', on hand ' . $onHand . '.');
}
$issueIds = [];
foreach ($toIssue as $studentId) {
$enrollment = $enrollments[$studentId];
$idempotencyKey = substr($batchKey . ':student:' . $studentId, 0, 160);
$existingKey = $this->db->table('student_book_issues')
->select('id')
->where('idempotency_key', $idempotencyKey)
->get(1)
->getRowArray();
if ($existingKey !== null) {
$skipped++;
continue;
}
$now = date('Y-m-d H:i:s');
$issue = [
'student_id' => $studentId,
'enrollment_id' => (int) $enrollment['id'],
'parent_id' => (int) $enrollment['parent_id'],
'inventory_item_id' => $inventoryItemId,
'inventory_item_year_id' => (int) $itemYear['id'],
'school_year' => $schoolYear,
'class_section_id' => $classSectionId,
'quantity' => 1,
'unit_charge_price_cents' => $priceCents,
'total_charge_cents' => $priceCents,
'idempotency_key' => $idempotencyKey,
'status' => 'issued',
'issued_at' => $issuedAt,
'issued_by' => $actorId,
'created_at' => $now,
'updated_at' => $now,
];
if (! $this->db->table('student_book_issues')->insert($issue)) {
throw new RuntimeException('Unable to create a student book issue.');
}
$issueId = (int) $this->db->insertID();
$movement = [
'item_id' => $inventoryItemId,
'item_year_id' => (int) $itemYear['id'],
'qty_change' => -1,
'movement_type' => 'distribution',
'reason' => 'Student book distribution',
'note' => $note,
'semester' => null,
'school_year' => $schoolYear,
'performed_by' => $actorId,
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'idempotency_key' => substr($idempotencyKey . ':movement', 0, 120),
'status' => 'posted',
'source_type' => 'student_book_issue',
'source_id' => $issueId,
'created_at' => $now,
'updated_at' => $now,
];
if (! $this->db->table('inventory_movements')->insert($movement)) {
throw new RuntimeException('Unable to create the issue stock movement.');
}
$movementId = (int) $this->db->insertID();
if (! $this->db->table('student_book_issues')->where('id', $issueId)->update([
'distribution_movement_id' => $movementId,
'updated_at' => $now,
])) {
throw new RuntimeException('Unable to link the issue to its stock movement.');
}
$issueIds[] = $issueId;
}
$onHand -= count($issueIds);
$this->db->table('inventory_items')->where('id', $inventoryItemId)->update([
'quantity' => max(0, $onHand),
'updated_by' => $actorId,
'updated_at' => date('Y-m-d H:i:s'),
]);
if (! $this->db->transCommit()) {
throw new RuntimeException('Unable to commit the book distribution.');
}
return [
'issued' => count($issueIds),
'skipped' => $skipped,
'issue_ids' => $issueIds,
'unit_charge_price_cents' => $priceCents,
'on_hand' => $onHand,
];
} catch (Throwable $e) {
$this->db->transRollback();
throw $e;
}
}
public function reverseErroneousIssue(int $issueId, string $reason, ?int $actorId): void
{
$reason = trim($reason);
if ($issueId <= 0 || $reason === '') {
throw new InvalidArgumentException('Issue and correction reason are required.');
}
$this->db->transBegin();
try {
$issue = $this->db->query('SELECT * FROM student_book_issues WHERE id = ? FOR UPDATE', [$issueId])->getRowArray();
if ($issue === null || ($issue['status'] ?? '') !== 'issued') {
throw new InvalidArgumentException('Only an active issue can be corrected.');
}
$itemYear = $this->db->query('SELECT * FROM inventory_item_years WHERE id = ? FOR UPDATE', [
(int) $issue['inventory_item_year_id'],
])->getRowArray();
if ($itemYear === null || ($itemYear['status'] ?? '') !== 'open') {
throw new InvalidArgumentException('Corrections are not allowed after the inventory year is closed.');
}
$now = date('Y-m-d H:i:s');
$quantity = (int) $issue['quantity'];
$movement = [
'item_id' => (int) $issue['inventory_item_id'],
'item_year_id' => (int) $issue['inventory_item_year_id'],
'qty_change' => $quantity,
'movement_type' => 'adjust',
'reason' => 'Correction of erroneous book issue',
'note' => $reason,
'school_year' => (string) $issue['school_year'],
'performed_by' => $actorId,
'student_id' => (int) $issue['student_id'],
'class_section_id' => (int) ($issue['class_section_id'] ?? 0) ?: null,
'reversal_of_movement_id' => (int) ($issue['distribution_movement_id'] ?? 0) ?: null,
'idempotency_key' => substr('student-book-issue-reversal:' . $issueId, 0, 120),
'status' => 'posted',
'source_type' => 'student_book_issue_reversal',
'source_id' => $issueId,
'created_at' => $now,
'updated_at' => $now,
];
if (! $this->db->table('inventory_movements')->insert($movement)) {
throw new RuntimeException('Unable to create the correction movement.');
}
$reversalMovementId = (int) $this->db->insertID();
$this->db->table('student_book_issues')->where('id', $issueId)->update([
'status' => 'reversed',
'reversed_at' => $now,
'reversed_by' => $actorId,
'reversal_reason' => $reason,
'reversal_quantity' => $quantity,
'reversal_movement_id' => $reversalMovementId,
'updated_at' => $now,
]);
if ((int) ($issue['distribution_movement_id'] ?? 0) > 0) {
$this->db->table('inventory_movements')
->where('id', (int) $issue['distribution_movement_id'])
->update(['status' => 'reversed', 'reversed_at' => $now, 'reversed_by' => $actorId]);
}
$this->markPostedCalculationsForReview($issueId, (int) $issue['student_id'], (string) $issue['school_year'], $now);
$onHand = $this->onHandForItemYear((int) $itemYear['id'], (int) $itemYear['opening_quantity']);
$this->db->table('inventory_items')->where('id', (int) $issue['inventory_item_id'])->update([
'quantity' => max(0, $onHand),
'updated_by' => $actorId,
'updated_at' => $now,
]);
if (! $this->db->transCommit()) {
throw new RuntimeException('Unable to commit the issue correction.');
}
} catch (Throwable $e) {
$this->db->transRollback();
throw $e;
}
}
/** @return list<array<string,mixed>> */
public function activeIssuesAsOf(int $studentId, string $schoolYear, string $date): array
{
$dateSql = $this->db->escape($date);
return $this->db->table('student_book_issues sbi')
->select('sbi.*, i.name AS book_name, i.isbn, i.edition')
->join('inventory_items i', 'i.id = sbi.inventory_item_id', 'inner')
->where('sbi.student_id', $studentId)
->where('sbi.school_year', $schoolYear)
->where('sbi.status', 'issued')
->where('DATE(sbi.issued_at) <= ' . $dateSql, null, false)
->orderBy('sbi.issued_at', 'ASC')
->orderBy('sbi.id', 'ASC')
->get()
->getResultArray();
}
public function totalChargeCentsAsOf(int $studentId, string $schoolYear, string $date): int
{
$dateSql = $this->db->escape($date);
$row = $this->db->table('student_book_issues')
->selectSum('total_charge_cents', 'total')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('status', 'issued')
->where('DATE(issued_at) <= ' . $dateSql, null, false)
->get()
->getRowArray();
return (int) ($row['total'] ?? 0);
}
/** @return list<array<string,mixed>> */
public function issueEvidenceAsOf(int $studentId, string $schoolYear, string $date): array
{
$dateSql = $this->db->escape($date);
return $this->db->table('student_book_issues sbi')
->select('sbi.id, sbi.inventory_item_id, sbi.quantity, sbi.unit_charge_price_cents, sbi.total_charge_cents, sbi.status, sbi.issued_at, sbi.reversed_at, sbi.reversal_reason, sbi.reversal_quantity, sbi.reversal_movement_id, i.name AS book_name, i.isbn, i.edition')
->join('inventory_items i', 'i.id = sbi.inventory_item_id', 'inner')
->where('sbi.student_id', $studentId)
->where('sbi.school_year', $schoolYear)
->where('DATE(sbi.issued_at) <= ' . $dateSql, null, false)
->orderBy('sbi.issued_at', 'ASC')
->orderBy('sbi.id', 'ASC')
->get()
->getResultArray();
}
private function onHandForItemYear(int $itemYearId, int $openingQuantity): int
{
$row = $this->db->table('inventory_movements')
->selectSum('qty_change', 'movement_total')
->where('item_year_id', $itemYearId)
->whereIn('status', ['posted', 'reversed'])
->get()
->getRowArray();
return $openingQuantity + (int) ($row['movement_total'] ?? 0);
}
private function legacyOnHand(int $itemId): int
{
$row = $this->db->table('inventory_items')->select('quantity')->where('id', $itemId)->get(1)->getRowArray();
return (int) ($row['quantity'] ?? 0);
}
private function markPostedCalculationsForReview(int $issueId, int $studentId, string $schoolYear, string $now): void
{
if (! $this->db->tableExists('withdrawal_financial_calculations')) {
return;
}
$calculations = $this->db->table('withdrawal_financial_calculations')
->select('id, book_evidence_json')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('status', 'posted')
->get()
->getResultArray();
foreach ($calculations as $calculation) {
$evidence = json_decode((string) ($calculation['book_evidence_json'] ?? '[]'), true);
$ids = array_map('intval', array_column(is_array($evidence) ? $evidence : [], 'id'));
if (! in_array($issueId, $ids, true)) {
continue;
}
$calculationId = (int) $calculation['id'];
$this->db->table('withdrawal_financial_calculations')->where('id', $calculationId)->update([
'status' => 'requires_review',
'active_posted_key' => null,
'updated_at' => $now,
]);
if ($this->db->fieldExists('withdrawal_calculation_id', 'refunds')) {
$this->db->table('refunds')->where('withdrawal_calculation_id', $calculationId)->update([
'reconciliation_status' => 'requires_review',
'reconciliation_reason' => 'Book issue #' . $issueId . ' was corrected after posting.',
'reconciliation_required_at' => $now,
'updated_at' => $now,
]);
}
}
}
private function assertTables(): void
{
foreach (['inventory_item_years', 'student_book_issues', 'inventory_movements', 'enrollments'] as $table) {
if (! $this->db->tableExists($table)) {
throw new RuntimeException('Required table is missing: ' . $table . '. Run migrations first.');
}
}
}
}