add refund logic and fix books inventory logic
This commit is contained in:
@@ -76,9 +76,14 @@ class InventoryController extends BaseController
|
||||
$selectedSem = $selectedSemRaw === null ? (string) $this->semester : trim((string) $selectedSemRaw);
|
||||
|
||||
$builder = $this->itemModel->where('type', $type);
|
||||
$this->applyInventoryMovementPeriodFilter($builder, 'inventory_items', $selectedYear, $selectedSem);
|
||||
if ($type !== 'book') {
|
||||
$this->applyInventoryMovementPeriodFilter($builder, 'inventory_items', $selectedYear, $selectedSem);
|
||||
}
|
||||
|
||||
$items = $builder->orderBy('name', 'ASC')->findAll();
|
||||
if ($type === 'book') {
|
||||
$items = $this->attachBookYearRows($items, $selectedYear);
|
||||
}
|
||||
$categories = $this->catModel->optionsForType($type);
|
||||
|
||||
// NEW: build UpdatedBy name map
|
||||
@@ -106,6 +111,9 @@ class InventoryController extends BaseController
|
||||
|
||||
// Load categories for THIS item type
|
||||
$categories = $this->catModel->optionsForType($item['type']);
|
||||
if (($item['type'] ?? '') === 'book') {
|
||||
$item = $this->withBookYearData($item);
|
||||
}
|
||||
|
||||
return view($this->viewForForm($item['type']), [
|
||||
'type' => $item['type'],
|
||||
@@ -135,6 +143,9 @@ class InventoryController extends BaseController
|
||||
if (!$this->itemModel->update($id, $data)) {
|
||||
return redirect()->back()->withInput()->with('error', implode(', ', $this->itemModel->errors()));
|
||||
}
|
||||
if (($item['type'] ?? '') === 'book') {
|
||||
$this->saveBookClassAssignments($id);
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('inventory/' . $item['type']))->with('success', 'Item updated.');
|
||||
}
|
||||
@@ -148,6 +159,15 @@ class InventoryController extends BaseController
|
||||
$itemId = $this->itemModel->insert($itemData, true);
|
||||
if ($itemId) {
|
||||
$initialQty = (int) ($itemData['quantity'] ?? 0);
|
||||
if (($itemData['type'] ?? '') === 'book') {
|
||||
$this->saveBookClassAssignments((int) $itemId);
|
||||
if ($initialQty !== 0) {
|
||||
$this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock');
|
||||
} else {
|
||||
$this->recalcQuantity($itemId);
|
||||
}
|
||||
return redirect()->to(site_url("inventory/{$itemData['type']}"))->with('success', 'Item added.');
|
||||
}
|
||||
if ($initialQty !== 0) {
|
||||
$this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock');
|
||||
} else {
|
||||
@@ -341,6 +361,13 @@ class InventoryController extends BaseController
|
||||
if ($type === 'book') {
|
||||
$base['isbn'] = $this->post('isbn');
|
||||
$base['edition'] = $this->post('edition');
|
||||
$base['author'] = $this->post('author');
|
||||
$base['is_active'] = 1;
|
||||
$isbn = preg_replace('/[^0-9X]/i', '', strtoupper((string) $base['isbn']));
|
||||
$edition = strtolower(trim((string) $base['edition']));
|
||||
$sku = strtolower(trim((string) $base['sku']));
|
||||
$base['isbn_edition_key'] = $isbn !== '' ? $isbn . '|' . $edition : null;
|
||||
$base['sku_normalized'] = $sku !== '' ? $sku : null;
|
||||
} else {
|
||||
$base['isbn'] = $base['edition'] = null;
|
||||
}
|
||||
@@ -348,6 +375,191 @@ class InventoryController extends BaseController
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function withBookYearData(array $item): array
|
||||
{
|
||||
$itemYear = $this->bookItemYear((int) $item['id'], false);
|
||||
if ($itemYear !== null) {
|
||||
$item['charge_price'] = number_format(((int) ($itemYear['charge_price_cents'] ?? 0)) / 100, 2, '.', '');
|
||||
$item['price_confirmed'] = (int) ($itemYear['price_confirmed'] ?? 0);
|
||||
$item['item_year_id'] = (int) $itemYear['id'];
|
||||
$item['opening_quantity'] = (int) ($itemYear['opening_quantity'] ?? 0);
|
||||
}
|
||||
$item['classes'] = array_map(
|
||||
'intval',
|
||||
array_column(
|
||||
$this->db->table('inventory_book_class_assignments')
|
||||
->select('class_number')
|
||||
->where('inventory_item_id', (int) $item['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->orderBy('class_number', 'ASC')
|
||||
->get()->getResultArray(),
|
||||
'class_number'
|
||||
)
|
||||
);
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function attachBookYearRows(array $items, string $schoolYear): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_map(static fn (array $item): int => (int) ($item['id'] ?? 0), $items)));
|
||||
if ($ids === []) {
|
||||
return $items;
|
||||
}
|
||||
$rows = $this->db->table('inventory_item_years')
|
||||
->whereIn('inventory_item_id', $ids)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getResultArray();
|
||||
$byItem = [];
|
||||
foreach ($rows as $row) {
|
||||
$byItem[(int) $row['inventory_item_id']] = $row;
|
||||
}
|
||||
foreach ($items as &$item) {
|
||||
$row = $byItem[(int) ($item['id'] ?? 0)] ?? null;
|
||||
if ($row !== null) {
|
||||
$item['charge_price_cents'] = (int) ($row['charge_price_cents'] ?? 0);
|
||||
$item['price_confirmed'] = (int) ($row['price_confirmed'] ?? 0);
|
||||
$item['opening_quantity'] = (int) ($row['opening_quantity'] ?? 0);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function bookPrices()
|
||||
{
|
||||
$books = $this->itemModel
|
||||
->where('type', 'book')
|
||||
->orderBy('name', 'ASC')
|
||||
->findAll();
|
||||
$books = $this->attachBookYearRows($books, (string) $this->schoolYear);
|
||||
|
||||
return view('inventory/book/prices', [
|
||||
'books' => $books,
|
||||
'categories' => $this->catModel->optionsForType('book'),
|
||||
'schoolYear' => $this->schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateBookPrices()
|
||||
{
|
||||
$prices = (array) $this->request->getPost('prices');
|
||||
$confirmed = (array) $this->request->getPost('confirmed');
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', array_keys($prices)))));
|
||||
|
||||
if ($ids === []) {
|
||||
return redirect()->back()->with('warning', 'No book prices were submitted.');
|
||||
}
|
||||
|
||||
$books = $this->itemModel
|
||||
->where('type', 'book')
|
||||
->whereIn('id', $ids)
|
||||
->findAll();
|
||||
$booksById = [];
|
||||
foreach ($books as $book) {
|
||||
$booksById[(int) $book['id']] = $book;
|
||||
}
|
||||
|
||||
$year = $this->currentSchoolYearRow();
|
||||
$updated = 0;
|
||||
|
||||
try {
|
||||
$this->db->transStart();
|
||||
foreach ($ids as $id) {
|
||||
if (! isset($booksById[$id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isConfirmed = isset($confirmed[$id]) && (string) $confirmed[$id] === '1';
|
||||
$priceCents = $this->moneyValueToCents($prices[$id] ?? '');
|
||||
if ($isConfirmed && $priceCents <= 0) {
|
||||
throw new \InvalidArgumentException('Confirmed book prices must be greater than $0.00.');
|
||||
}
|
||||
|
||||
$itemYear = $this->bookItemYear($id, false);
|
||||
$payload = [
|
||||
'inventory_item_id' => $id,
|
||||
'school_year_id' => (int) ($year['id'] ?? 0) ?: null,
|
||||
'school_year' => $this->schoolYear,
|
||||
'charge_price_cents' => $priceCents,
|
||||
'currency' => 'USD',
|
||||
'price_confirmed' => $isConfirmed ? 1 : 0,
|
||||
'status' => 'open',
|
||||
'updated_by' => $this->currentUserId(),
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($itemYear === null) {
|
||||
$payload['opening_quantity'] = (int) ($booksById[$id]['quantity'] ?? 0);
|
||||
$payload['created_by'] = $this->currentUserId();
|
||||
$payload['created_at'] = utc_now();
|
||||
$this->db->table('inventory_item_years')->insert($payload);
|
||||
} else {
|
||||
$this->db->table('inventory_item_years')->where('id', (int) $itemYear['id'])->update($payload);
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($this->db->transStatus() === false) {
|
||||
throw new \RuntimeException('Book prices could not be saved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('inventory/book-prices'))->with('success', 'Saved prices for ' . $updated . ' book(s).');
|
||||
}
|
||||
|
||||
private function saveBookClassAssignments(int $itemId): void
|
||||
{
|
||||
$classes = array_values(array_unique(array_filter(
|
||||
array_map('intval', (array) $this->request->getPost('classes')),
|
||||
static fn (int $class): bool => $class >= 1 && $class <= 13
|
||||
)));
|
||||
$this->db->table('inventory_book_class_assignments')
|
||||
->where('inventory_item_id', $itemId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->delete();
|
||||
foreach ($classes as $classNumber) {
|
||||
$this->db->table('inventory_book_class_assignments')->insert([
|
||||
'inventory_item_id' => $itemId,
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_number' => $classNumber,
|
||||
'created_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function moneyValueToCents($value): int
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return 0;
|
||||
}
|
||||
if (! preg_match('/^\d+(?:\.\d{1,2})?$/', $raw)) {
|
||||
throw new \InvalidArgumentException('Book charge price must be a valid dollar amount with at most two decimals.');
|
||||
}
|
||||
return (int) round(((float) $raw) * 100);
|
||||
}
|
||||
|
||||
private function currentSchoolYearRow(): ?array
|
||||
{
|
||||
return $this->db->table('school_years')
|
||||
->where('name', $this->schoolYear)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
}
|
||||
|
||||
private function bookItemYear(int $itemId, bool $forUpdate = false): ?array
|
||||
{
|
||||
$sql = 'SELECT * FROM inventory_item_years WHERE inventory_item_id = ? AND school_year = ? LIMIT 1';
|
||||
if ($forUpdate) {
|
||||
$sql .= ' FOR UPDATE';
|
||||
}
|
||||
return $this->db->query($sql, [$itemId, $this->schoolYear])->getRowArray();
|
||||
}
|
||||
|
||||
public function auditClassroomForm(int $itemId)
|
||||
{
|
||||
$item = $this->itemModel->find($itemId);
|
||||
@@ -742,12 +954,17 @@ class InventoryController extends BaseController
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table('inventory_items i')
|
||||
->select('i.id,i.name,i.isbn,i.edition,i.quantity,i.category_id,i.type,
|
||||
iy.id AS item_year_id, iy.charge_price_cents, iy.price_confirmed,
|
||||
c.name AS category_name, c.grade_min, c.grade_max')
|
||||
->join('inventory_categories c', 'c.id = i.category_id', 'left')
|
||||
->join('inventory_item_years iy', 'iy.inventory_item_id = i.id AND iy.school_year = ' . $db->escape($this->schoolYear), 'left')
|
||||
->join('inventory_book_class_assignments bca', 'bca.inventory_item_id = i.id AND bca.school_year = ' . $db->escape($this->schoolYear), 'left')
|
||||
->where('i.type', 'book');
|
||||
|
||||
if (!empty($classID)) {
|
||||
$builder->groupStart()
|
||||
->where('bca.class_number', (int) $classID)
|
||||
->orGroupStart()
|
||||
// Case A: both bounds set and classID between [min..max]
|
||||
->groupStart()
|
||||
->where('c.grade_min IS NOT NULL', null, false)
|
||||
@@ -769,10 +986,11 @@ class InventoryController extends BaseController
|
||||
->where('c.grade_min', null) // IS NULL
|
||||
->where('c.grade_max', (int)$classID) // =
|
||||
->groupEnd()
|
||||
->groupEnd()
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$rows = $builder->orderBy('i.name', 'ASC')->get()->getResultArray();
|
||||
$rows = $builder->groupBy('i.id')->orderBy('i.name', 'ASC')->get()->getResultArray();
|
||||
|
||||
// Build CATEGORY groups keyed by category_id
|
||||
$labelFor = static function (?array $r): string {
|
||||
@@ -803,6 +1021,8 @@ class InventoryController extends BaseController
|
||||
'id' => (int)$r['id'],
|
||||
'display' => $display,
|
||||
'quantity' => (int)($r['quantity'] ?? 0),
|
||||
'charge_price_cents' => (int)($r['charge_price_cents'] ?? 0),
|
||||
'price_confirmed' => (int)($r['price_confirmed'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -819,16 +1039,16 @@ class InventoryController extends BaseController
|
||||
// 7) History map: how many of the selected book each student already received this school year
|
||||
$already = [];
|
||||
if ($itemId && $classSectionId) {
|
||||
$rowsHist = $this->movModel
|
||||
->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty')
|
||||
->where([
|
||||
'item_id' => $itemId,
|
||||
'movement_type' => 'distribution',
|
||||
'class_section_id' => $classSectionId,
|
||||
'school_year' => $ctx['schoolYear'],
|
||||
])
|
||||
$itemYear = $this->bookItemYear($itemId, false);
|
||||
$rowsHist = $this->db->table('student_book_issues')
|
||||
->select('student_id, SUM(quantity) AS qty')
|
||||
->where('inventory_item_id', $itemId)
|
||||
->where('inventory_item_year_id', (int) ($itemYear['id'] ?? 0))
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $ctx['schoolYear'])
|
||||
->where('status', 'issued')
|
||||
->groupBy('student_id')
|
||||
->findAll();
|
||||
->get()->getResultArray();
|
||||
|
||||
foreach ($rowsHist as $r) {
|
||||
$sid = (int) ($r['student_id'] ?? 0);
|
||||
@@ -838,11 +1058,16 @@ class InventoryController extends BaseController
|
||||
|
||||
// 8) On-hand for the selected book (to show live available stock)
|
||||
$onHand = 0;
|
||||
$unitChargeCents = 0;
|
||||
$priceConfirmed = 0;
|
||||
if ($itemId) {
|
||||
$book = $this->itemModel->find($itemId);
|
||||
if ($book && ($book['type'] ?? '') === 'book') {
|
||||
$onHand = (int) ($book['quantity'] ?? 0);
|
||||
}
|
||||
$itemYear = $this->bookItemYear($itemId, false);
|
||||
$unitChargeCents = (int) ($itemYear['charge_price_cents'] ?? 0);
|
||||
$priceConfirmed = (int) ($itemYear['price_confirmed'] ?? 0);
|
||||
}
|
||||
|
||||
// 9) Render view
|
||||
@@ -858,6 +1083,8 @@ class InventoryController extends BaseController
|
||||
'students' => $students,
|
||||
'already' => $already,
|
||||
'onHand' => $onHand,
|
||||
'unitChargeCents' => $unitChargeCents,
|
||||
'priceConfirmed' => $priceConfirmed,
|
||||
|
||||
'schoolYear' => $ctx['schoolYear'],
|
||||
'semester' => $ctx['semester'],
|
||||
@@ -910,50 +1137,34 @@ class InventoryController extends BaseController
|
||||
$selectedIds = array_values(array_unique(array_map('intval', $selectedIds)));
|
||||
$selectedIds = array_values(array_filter($selectedIds, fn($sid) => isset($validIds[$sid])));
|
||||
|
||||
// Already distributed this year for this class/book
|
||||
$already = [];
|
||||
$rows = $this->movModel
|
||||
->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty')
|
||||
->where([
|
||||
'item_id' => $itemId,
|
||||
'movement_type' => 'distribution',
|
||||
'class_section_id' => $classSectionId,
|
||||
'school_year' => $ctx['schoolYear'],
|
||||
])->groupBy('student_id')->findAll();
|
||||
foreach ($rows as $r) {
|
||||
$sid = (int)($r['student_id'] ?? 0);
|
||||
if ($sid) $already[$sid] = (int)$r['qty'];
|
||||
try {
|
||||
$year = $this->currentSchoolYearRow();
|
||||
$result = (new \App\Services\StudentBookIssueService($this->db))->distributeBatch(
|
||||
$itemId,
|
||||
$selectedIds,
|
||||
$classSectionId,
|
||||
(int) ($year['id'] ?? 0),
|
||||
(string) $ctx['schoolYear'],
|
||||
$this->currentUserId(),
|
||||
$note,
|
||||
null,
|
||||
(string) $this->request->getPost('idempotency_key')
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()
|
||||
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
||||
->withInput()
|
||||
->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
// Only assign to students who don't already have it
|
||||
$toGive = [];
|
||||
foreach ($selectedIds as $sid) {
|
||||
if (($already[$sid] ?? 0) < 1) $toGive[] = $sid;
|
||||
}
|
||||
|
||||
// Stock check
|
||||
$needed = count($toGive);
|
||||
$onHand = (int)$book['quantity'];
|
||||
if ($needed > $onHand) {
|
||||
return redirect()->back()->withInput()->with('error', 'Not enough stock: need ' . $needed . ', on hand ' . $onHand . '.');
|
||||
}
|
||||
|
||||
// Record one movement per student
|
||||
$okCount = 0;
|
||||
foreach ($toGive as $sid) {
|
||||
$ok = $this->recordMovement($itemId, -1, 'distribution', 'Teacher distribution', $note, $classSectionId, $sid);
|
||||
if ($ok) $okCount++;
|
||||
}
|
||||
|
||||
if ($okCount === 0) {
|
||||
if ((int) ($result['issued'] ?? 0) === 0) {
|
||||
return redirect()
|
||||
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
||||
->with('warning', 'No changes (everyone selected already has this book).');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
||||
->with('success', 'Distributed to ' . $okCount . ' student(s).');
|
||||
->with('success', 'Distributed to ' . (int) $result['issued'] . ' student(s).');
|
||||
}
|
||||
|
||||
|
||||
@@ -1105,7 +1316,9 @@ class InventoryController extends BaseController
|
||||
|
||||
public function create(string $type = 'classroom')
|
||||
{
|
||||
$type = $this->normalizeType($type);
|
||||
$categories = $this->catModel->optionsForType($type);
|
||||
|
||||
return view("inventory/{$type}/form", [
|
||||
'type' => $type,
|
||||
'item' => [], // important for create
|
||||
@@ -1347,6 +1560,10 @@ class InventoryController extends BaseController
|
||||
$userId = (int) (session('user_id') ?? 0);
|
||||
$itemId = (int) $this->request->getPost('item_id');
|
||||
$movementType = (string) $this->request->getPost('movement_type');
|
||||
if ($movementType === 'distribution') {
|
||||
return redirect()->back()->withInput()->with('status', 'error')
|
||||
->with('message', 'Student book distributions must be created from the book distribution workflow.');
|
||||
}
|
||||
|
||||
$item = $this->itemModel->find($itemId);
|
||||
if (!$item) {
|
||||
@@ -1404,6 +1621,10 @@ class InventoryController extends BaseController
|
||||
return redirect()->to(site_url('inventory/movements'))
|
||||
->with('status', 'error')->with('message', 'Movement not found.');
|
||||
}
|
||||
if ($this->isProtectedMovement($movement)) {
|
||||
return redirect()->to(site_url('inventory/movements'))
|
||||
->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.');
|
||||
}
|
||||
|
||||
// enrich for display (names)
|
||||
$movement = $this->hydrateNames($movement);
|
||||
@@ -1449,8 +1670,16 @@ class InventoryController extends BaseController
|
||||
return redirect()->to(site_url('inventory/movements'))
|
||||
->with('status', 'error')->with('message', 'Movement not found.');
|
||||
}
|
||||
if ($this->isProtectedMovement($existing)) {
|
||||
return redirect()->to(site_url('inventory/movements'))
|
||||
->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.');
|
||||
}
|
||||
|
||||
$movementType = (string) $this->request->getPost('movement_type');
|
||||
if ($movementType === 'distribution') {
|
||||
return redirect()->back()->withInput()->with('status', 'error')
|
||||
->with('message', 'Student book distributions must be created from the book distribution workflow.');
|
||||
}
|
||||
$qtyChange = $this->normalizeMovementQty($movementType, (int) $this->request->getPost('qty_change'));
|
||||
|
||||
$itemId = (int) ($existing['item_id'] ?? 0);
|
||||
@@ -1504,10 +1733,13 @@ class InventoryController extends BaseController
|
||||
{
|
||||
// Optional: authorization checks here
|
||||
log_message('info', 'Inventory: delete one attempt', ['id' => $id, 'user' => (int)(session('user_id') ?? 0)]);
|
||||
$movement = $this->db->table('inventory_movements')->select('id, item_id')->where('id', $id)->get()->getRowArray();
|
||||
$movement = $this->db->table('inventory_movements')->select('*')->where('id', $id)->get()->getRowArray();
|
||||
if (!$movement) {
|
||||
return redirect()->back()->with('status', 'error')->with('message', 'Movement not found.');
|
||||
}
|
||||
if ($this->isProtectedMovement($movement)) {
|
||||
return redirect()->back()->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.');
|
||||
}
|
||||
|
||||
$ok = $this->db->table('inventory_movements')->where('id', $id)->delete();
|
||||
log_message('info', 'Inventory: delete one result', ['id' => $id, 'ok' => (bool)$ok, 'affected' => $this->db->affectedRows()]);
|
||||
@@ -1577,6 +1809,16 @@ class InventoryController extends BaseController
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isProtectedMovement(array $movement): bool
|
||||
{
|
||||
$type = (string) ($movement['movement_type'] ?? '');
|
||||
$sourceType = (string) ($movement['source_type'] ?? '');
|
||||
return $type === 'distribution'
|
||||
|| in_array($sourceType, ['student_book_issue', 'student_book_issue_reversal'], true)
|
||||
|| (int) ($movement['source_id'] ?? 0) > 0
|
||||
|| (int) ($movement['reversal_of_movement_id'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Hydrate display names for a row (used in edit) */
|
||||
private function hydrateNames(array $m): array
|
||||
{
|
||||
@@ -1682,13 +1924,18 @@ class InventoryController extends BaseController
|
||||
}
|
||||
|
||||
log_message('info', 'Inventory: bulk delete attempt', ['count' => count($ids), 'ids' => $ids, 'user' => (int)(session('user_id') ?? 0)]);
|
||||
$itemIds = $this->db->table('inventory_movements')
|
||||
->select('item_id')
|
||||
$movements = $this->db->table('inventory_movements')
|
||||
->select('*')
|
||||
->whereIn('id', $ids)
|
||||
->get()->getResultArray();
|
||||
foreach ($movements as $movement) {
|
||||
if ($this->isProtectedMovement($movement)) {
|
||||
return redirect()->back()->with('status','error')->with('message','Bulk delete contains issue-backed or distribution movement #' . (int) $movement['id'] . '. Use correction reversal instead.');
|
||||
}
|
||||
}
|
||||
$itemIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn($r) => (int) ($r['item_id'] ?? 0),
|
||||
$itemIds
|
||||
$movements
|
||||
))));
|
||||
|
||||
$ok = $this->db->table('inventory_movements')->whereIn('id', $ids)->delete();
|
||||
|
||||
@@ -18,8 +18,8 @@ use \App\Models\EnrollmentModel;
|
||||
use \App\Models\EventChargesModel;
|
||||
use \App\Models\EventModel;
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Services\SchoolIdService;
|
||||
use App\Services\FeeCalculationService;
|
||||
use App\Services\SchoolIdService;
|
||||
use App\Services\PhoneFormatterService;
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use App\Support\Enrollment\EnrollmentEligibility;
|
||||
@@ -441,8 +441,6 @@ class ParentController extends BaseController
|
||||
|
||||
public function enrollClassesHandler()
|
||||
{
|
||||
$refundService = new FeeCalculationService();
|
||||
|
||||
// Retrieve enrollment and withdrawal data from the POST request
|
||||
$enroll = $this->request->getPost('enroll'); // Selected students for enrollment
|
||||
$withdraw = $this->request->getPost('withdraw'); // Selected students for withdrawal
|
||||
@@ -697,97 +695,14 @@ class ParentController extends BaseController
|
||||
->getRowArray();
|
||||
|
||||
if ($enrollment !== null) {
|
||||
// Update enrollment as withdrawn
|
||||
$enrollmentStatusService = \Config\Services::enrollmentStatus(false);
|
||||
$enrollmentStatusService->upsertStatus([
|
||||
'id' => (int) $enrollment['id'],
|
||||
'student_id' => (int) $studentId,
|
||||
'parent_id' => (int) ($enrollment['parent_id'] ?? $parentId),
|
||||
'school_year' => (string) $this->schoolYear,
|
||||
'semester' => (string) ($enrollment['semester'] ?? $this->semester),
|
||||
'withdrawal_date' => local_date(utc_now(), 'Y-m-d'),
|
||||
'enrollment_status' => 'withdraw under review', // Withdrawal needs review
|
||||
'updated_at' => utc_now()
|
||||
], (int) $parentId, 'parent_withdrawal_requested');
|
||||
$withdrawalRequestDate = local_date(utc_now(), 'Y-m-d');
|
||||
service('withdrawalFinancial')->requestWithdrawal(
|
||||
(int) $enrollment['id'],
|
||||
$withdrawalRequestDate,
|
||||
(int) $parentId
|
||||
);
|
||||
log_message('info', "Student ID $studentId has been withdrawn from enrollment ID {$enrollment['id']}.");
|
||||
$withdrawalResultMessages[] = 'Student ID ' . $studentId . ': withdraw under review';
|
||||
|
||||
// === Trigger refund process ===
|
||||
// Find the related invoice (you may need to adjust this based on your DB structure)
|
||||
$invoice = $this->db->table('invoices')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($invoice !== null) {
|
||||
$invoiceId = $invoice['id'];
|
||||
$studentsForRefund = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
$refundAmount = $refundService->calculateRefund($studentsForRefund, (int) $parentId);
|
||||
$refundCents = max(0, (int) round($refundAmount * 100));
|
||||
|
||||
$refundTable = $this->db->table('refunds');
|
||||
|
||||
$existingRefund = $refundTable
|
||||
->where('parent_id', $parentId)
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->whereIn('status', ['Pending', 'Approved', 'Partial', 'pending', 'requested', 'approved', 'partial', 'partially_paid'])
|
||||
->get()
|
||||
->getRow();
|
||||
|
||||
if ($existingRefund) {
|
||||
// Only update the fields that should change
|
||||
$updateData = [
|
||||
'refund_amount' => $refundAmount,
|
||||
'requested_amount_cents' => $refundCents,
|
||||
'currency' => 'USD',
|
||||
'reason' => 'Withdrawal under review for student ID ' . $studentId,
|
||||
'note' => null,
|
||||
'request' => 'tuition',
|
||||
'source_type' => 'tuition_withdrawal',
|
||||
'source_id' => (int) $invoiceId,
|
||||
'status' => 'Pending',
|
||||
'updated_by' => session()->get('user_id'), // optionally track updates
|
||||
// Add other fields if *and only if* they must be changed
|
||||
];
|
||||
|
||||
$refundTable
|
||||
->where('id', $existingRefund->id)
|
||||
->update($this->filterPayloadByTableColumns($updateData, 'refunds'));
|
||||
|
||||
log_message('info', "Refund record updated for invoice ID {$invoiceId}, student ID {$studentId}.");
|
||||
} else {
|
||||
// Only set these once, for new entries
|
||||
$insertData = [
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'refund_amount' => $refundAmount,
|
||||
'requested_amount_cents' => $refundCents,
|
||||
'approved_amount_cents' => null,
|
||||
'currency' => 'USD',
|
||||
'requested_at' => utc_now(),
|
||||
'school_year' => $this->schoolYear,
|
||||
'status' => 'Pending',
|
||||
'reason' => 'Withdrawal under review for student ID ' . $studentId,
|
||||
'request' => 'tuition',
|
||||
'source_type' => 'tuition_withdrawal',
|
||||
'source_id' => (int) $invoiceId,
|
||||
'semester' => $this->semester,
|
||||
'refund_paid_amount' => 0.0,
|
||||
];
|
||||
|
||||
$refundTable->insert($this->filterPayloadByTableColumns($insertData, 'refunds'));
|
||||
|
||||
log_message('info', "Refund record created for invoice ID {$invoiceId}, student ID {$studentId}.");
|
||||
}
|
||||
} else {
|
||||
log_message('error', "No invoice found for parent ID {$parentId}, student ID {$studentId}.");
|
||||
}
|
||||
} else {
|
||||
log_message('error', "No active enrollment found for student ID $studentId.");
|
||||
$withdrawalErrors[] = 'Student ID ' . $studentId . ': no active enrollment was found.';
|
||||
|
||||
@@ -16,7 +16,7 @@ use App\Models\PaymentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Services\FeeCalculationService;
|
||||
use App\Services\EnrollmentStatusService;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
|
||||
class RefundController extends BaseController
|
||||
@@ -32,7 +32,6 @@ class RefundController extends BaseController
|
||||
protected ParentLedgerService $parentLedgerService;
|
||||
protected RefundEligibilityService $refundEligibilityService;
|
||||
protected FinancialAttachmentService $financialAttachmentService;
|
||||
protected FeeCalculationService $feeCalculationService;
|
||||
protected $db;
|
||||
|
||||
// Allowed request types (mapped to your `refunds.request` column)
|
||||
@@ -54,7 +53,6 @@ class RefundController extends BaseController
|
||||
$this->parentLedgerService = new ParentLedgerService();
|
||||
$this->refundEligibilityService = new RefundEligibilityService();
|
||||
$this->financialAttachmentService = new FinancialAttachmentService();
|
||||
$this->feeCalculationService = new FeeCalculationService();
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
@@ -723,6 +721,10 @@ class RefundController extends BaseController
|
||||
throw new FinancialPersistenceException('REFUND_PROJECTION_UPDATE_FAILED', $this->refundModel->errors());
|
||||
}
|
||||
|
||||
if (!$isOnline && $newStatus === FinancialStatus::REFUND_PAID) {
|
||||
$this->markWithdrawalEnrollmentComplete($lockedRefund);
|
||||
}
|
||||
|
||||
if (!$isOnline && $affectedInvoiceId > 0) {
|
||||
$this->invoiceLedgerService->recalculateInvoice($affectedInvoiceId);
|
||||
}
|
||||
@@ -774,6 +776,49 @@ class RefundController extends BaseController
|
||||
return $this->response->setJSON($payload);
|
||||
}
|
||||
|
||||
private function markWithdrawalEnrollmentComplete(array $refund): void
|
||||
{
|
||||
if ((string)($refund['source_type'] ?? '') !== 'tuition_withdrawal') {
|
||||
return;
|
||||
}
|
||||
|
||||
$calculationId = (int)($refund['withdrawal_calculation_id'] ?? 0);
|
||||
if ($calculationId <= 0 || ! $this->db->tableExists('withdrawal_financial_calculations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$calculation = $this->db->query(
|
||||
'SELECT enrollment_id FROM withdrawal_financial_calculations WHERE id = ? FOR UPDATE',
|
||||
[$calculationId]
|
||||
)->getRowArray();
|
||||
$enrollmentId = (int)($calculation['enrollment_id'] ?? 0);
|
||||
if ($enrollmentId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$enrollment = $this->db->query(
|
||||
'SELECT * FROM enrollments WHERE id = ? FOR UPDATE',
|
||||
[$enrollmentId]
|
||||
)->getRowArray();
|
||||
if (! $enrollment) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statusService = new EnrollmentStatusService($this->db);
|
||||
$statusService->upsertStatus([
|
||||
'id' => (int)$enrollment['id'],
|
||||
'student_id' => (int)$enrollment['student_id'],
|
||||
'parent_id' => (int)$enrollment['parent_id'],
|
||||
'school_year' => (string)$enrollment['school_year'],
|
||||
'semester' => (string)($enrollment['semester'] ?? ''),
|
||||
'enrollment_status' => 'withdrawn',
|
||||
'admission_status' => (string)($enrollment['admission_status'] ?? 'pending'),
|
||||
'is_withdrawn' => 1,
|
||||
'withdrawal_date' => $enrollment['withdrawal_date'] ?? null,
|
||||
'updated_at' => utc_now(),
|
||||
], (int)(session()->get('user_id') ?? 0) ?: null, 'withdrawal_refund_paid');
|
||||
}
|
||||
|
||||
public function reversePayout(?int $routePayoutId = null)
|
||||
{
|
||||
$payoutId = (int)($routePayoutId ?: $this->request->getPost('payout_id'));
|
||||
@@ -933,6 +978,7 @@ class RefundController extends BaseController
|
||||
{
|
||||
// Repair legacy withdrawal placeholders only; overpayment recalculation remains explicit.
|
||||
$this->repairPendingWithdrawalRefunds();
|
||||
$withdrawalReviews = $this->pendingWithdrawalReviews();
|
||||
|
||||
// 2) List refunds with joins
|
||||
$refunds = $this->refundModel
|
||||
@@ -987,10 +1033,41 @@ class RefundController extends BaseController
|
||||
|
||||
return view('refunds/list', [
|
||||
'refunds' => $refunds,
|
||||
'parentId' => $parentId
|
||||
'parentId' => $parentId,
|
||||
'withdrawalReviews' => $withdrawalReviews,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function pendingWithdrawalReviews(): array
|
||||
{
|
||||
try {
|
||||
if (! $this->db->tableExists('withdrawal_financial_calculations')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->db->table('withdrawal_financial_calculations wfc')
|
||||
->select('wfc.*,
|
||||
i.invoice_number,
|
||||
u.firstname AS parent_firstname,
|
||||
u.lastname AS parent_lastname,
|
||||
s.firstname AS student_firstname,
|
||||
s.lastname AS student_lastname')
|
||||
->join('invoices i', 'wfc.invoice_id = i.id', 'left')
|
||||
->join('users u', 'wfc.parent_id = u.id', 'left')
|
||||
->join('students s', 'wfc.student_id = s.id', 'left')
|
||||
->whereIn('wfc.status', ['preview', 'requires_review'])
|
||||
->where('wfc.posted_at', null)
|
||||
->orderBy('wfc.calculated_at', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Pending withdrawal review lookup failed: ' . $e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function repairPendingWithdrawalRefunds(): void
|
||||
{
|
||||
try {
|
||||
@@ -1021,24 +1098,48 @@ class RefundController extends BaseController
|
||||
continue;
|
||||
}
|
||||
|
||||
$students = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
$refundAmount = $this->feeCalculationService->calculateRefund($students, $parentId);
|
||||
$refundCents = max(0, (int)round($refundAmount * 100));
|
||||
|
||||
$this->refundModel->update((int)$refund['id'], [
|
||||
'invoice_id' => (int)$invoice['id'],
|
||||
'refund_amount' => $refundAmount,
|
||||
'requested_amount_cents' => $refundCents,
|
||||
'currency' => 'USD',
|
||||
'request' => 'tuition',
|
||||
'source_type' => 'tuition_withdrawal',
|
||||
'source_id' => (int)$invoice['id'],
|
||||
'reconciliation_status' => 'requires_review',
|
||||
'reconciliation_reason' => 'Legacy pending withdrawal refund requires recalculation through the withdrawal financial review workflow.',
|
||||
'reconciliation_required_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('withdrawal_financial_calculations')) {
|
||||
$postedRows = $this->db->table('refunds r')
|
||||
->select('r.id, r.refund_amount, r.requested_amount_cents, wfc.posted_at, wfc.posted_by')
|
||||
->join('withdrawal_financial_calculations wfc', 'wfc.id = r.withdrawal_calculation_id', 'inner')
|
||||
->where('r.source_type', 'tuition_withdrawal')
|
||||
->whereIn('r.status', ['Pending', 'pending', 'requested'])
|
||||
->where('wfc.status', 'posted')
|
||||
->where('wfc.posted_at IS NOT NULL', null, false)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($postedRows as $refund) {
|
||||
$amountCents = (int)($refund['requested_amount_cents'] ?? 0);
|
||||
if ($amountCents <= 0) {
|
||||
$amountCents = (int)round(((float)($refund['refund_amount'] ?? 0)) * 100);
|
||||
}
|
||||
if ($amountCents <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->refundModel->update((int)$refund['id'], [
|
||||
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_APPROVED),
|
||||
'approved_amount_cents' => $amountCents,
|
||||
'approved_at' => $refund['posted_at'] ?? utc_now(),
|
||||
'approved_by' => !empty($refund['posted_by']) ? (int)$refund['posted_by'] : null,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Pending withdrawal refund repair failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user