add refund logic and fix books inventory logic
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s

This commit is contained in:
root
2026-08-22 13:44:25 -04:00
parent 23d1cbb64c
commit d906a915d6
45 changed files with 4711 additions and 442 deletions
+298 -51
View File
@@ -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();