fix inventory

This commit is contained in:
root
2026-06-11 11:06:32 -04:00
parent 9483750161
commit c91fa2ce4d
53 changed files with 2936 additions and 12666 deletions
@@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use App\Services\Inventory\InventoryMovementService;
use Illuminate\Console\Command;
class InventoryReconcile extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'inventory:reconcile
{--school-year= : The school year to reconcile (e.g., 2025-2026). Defaults to current.}
{--fix : Automatically fix discrepancies by creating adjustment movements.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reconcile inventory item quantities against movement ledger totals';
/**
* Execute the console command.
*/
public function handle(InventoryMovementService $movementService)
{
$schoolYear = $this->option('school-year');
$autoFix = $this->option('fix');
$result = $movementService->reconcile($schoolYear);
$this->info("School Year: {$result['school_year']}");
$this->info("Total Items: {$result['total_items']}");
$this->info("Discrepancies: {$result['discrepancies_count']}");
if (empty($result['discrepancies'])) {
$this->info('✅ All item quantities match movement ledger.');
return Command::SUCCESS;
}
$this->table(
['ID', 'Name', 'Stored Qty', 'Movement Sum', 'Variance'],
collect($result['discrepancies'])->map(fn ($d) => [
$d['id'],
$d['name'],
$d['stored_quantity'],
$d['movement_sum'],
$d['variance'],
])->toArray()
);
if ($autoFix) {
$this->info('Fixing discrepancies...');
foreach ($result['discrepancies'] as $d) {
if ($d['variance'] !== 0) {
$movementService->recordMovement(
itemId: $d['id'],
qtyChange: -$d['variance'],
type: 'adjust',
reason: 'Auto-reconciliation: stored qty differed from movement sum',
note: 'Reconciled from ' . $d['stored_quantity'] . ' to ' . $d['movement_sum'],
performedBy: null
);
$this->line(" Fixed item #{$d['id']} ({$d['name']}): variance was {$d['variance']}");
}
}
$this->info('✅ Fix applied.');
} else {
$this->warn('Run with --fix to automatically correct discrepancies.');
}
return Command::SUCCESS;
}
}
@@ -11,7 +11,9 @@ use App\Http\Requests\Inventory\InventoryItemUpdateRequest;
use App\Http\Requests\Inventory\InventoryTeacherDistributionRequest;
use App\Http\Requests\Inventory\InventoryTeacherDistributionStoreRequest;
use App\Http\Resources\Inventory\InventoryItemResource;
use App\Models\InventoryItem;
use App\Services\Inventory\InventoryItemService;
use App\Services\Inventory\InventoryMovementService;
use App\Services\Inventory\InventorySummaryService;
use App\Services\Inventory\InventoryTeacherDistributionService;
use Illuminate\Http\JsonResponse;
@@ -23,7 +25,8 @@ class InventoryController extends BaseApiController
public function __construct(
private InventoryItemService $items,
private InventorySummaryService $summary,
private InventoryTeacherDistributionService $distribution
private InventoryTeacherDistributionService $distribution,
private InventoryMovementService $movementService
) {
parent::__construct();
}
@@ -182,6 +185,161 @@ class InventoryController extends BaseApiController
return $this->success($result);
}
/**
* Get low-stock items (where quantity <= reorder_point).
*/
public function lowStock(): JsonResponse
{
$items = InventoryItem::lowStock()
->with('category', 'preferredSupplier')
->orderBy('name')
->get();
return $this->success([
'items' => InventoryItemResource::collection($items),
'count' => $items->count(),
]);
}
/**
* Get reorder suggestions based on low-stock items.
*/
public function reorderSuggestions(): JsonResponse
{
$lowStockItems = InventoryItem::lowStock()
->with('category', 'preferredSupplier')
->orderBy('name')
->get();
$suggestions = $lowStockItems->map(function ($item) {
$reorderQty = (int) ($item->reorder_quantity ?: 0);
$currentQty = (int) $item->quantity;
$reorderPoint = (int) ($item->reorder_point ?: 0);
// If no reorder quantity is set, suggest enough to bring back above reorder point
if ($reorderQty <= 0) {
$reorderQty = max(1, $reorderPoint - $currentQty + 5);
}
return [
'id' => $item->id,
'name' => $item->name,
'current_quantity' => $currentQty,
'reorder_point' => $reorderPoint,
'suggested_order_qty' => $reorderQty,
'preferred_supplier' => $item->preferredSupplier ? [
'id' => $item->preferredSupplier->id,
'name' => $item->preferredSupplier->name,
] : null,
'category' => $item->category ? $item->category->name : null,
];
});
return $this->success([
'suggestions' => $suggestions,
'count' => $suggestions->count(),
]);
}
/**
* Create a reorder request for a low-stock item.
*/
public function reorderRequest(int $id): JsonResponse
{
$item = InventoryItem::with('preferredSupplier')->find($id);
if (!$item) {
return $this->error('Item not found.', Response::HTTP_NOT_FOUND);
}
$reorderQty = (int) ($item->reorder_quantity ?: 0);
if ($reorderQty <= 0) {
$reorderQty = max(1, ((int) ($item->reorder_point ?: 0)) - ((int) $item->quantity) + 5);
}
return $this->success([
'item_id' => $item->id,
'item_name' => $item->name,
'current_quantity' => (int) $item->quantity,
'suggested_quantity' => $reorderQty,
'preferred_supplier' => $item->preferredSupplier ? [
'id' => $item->preferredSupplier->id,
'name' => $item->preferredSupplier->name,
] : null,
]);
}
/**
* View all items with their stock status.
*/
public function stockStatus(): JsonResponse
{
$type = request()->query('type');
$items = InventoryItem::query()
->with('category', 'preferredSupplier')
->when($type, fn ($q) => $q->where('type', $type))
->orderBy('name')
->get()
->map(function ($item) {
$qty = (int) $item->quantity;
$reorderPoint = (int) ($item->reorder_point ?: 0);
$status = 'ok';
if ($qty <= 0) {
$status = 'out_of_stock';
} elseif ($reorderPoint > 0 && $qty <= $reorderPoint) {
$status = 'low_stock';
}
return [
'id' => $item->id,
'name' => $item->name,
'type' => $item->type,
'category' => $item->category ? $item->category->name : null,
'quantity' => $qty,
'reorder_point' => $reorderPoint,
'status' => $status,
];
});
$counts = [
'ok' => $items->where('status', 'ok')->count(),
'low_stock' => $items->where('status', 'low_stock')->count(),
'out_of_stock' => $items->where('status', 'out_of_stock')->count(),
];
return $this->success([
'items' => $items,
'counts' => $counts,
]);
}
/**
* Inventory dashboard summary.
*/
public function dashboard(): JsonResponse
{
$schoolYear = request()->query('school_year');
$items = InventoryItem::query()
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
->get();
$totalItems = $items->count();
$byType = $items->groupBy('type')->map(fn ($g) => $g->count());
$lowStockCount = $items->filter(fn ($i) => $i->reorder_point && $i->quantity <= $i->reorder_point)->count();
$outOfStockCount = $items->filter(fn ($i) => $i->quantity <= 0)->count();
$needsRepairCount = $items->sum('needs_repair_qty');
$missingCount = $items->sum('cannot_find_qty');
return $this->success([
'total_items' => $totalItems,
'by_type' => $byType,
'low_stock_count' => $lowStockCount,
'out_of_stock_count' => $outOfStockCount,
'needs_repair_count' => (int) $needsRepairCount,
'missing_count' => (int) $missingCount,
]);
}
/**
* @return int|JsonResponse
*/
@@ -73,6 +73,57 @@ class InventoryMovementController extends BaseApiController
return $this->success($result);
}
/**
* Void a movement (append-only correction).
*/
public function void(int $id): JsonResponse
{
$actor = $this->authenticatedUserIdOrUnauthorized();
if ($actor instanceof JsonResponse) {
return $actor;
}
$reason = (string) request()->input('reason', '');
if (empty(trim($reason))) {
return $this->error('Reason is required to void a movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$result = $this->movements->voidMovement($id, $reason, $actor);
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to void movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success(['message' => 'Movement voided successfully.']);
}
/**
* Create a correction movement.
*/
public function correct(int $id): JsonResponse
{
$actor = $this->authenticatedUserIdOrUnauthorized();
if ($actor instanceof JsonResponse) {
return $actor;
}
$qtyChange = (int) request()->input('qty_change', 0);
$reason = (string) request()->input('reason', '');
if ($qtyChange === 0) {
return $this->error('Quantity change must be non-zero.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
if (empty(trim($reason))) {
return $this->error('Reason is required.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$result = $this->movements->correctMovement($id, $qtyChange, $reason, $actor);
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to correct movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success(['message' => 'Correction movement created.']);
}
/**
* @return int|JsonResponse
*/
@@ -14,13 +14,13 @@ class InventoryItemStoreRequest extends ApiFormRequest
public function rules(): array
{
return [
'type' => ['required', 'string', 'max:20'],
'category_id' => ['nullable', 'integer', 'min:1'],
'type' => ['required', 'string', 'in:classroom,book,office,kitchen'],
'category_id' => ['nullable', 'integer', 'min:1', 'exists:inventory_categories,id'],
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'quantity' => ['nullable', 'integer'],
'quantity' => ['nullable', 'integer', 'min:0'],
'unit' => ['nullable', 'string', 'max:50'],
'condition' => ['nullable', 'string', 'max:50'],
'condition' => ['nullable', 'string', 'in:good,needs_repair,need_replace,cannot_find'],
'isbn' => ['nullable', 'string', 'max:50'],
'edition' => ['nullable', 'string', 'max:50'],
'sku' => ['nullable', 'string', 'max:50'],
@@ -14,12 +14,12 @@ class InventoryItemUpdateRequest extends ApiFormRequest
public function rules(): array
{
return [
'category_id' => ['nullable', 'integer', 'min:1'],
'category_id' => ['nullable', 'integer', 'min:1', 'exists:inventory_categories,id'],
'name' => ['sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'quantity' => ['nullable', 'integer'],
// quantity is intentionally excluded: stock changes must go through movements
'unit' => ['nullable', 'string', 'max:50'],
'condition' => ['nullable', 'string', 'max:50'],
'condition' => ['nullable', 'string', 'in:good,needs_repair,need_replace,cannot_find'],
'isbn' => ['nullable', 'string', 'max:50'],
'edition' => ['nullable', 'string', 'max:50'],
'sku' => ['nullable', 'string', 'max:50'],
@@ -28,6 +28,16 @@ class InventoryItemResource extends JsonResource
'semester' => $this['semester'] ?? null,
'school_year' => $this['school_year'] ?? null,
'updated_by' => $this['updated_by'] ?? null,
// New fields
'preferred_supplier_id' => $this['preferred_supplier_id'] ?? null,
'last_purchase_price' => $this['last_purchase_price'] ?? null,
'average_unit_cost' => $this['average_unit_cost'] ?? null,
'reorder_point' => $this['reorder_point'] ?? null,
'reorder_quantity' => $this['reorder_quantity'] ?? null,
'lead_time_days' => $this['lead_time_days'] ?? null,
'barcode' => $this['barcode'] ?? null,
'qr_code' => $this['qr_code'] ?? null,
'asset_tag' => $this['asset_tag'] ?? null,
];
}
}
+39
View File
@@ -30,6 +30,17 @@ class InventoryItem extends BaseModel
'needs_repair_qty',
'need_replace_qty',
'cannot_find_qty',
// Supplier/reorder fields
'preferred_supplier_id',
'last_purchase_price',
'average_unit_cost',
'reorder_point',
'reorder_quantity',
'lead_time_days',
// Barcode/QR fields
'barcode',
'qr_code',
'asset_tag',
];
protected $casts = [
@@ -41,6 +52,13 @@ class InventoryItem extends BaseModel
'needs_repair_qty' => 'integer',
'need_replace_qty' => 'integer',
'cannot_find_qty' => 'integer',
'preferred_supplier_id' => 'integer',
'last_purchase_price' => 'decimal:2',
'average_unit_cost' => 'decimal:2',
'reorder_point' => 'integer',
'reorder_quantity' => 'integer',
'lead_time_days' => 'integer',
];
/* Optional relationships */
@@ -48,4 +66,25 @@ class InventoryItem extends BaseModel
{
return $this->belongsTo(InventoryCategory::class, 'category_id');
}
public function preferredSupplier()
{
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
}
public function movements()
{
return $this->hasMany(InventoryMovement::class, 'item_id');
}
public function scopeLowStock($query)
{
return $query->whereNotNull('reorder_point')
->whereColumn('quantity', '<=', 'reorder_point');
}
public function scopeOutOfStock($query)
{
return $query->where('quantity', '<=', 0);
}
}
+37
View File
@@ -23,6 +23,13 @@ class InventoryMovement extends BaseModel
'teacher_id',
'student_id',
'class_section_id',
// Audit/correction fields
'voided_at',
'voided_by',
'void_reason',
'corrects_movement_id',
'source_type',
'source_id',
];
protected $casts = [
@@ -32,8 +39,23 @@ class InventoryMovement extends BaseModel
'teacher_id' => 'integer',
'student_id' => 'integer',
'class_section_id'=> 'integer',
'voided_by' => 'integer',
'corrects_movement_id' => 'integer',
'source_id' => 'integer',
'voided_at' => 'datetime',
];
/* Scopes */
public function scopeNotVoided($query)
{
return $query->whereNull('voided_at');
}
public function scopeVoided($query)
{
return $query->whereNotNull('voided_at');
}
/* Optional relationships */
public function item()
{
@@ -59,4 +81,19 @@ class InventoryMovement extends BaseModel
{
return $this->belongsTo(ClassSection::class, 'class_section_id');
}
public function voidedBy()
{
return $this->belongsTo(User::class, 'voided_by');
}
public function correctsMovement()
{
return $this->belongsTo(self::class, 'corrects_movement_id');
}
public function corrections()
{
return $this->hasMany(self::class, 'corrects_movement_id');
}
}
+1
View File
@@ -12,6 +12,7 @@ class PurchaseOrderItem extends BaseModel
protected $fillable = [
'purchase_order_id',
'supply_id',
'inventory_item_id',
'description',
'quantity',
'received_qty',
@@ -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),
];
}
}
@@ -6,11 +6,17 @@ use App\Models\PurchaseOrder;
use App\Models\PurchaseOrderItem;
use App\Models\Supply;
use App\Models\SupplyTransaction;
use App\Services\Inventory\InventoryMovementService;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class PurchaseOrderReceiveService
{
public function __construct(
private InventoryMovementService $inventoryMovementService
) {
}
public function receive(int $poId, array $received, string $issuedBy): array
{
$po = PurchaseOrder::query()->find($poId);
@@ -51,24 +57,36 @@ class PurchaseOrderReceiveService
$poItem->received_qty = (int) $poItem->received_qty + $toReceive;
$poItem->save();
// Update supply qty_on_hand (legacy support)
$supply = Supply::query()->find($poItem->supply_id);
if (!$supply) {
$completed = false;
continue;
if ($supply) {
$supply->qty_on_hand = (int) $supply->qty_on_hand + $toReceive;
$supply->save();
SupplyTransaction::query()->create([
'supply_id' => $supply->id,
'type' => 'in',
'quantity' => $toReceive,
'ref' => 'PO ' . ($po->po_number ?? $po->id),
'issued_to' => 'Inventory',
'issued_by' => $issuedBy,
'notes' => 'Received against PO',
]);
}
$supply->qty_on_hand = (int) $supply->qty_on_hand + $toReceive;
$supply->save();
SupplyTransaction::query()->create([
'supply_id' => $supply->id,
'type' => 'in',
'quantity' => $toReceive,
'ref' => 'PO ' . ($po->po_number ?? $po->id),
'issued_to' => 'Inventory',
'issued_by' => $issuedBy,
'notes' => 'Received against PO',
]);
// Create inventory movement if the PO item is linked to an inventory item
$inventoryItemId = $poItem->inventory_item_id;
if ($inventoryItemId) {
$performedBy = is_numeric($issuedBy) ? (int) $issuedBy : null;
$this->inventoryMovementService->recordMovement(
itemId: (int) $inventoryItemId,
qtyChange: $toReceive,
type: 'in',
reason: 'Purchase order received',
note: 'PO ' . ($po->po_number ?? $po->id),
performedBy: $performedBy
);
}
if ($poItem->received_qty < $poItem->quantity) {
$completed = false;