$studentIds * @return array{issued:int,skipped:int,issue_ids:list,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 book’s 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> */ 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> */ 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.'); } } } }