add refund logic and fix books inventory logic
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s

This commit is contained in:
root
2026-08-22 13:44:25 -04:00
parent 23d1cbb64c
commit d906a915d6
45 changed files with 4711 additions and 442 deletions
+403
View File
@@ -0,0 +1,403 @@
<?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.');
}
}
}
}