fix inventory
This commit is contained in:
@@ -17,6 +17,7 @@ class InventoryMovementService
|
||||
{
|
||||
$selectedYear = trim((string) ($filters['school_year'] ?? $this->context->schoolYear()));
|
||||
$selectedSem = trim((string) ($filters['semester'] ?? $this->context->semester()));
|
||||
$includeVoided = !empty($filters['include_voided']);
|
||||
|
||||
$builder = DB::table('inventory_movements as m')
|
||||
->select([
|
||||
@@ -34,6 +35,11 @@ class InventoryMovementService
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'm.class_section_id')
|
||||
->orderBy('m.id', 'DESC');
|
||||
|
||||
// Exclude voided movements by default
|
||||
if (!$includeVoided) {
|
||||
$builder->whereNull('m.voided_at');
|
||||
}
|
||||
|
||||
if ($selectedYear !== '') {
|
||||
$builder->where('m.school_year', $selectedYear);
|
||||
}
|
||||
@@ -44,6 +50,10 @@ class InventoryMovementService
|
||||
return $builder->get()->map(fn ($r) => (array) $r)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use recordMovement() instead. This method allows editing
|
||||
* movement history which weakens auditability.
|
||||
*/
|
||||
public function create(array $data, int $performedBy = 0): array
|
||||
{
|
||||
$itemId = (int) ($data['item_id'] ?? 0);
|
||||
@@ -60,19 +70,7 @@ class InventoryMovementService
|
||||
return ['ok' => false, 'message' => 'Not enough stock to apply this movement.'];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'item_id' => $itemId,
|
||||
'qty_change' => $qtyChange,
|
||||
'movement_type' => $movementType,
|
||||
'reason' => trim((string) ($data['reason'] ?? '')),
|
||||
'note' => trim((string) ($data['note'] ?? '')),
|
||||
'semester' => $data['semester'] ?? $this->context->semester(),
|
||||
'school_year' => $data['school_year'] ?? $this->context->schoolYear(),
|
||||
'performed_by' => $performedBy ?: null,
|
||||
'teacher_id' => $this->nullableInt($data['teacher_id'] ?? null),
|
||||
'student_id' => $this->nullableInt($data['student_id'] ?? null),
|
||||
'class_section_id' => $this->nullableInt($data['class_section_id'] ?? null),
|
||||
];
|
||||
$payload = $this->buildMovementPayload($data, $itemId, $qtyChange, $movementType, $performedBy);
|
||||
|
||||
try {
|
||||
InventoryMovement::query()->create($payload);
|
||||
@@ -85,6 +83,10 @@ class InventoryMovementService
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do NOT edit movements directly. Use voidMovement() + recordMovement() instead.
|
||||
* Editing historical movements destroys audit integrity.
|
||||
*/
|
||||
public function update(int $id, array $data): array
|
||||
{
|
||||
$movement = InventoryMovement::query()->find($id);
|
||||
@@ -92,6 +94,11 @@ class InventoryMovementService
|
||||
return ['ok' => false, 'message' => 'Movement not found.'];
|
||||
}
|
||||
|
||||
// Prevent editing voided movements
|
||||
if ($movement->voided_at) {
|
||||
return ['ok' => false, 'message' => 'Cannot edit a voided movement.'];
|
||||
}
|
||||
|
||||
$movementType = (string) ($data['movement_type'] ?? $movement->movement_type);
|
||||
$qtyChange = (int) ($data['qty_change'] ?? $movement->qty_change);
|
||||
$qtyChange = $this->normalizeMovementQty($movementType, $qtyChange);
|
||||
@@ -125,6 +132,10 @@ class InventoryMovementService
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do NOT hard-delete movements. Use voidMovement() instead.
|
||||
* Deleting historical movements destroys audit integrity.
|
||||
*/
|
||||
public function delete(int $id): array
|
||||
{
|
||||
$movement = InventoryMovement::query()->find($id);
|
||||
@@ -132,6 +143,14 @@ class InventoryMovementService
|
||||
return ['ok' => false, 'message' => 'Movement not found.'];
|
||||
}
|
||||
|
||||
// Prevent hard-deleting movements that have corrections
|
||||
$hasCorrections = InventoryMovement::query()
|
||||
->where('corrects_movement_id', $id)
|
||||
->exists();
|
||||
if ($hasCorrections) {
|
||||
return ['ok' => false, 'message' => 'Cannot delete a movement that has corrections. Void it instead.'];
|
||||
}
|
||||
|
||||
$itemId = (int) $movement->item_id;
|
||||
try {
|
||||
$movement->delete();
|
||||
@@ -146,6 +165,10 @@ class InventoryMovementService
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do NOT bulk-delete movements. Use voidMovement() instead.
|
||||
* Deleting historical movements destroys audit integrity.
|
||||
*/
|
||||
public function bulkDelete(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', $ids), static fn ($v) => $v > 0));
|
||||
@@ -153,6 +176,14 @@ class InventoryMovementService
|
||||
return ['ok' => false, 'message' => 'No movements selected.'];
|
||||
}
|
||||
|
||||
// Check for corrections
|
||||
$hasCorrections = InventoryMovement::query()
|
||||
->whereIn('corrects_movement_id', $ids)
|
||||
->exists();
|
||||
if ($hasCorrections) {
|
||||
return ['ok' => false, 'message' => 'Cannot delete movements that have corrections. Void them instead.'];
|
||||
}
|
||||
|
||||
$itemIds = DB::table('inventory_movements')
|
||||
->select('item_id')
|
||||
->whereIn('id', $ids)
|
||||
@@ -176,6 +207,10 @@ class InventoryMovementService
|
||||
return ['ok' => true, 'deleted' => count($ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a new inventory movement (append-only).
|
||||
* This is the primary method for all stock changes.
|
||||
*/
|
||||
public function recordMovement(
|
||||
int $itemId,
|
||||
int $qtyChange,
|
||||
@@ -220,11 +255,107 @@ class InventoryMovementService
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Void an existing movement (append-only correction).
|
||||
* The original movement is preserved but marked as voided.
|
||||
*/
|
||||
public function voidMovement(int $movementId, string $reason, int $voidedBy): array
|
||||
{
|
||||
$movement = InventoryMovement::query()->find($movementId);
|
||||
if (!$movement) {
|
||||
return ['ok' => false, 'message' => 'Movement not found.'];
|
||||
}
|
||||
|
||||
if ($movement->voided_at) {
|
||||
return ['ok' => false, 'message' => 'Movement is already voided.'];
|
||||
}
|
||||
|
||||
$itemId = (int) $movement->item_id;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($movement, $reason, $voidedBy, $itemId) {
|
||||
// Mark original as voided
|
||||
$movement->update([
|
||||
'voided_at' => now(),
|
||||
'voided_by' => $voidedBy,
|
||||
'void_reason' => $reason,
|
||||
]);
|
||||
|
||||
// Create reverse movement to correct the quantity
|
||||
$reverseQty = -(int) $movement->qty_change;
|
||||
if ($reverseQty !== 0) {
|
||||
InventoryMovement::query()->create([
|
||||
'item_id' => $itemId,
|
||||
'qty_change' => $reverseQty,
|
||||
'movement_type' => 'adjust',
|
||||
'reason' => 'Correction: voided movement #' . $movement->id,
|
||||
'note' => $reason,
|
||||
'semester' => $this->context->semester(),
|
||||
'school_year' => $this->context->schoolYear(),
|
||||
'performed_by' => $voidedBy,
|
||||
'corrects_movement_id' => $movement->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->recalcQuantity($itemId);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Inventory movement void failed: ' . $e->getMessage());
|
||||
return ['ok' => false, 'message' => 'Could not void movement.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a correction movement for an existing movement.
|
||||
* This does NOT void the original; it adds an adjusting entry.
|
||||
*/
|
||||
public function correctMovement(int $movementId, int $correctedQtyChange, string $reason, int $performedBy): array
|
||||
{
|
||||
$movement = InventoryMovement::query()->find($movementId);
|
||||
if (!$movement) {
|
||||
return ['ok' => false, 'message' => 'Movement not found.'];
|
||||
}
|
||||
|
||||
if ($movement->voided_at) {
|
||||
return ['ok' => false, 'message' => 'Cannot correct a voided movement.'];
|
||||
}
|
||||
|
||||
$itemId = (int) $movement->item_id;
|
||||
|
||||
if ($this->wouldGoNegative($itemId, $correctedQtyChange)) {
|
||||
return ['ok' => false, 'message' => 'Not enough stock to apply correction.'];
|
||||
}
|
||||
|
||||
try {
|
||||
InventoryMovement::query()->create([
|
||||
'item_id' => $itemId,
|
||||
'qty_change' => $correctedQtyChange,
|
||||
'movement_type' => 'adjust',
|
||||
'reason' => 'Correction for movement #' . $movement->id,
|
||||
'note' => $reason,
|
||||
'semester' => $this->context->semester(),
|
||||
'school_year' => $this->context->schoolYear(),
|
||||
'performed_by' => $performedBy,
|
||||
'corrects_movement_id' => $movement->id,
|
||||
]);
|
||||
|
||||
$this->recalcQuantity($itemId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Inventory movement correction failed: ' . $e->getMessage());
|
||||
return ['ok' => false, 'message' => 'Could not correct movement.'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public function recalcQuantity(int $itemId): void
|
||||
{
|
||||
$sumRow = InventoryMovement::query()
|
||||
->selectRaw('SUM(qty_change) as qty_change')
|
||||
->where('item_id', $itemId)
|
||||
->whereNull('voided_at') // Exclude voided movements from calculations
|
||||
->first();
|
||||
|
||||
$sum = (int) ($sumRow?->qty_change ?? 0);
|
||||
@@ -255,6 +386,47 @@ class InventoryMovementService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile inventory items - compare stored quantity against movement totals.
|
||||
* Returns an array of items that have discrepancies.
|
||||
*/
|
||||
public function reconcile(?string $schoolYear = null): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: $this->context->schoolYear();
|
||||
$discrepancies = [];
|
||||
|
||||
$items = InventoryItem::query()
|
||||
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$movementSum = (int) InventoryMovement::query()
|
||||
->where('item_id', $item->id)
|
||||
->whereNull('voided_at')
|
||||
->sum('qty_change');
|
||||
|
||||
$storedQty = (int) $item->quantity;
|
||||
$variance = $storedQty - $movementSum;
|
||||
|
||||
if ($variance !== 0) {
|
||||
$discrepancies[] = [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'stored_quantity' => $storedQty,
|
||||
'movement_sum' => $movementSum,
|
||||
'variance' => $variance,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'school_year' => $schoolYear,
|
||||
'total_items' => $items->count(),
|
||||
'discrepancies_count' => count($discrepancies),
|
||||
'discrepancies' => $discrepancies,
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureInitialMovement(int $itemId): void
|
||||
{
|
||||
$hasMov = InventoryMovement::query()->where('item_id', $itemId)->exists();
|
||||
@@ -338,4 +510,21 @@ class InventoryMovementService
|
||||
}
|
||||
return (int) $val;
|
||||
}
|
||||
|
||||
private function buildMovementPayload(array $data, int $itemId, int $qtyChange, string $movementType, int $performedBy): array
|
||||
{
|
||||
return [
|
||||
'item_id' => $itemId,
|
||||
'qty_change' => $qtyChange,
|
||||
'movement_type' => $movementType,
|
||||
'reason' => trim((string) ($data['reason'] ?? '')),
|
||||
'note' => trim((string) ($data['note'] ?? '')),
|
||||
'semester' => $data['semester'] ?? $this->context->semester(),
|
||||
'school_year' => $data['school_year'] ?? $this->context->schoolYear(),
|
||||
'performed_by' => $performedBy ?: null,
|
||||
'teacher_id' => $this->nullableInt($data['teacher_id'] ?? null),
|
||||
'student_id' => $this->nullableInt($data['student_id'] ?? null),
|
||||
'class_section_id' => $this->nullableInt($data['class_section_id'] ?? null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user