404 lines
18 KiB
PHP
404 lines
18 KiB
PHP
<?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 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<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.');
|
||
}
|
||
}
|
||
}
|
||
}
|