'Filter the read-only orphan list.', '--movement-id' => 'One legacy distribution movement to reconcile.', '--unit-price' => 'Admin-approved historical unit charge price.', '--reason' => 'Required audit explanation for the evidence source.', '--actor-id' => 'Required administrator user ID.', '--commit' => 'Actually write; without this flag the command is read-only.', '--json' => 'Print listing or result as JSON.', ]; public function run(array $params) { $db = \Config\Database::connect(); foreach (['inventory_movements', 'inventory_item_years', 'student_book_issues', 'withdrawal_financial_calculations', 'refunds'] as $table) { if (! $db->tableExists($table)) { CLI::error('Required table is missing: ' . $table . '. Run migrations first.'); return; } } $movementId = (int) (CLI::getOption('movement-id') ?? 0); if ($movementId <= 0) { $builder = $db->table('inventory_movements im') ->select('im.id, im.item_id, im.student_id, im.class_section_id, im.qty_change, im.school_year, im.created_at, i.name AS book_name') ->join('inventory_items i', 'i.id = im.item_id', 'inner') ->join('student_book_issues sbi', 'sbi.distribution_movement_id = im.id', 'left') ->where('im.movement_type', 'distribution') ->where('im.student_id IS NOT NULL', null, false) ->where('im.qty_change <', 0) ->where('im.status', 'posted') ->where('sbi.id', null) ->where('i.type', 'book') ->orderBy('im.id', 'ASC'); $year = trim((string) (CLI::getOption('school-year') ?? '')); if ($year !== '') { $builder->where('im.school_year', $year); } $rows = $builder->get()->getResultArray(); if (CLI::getOption('json') !== null) { CLI::write(json_encode(['count' => count($rows), 'legacy_distributions' => $rows], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return; } CLI::write('Legacy book distributions requiring priced issue evidence: ' . count($rows), 'yellow'); foreach ($rows as $row) { CLI::write(sprintf('#%d %s student=%d qty=%d year=%s', (int) $row['id'], (string) $row['book_name'], (int) $row['student_id'], abs((int) $row['qty_change']), (string) $row['school_year'])); } return; } try { $priceCents = $this->priceToCents((string) (CLI::getOption('unit-price') ?? '')); $reason = trim((string) (CLI::getOption('reason') ?? '')); $actorId = (int) (CLI::getOption('actor-id') ?? 0); if ($reason === '' || $actorId <= 0) { throw new InvalidArgumentException('--reason and a positive --actor-id are required.'); } $db->transBegin(); $movement = $db->query('SELECT * FROM inventory_movements WHERE id = ? FOR UPDATE', [$movementId])->getRowArray(); if ($movement === null || ($movement['movement_type'] ?? '') !== 'distribution' || ($movement['status'] ?? '') !== 'posted' || (int) ($movement['qty_change'] ?? 0) >= 0 || (int) ($movement['student_id'] ?? 0) <= 0) { throw new InvalidArgumentException('The movement is not a negative student distribution.'); } if ($db->table('student_book_issues')->where('distribution_movement_id', $movementId)->countAllResults() > 0) { throw new InvalidArgumentException('This movement is already linked to student issue evidence.'); } $book = $db->table('inventory_items')->select('id, name, type')->where('id', (int) $movement['item_id'])->get(1)->getRowArray(); if ($book === null || ($book['type'] ?? '') !== 'book') { throw new InvalidArgumentException('The movement item is not a book.'); } $schoolYear = trim((string) ($movement['school_year'] ?? '')); if ($schoolYear === '') { throw new InvalidArgumentException('The legacy movement has no school year. Correct that evidence first.'); } $itemYear = $db->table('inventory_item_years') ->where('inventory_item_id', (int) $movement['item_id']) ->where('school_year', $schoolYear)->get(1)->getRowArray(); if ($itemYear === null) { throw new InvalidArgumentException('The book has no inventory-year record for ' . $schoolYear . '.'); } $enrollments = $db->table('enrollments')->select('id, parent_id, class_section_id') ->where('student_id', (int) $movement['student_id'])->where('school_year', $schoolYear)->get()->getResultArray(); if (count($enrollments) !== 1 || (int) ($enrollments[0]['parent_id'] ?? 0) <= 0) { throw new InvalidArgumentException('Exactly one parent-linked enrollment must exist for the student and year.'); } $quantity = abs((int) $movement['qty_change']); if ($quantity <= 0 || $priceCents > intdiv(2147483647, $quantity)) { throw new InvalidArgumentException('The quantity and approved price exceed the supported charge range.'); } $now = date('Y-m-d H:i:s'); $issuedAt = trim((string) ($movement['created_at'] ?? '')) ?: $now; $issue = [ 'student_id' => (int) $movement['student_id'], 'enrollment_id' => (int) $enrollments[0]['id'], 'parent_id' => (int) $enrollments[0]['parent_id'], 'inventory_item_id' => (int) $movement['item_id'], 'inventory_item_year_id' => (int) $itemYear['id'], 'school_year' => $schoolYear, 'class_section_id' => (int) ($movement['class_section_id'] ?? $enrollments[0]['class_section_id'] ?? 0) ?: null, 'quantity' => $quantity, 'unit_charge_price_cents' => $priceCents, 'total_charge_cents' => $priceCents * $quantity, 'distribution_movement_id' => $movementId, 'idempotency_key' => 'legacy-distribution:' . $movementId, 'status' => 'issued', 'issued_at' => $issuedAt, 'issued_by' => $actorId, 'created_at' => $now, 'updated_at' => $now, ]; if (CLI::getOption('commit') === null) { $db->transRollback(); $result = ['mode' => 'dry-run', 'movement_id' => $movementId, 'book' => $book['name'], 'issue' => $issue, 'reason' => $reason]; CLI::write(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); CLI::write('DRY RUN — add --commit to save.', 'yellow'); return; } if (! $db->table('student_book_issues')->insert($issue)) { throw new RuntimeException('Unable to insert issue evidence.'); } $issueId = (int) $db->insertID(); $db->table('inventory_movements')->where('id', $movementId)->update([ 'item_year_id' => (int) $itemYear['id'], 'idempotency_key' => 'legacy-distribution-movement:' . $movementId, 'status' => 'posted', 'source_type' => 'student_book_issue', 'source_id' => $issueId, 'note' => trim((string) ($movement['note'] ?? '') . "\nLegacy evidence approved by user #{$actorId}: {$reason}"), 'updated_at' => $now, ]); $affectedCalculations = $db->table('withdrawal_financial_calculations') ->select('id') ->where('student_id', (int) $movement['student_id']) ->where('school_year', $schoolYear) ->where('status', 'posted') ->where('withdrawal_request_date >=', substr($issuedAt, 0, 10)) ->get()->getResultArray(); $affectedIds = array_values(array_filter(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $affectedCalculations))); if ($affectedIds !== []) { $db->table('withdrawal_financial_calculations')->whereIn('id', $affectedIds)->update([ 'status' => 'requires_review', 'active_posted_key' => null, 'updated_at' => $now, ]); $db->table('refunds')->whereIn('withdrawal_calculation_id', $affectedIds)->update([ 'reconciliation_status' => 'requires_review', 'reconciliation_reason' => 'Legacy book issue evidence was added after the withdrawal calculation was posted.', 'reconciliation_required_at' => $now, 'updated_at' => $now, ]); } if (! $db->transCommit()) { throw new RuntimeException('Unable to commit the reconciliation.'); } CLI::write('Reconciled movement #' . $movementId . ' as issue #' . $issueId . ' without changing stock.', 'green'); } catch (Throwable $e) { $db->transRollback(); CLI::error($e->getMessage()); } } private function priceToCents(string $price): int { $price = trim($price); if (! preg_match('/^\d+(?:\.\d{1,2})?$/', $price)) { throw new InvalidArgumentException('--unit-price is required with no more than two decimal places.'); } [$whole, $fraction] = array_pad(explode('.', $price, 2), 2, ''); $cents = ((int) $whole * 100) + (int) str_pad($fraction, 2, '0'); if ($cents <= 0 || $cents > 2147483647) { throw new InvalidArgumentException('Unit price must be greater than zero and within range.'); } return $cents; } }