Files
alrahma_sunday_school/app/Commands/ReconcileLegacyBookIssue.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

197 lines
11 KiB
PHP

<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
/**
* Converts one legacy distribution movement into immutable, priced issue
* evidence. The existing stock movement is linked, never replayed, so this
* command cannot decrement inventory twice.
*/
class ReconcileLegacyBookIssue extends BaseCommand
{
protected $group = 'Inventory';
protected $name = 'inventory:reconcile-legacy-book-issue';
protected $description = 'List or explicitly reconcile legacy student book distributions without guessing a price.';
protected $usage = 'php spark inventory:reconcile-legacy-book-issue [--school-year=2025-2026] [--movement-id=123 --unit-price=25.00 --reason="approved evidence" --actor-id=7 --commit]';
protected $options = [
'--school-year' => '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;
}
}