$warehouse->id, 'material_id' => $material->id], ['quantity' => 0, 'reserved_qty' => 0, 'unit_cost' => $unitCost] ); $stock->increment('quantity', $quantity); // Weighted average cost if ($stock->unit_cost != $unitCost && $stock->quantity > 0) { $totalBefore = ($stock->quantity - $quantity) * $stock->unit_cost; $totalNew = $quantity * $unitCost; $stock->update(['unit_cost' => ($totalBefore + $totalNew) / $stock->quantity]); } $stock->refresh(); $batch = InventoryBatch::create([ 'warehouse_id' => $warehouse->id, 'material_id' => $material->id, 'material_requisition_id' => $materialRequisitionId, 'purchase_order_id' => $purchaseOrder?->id, 'quantity' => $quantity, 'unit_cost' => $unitCost, ]); $this->recordMovement( material: $material, type: MovementType::PurchaseReceipt, direction: 'in', quantity: $quantity, balanceAfter: $stock->quantity, warehouseId: $warehouse->id, fromType: 'external', toId: $warehouse->id, reference: $purchaseOrder, performedBy: $performedBy, notes: $purchaseOrder ? "PO #{$purchaseOrder->document_number}" : null, inventoryBatchId: $batch->id, ); return $stock; }); } /** * Dispatch materials from warehouse to a project site. */ public function dispatchToProject( Warehouse $warehouse, Project $project, Material $material, float $quantity, ?int $inventoryBatchId = null, ?int $performedBy = null, ?int $dispatchedBy = null, ?string $notes = null, ): InventoryMovement { return DB::transaction(function () use ($warehouse, $project, $material, $quantity, $inventoryBatchId, $performedBy, $dispatchedBy, $notes) { $stock = WarehouseStock::where('warehouse_id', $warehouse->id) ->where('material_id', $material->id) ->firstOrFail(); if ($stock->available_qty < $quantity) { throw new \InvalidArgumentException( "Insufficient warehouse stock. Available: {$stock->available_qty}, Requested: {$quantity}" ); } if ($inventoryBatchId) { $batch = InventoryBatch::findOrFail($inventoryBatchId); if ($batch->quantity < $quantity) { throw new \InvalidArgumentException("Not enough remaining in this batch."); } $batch->decrement('quantity', $quantity); } // Deduct from warehouse $stock->decrement('quantity', $quantity); $stock->refresh(); // Add to project as on-hand immediately $projectInv = ProjectInventory::firstOrCreate( ['project_id' => $project->id, 'material_id' => $material->id], ['on_hand_qty' => 0, 'floated_qty' => 0, 'allocated_qty' => 0, 'consumed_qty' => 0, 'unit_cost' => $stock->unit_cost] ); $projectInv->increment('on_hand_qty', $quantity); return $this->recordMovement( material: $material, type: MovementType::Dispatch, direction: 'out', quantity: $quantity, balanceAfter: $stock->quantity, warehouseId: $warehouse->id, projectId: $project->id, fromType: 'warehouse', fromId: $warehouse->id, toType: 'project', toId: $project->id, performedBy: $performedBy, dispatchedBy: $dispatchedBy, notes: $notes, inventoryBatchId: $inventoryBatchId, ); }); } /** * Reserve materials at project site for a specific task. */ public function reserveForTask( Project $project, Task $task, Material $material, float $quantity, ?int $performedBy = null, ): InventoryMovement { return DB::transaction(function () use ($project, $task, $material, $quantity, $performedBy) { $projectInv = ProjectInventory::where('project_id', $project->id) ->where('material_id', $material->id) ->firstOrFail(); $available = (float) $projectInv->on_hand_qty - (float) $projectInv->allocated_qty; if ($available < $quantity) { throw new \InvalidArgumentException( "Insufficient on-site stock. Available: {$available}, Requested: {$quantity}" ); } // Reserve (earmark) — increases allocated $projectInv->increment('allocated_qty', $quantity); $projectInv->refresh(); // Create or update task material record $taskMaterial = TaskMaterial::firstOrCreate( ['task_id' => $task->id, 'material_id' => $material->id], ['planned_qty' => 0, 'actual_qty' => 0, 'unit_cost' => $projectInv->unit_cost] ); $taskMaterial->increment('planned_qty', $quantity); return $this->recordMovement( material: $material, type: MovementType::Reserve, direction: 'internal', quantity: $quantity, balanceAfter: $available - $quantity, projectId: $project->id, taskId: $task->id, fromType: 'project', fromId: $project->id, toType: 'task', toId: $task->id, performedBy: $performedBy, notes: "Reserved for task: {$task->name}", ); }); } /** * Consume materials at a task (actual usage deducted from inventory). */ public function consumeAtTask( Project $project, Task $task, Material $material, float $quantity, ?int $performedBy = null, ?string $notes = null, ): InventoryMovement { return DB::transaction(function () use ($project, $task, $material, $quantity, $performedBy, $notes) { $projectInv = ProjectInventory::where('project_id', $project->id) ->where('material_id', $material->id) ->firstOrFail(); if ((float) $projectInv->allocated_qty < $quantity) { throw new \InvalidArgumentException( "Cannot consume more than allocated. Allocated: {$projectInv->allocated_qty}, Consuming: {$quantity}" ); } // Deduct from on-hand and allocated, add to consumed $projectInv->decrement('on_hand_qty', $quantity); $projectInv->decrement('allocated_qty', $quantity); $projectInv->increment('consumed_qty', $quantity); $projectInv->refresh(); // Update task material actual qty $taskMaterial = TaskMaterial::where('task_id', $task->id) ->where('material_id', $material->id) ->first(); if ($taskMaterial) { $taskMaterial->increment('actual_qty', $quantity); } return $this->recordMovement( material: $material, type: MovementType::Consume, direction: 'out', quantity: $quantity, balanceAfter: $projectInv->on_hand_qty, projectId: $project->id, taskId: $task->id, fromType: 'task', fromId: $task->id, toType: 'external', performedBy: $performedBy, notes: $notes ?? "Consumed at task: {$task->name}", ); }); } /** * Return materials from project site back to warehouse. */ public function returnToWarehouse( Project $project, Warehouse $warehouse, Material $material, float $quantity, ?int $performedBy = null, ?int $returnedBy = null, ?string $notes = null, ): InventoryMovement { return DB::transaction(function () use ($project, $warehouse, $material, $quantity, $performedBy, $returnedBy, $notes) { $projectInv = ProjectInventory::where('project_id', $project->id) ->where('material_id', $material->id) ->firstOrFail(); $available = (float) $projectInv->on_hand_qty - (float) $projectInv->allocated_qty; if ($available < $quantity) { throw new \InvalidArgumentException( "Cannot return more than available (non-allocated). Available: {$available}, Returning: {$quantity}" ); } // Deduct from project $projectInv->decrement('on_hand_qty', $quantity); // Add back to warehouse $stock = WarehouseStock::firstOrCreate( ['warehouse_id' => $warehouse->id, 'material_id' => $material->id], ['quantity' => 0, 'reserved_qty' => 0, 'unit_cost' => $projectInv->unit_cost] ); $stock->increment('quantity', $quantity); $stock->refresh(); return $this->recordMovement( material: $material, type: MovementType::Return, direction: 'in', quantity: $quantity, balanceAfter: $stock->quantity, warehouseId: $warehouse->id, projectId: $project->id, fromType: 'project', fromId: $project->id, toType: 'warehouse', toId: $warehouse->id, performedBy: $performedBy, returnedBy: $returnedBy, notes: $notes ?? 'Returned to warehouse', ); }); } /** * Manual stock adjustment for discrepancies. */ public function adjustStock( string $locationType, // 'warehouse' or 'project' int $locationId, Material $material, float $quantity, string $direction, // 'in' or 'out' string $reason, ?int $performedBy = null, ?int $adjustedBy = null, ): InventoryMovement { return DB::transaction(function () use ($locationType, $locationId, $material, $quantity, $direction, $reason, $performedBy, $adjustedBy) { $balanceAfter = 0; if ($locationType === 'warehouse') { $stock = WarehouseStock::firstOrCreate( ['warehouse_id' => $locationId, 'material_id' => $material->id], ['quantity' => 0, 'reserved_qty' => 0, 'unit_cost' => $material->unit_cost] ); if ($direction === 'in') { $stock->increment('quantity', $quantity); } else { if ($stock->available_qty < $quantity) { throw new \InvalidArgumentException("Adjustment would result in negative available stock."); } $stock->decrement('quantity', $quantity); } $stock->refresh(); $balanceAfter = $stock->quantity; } else { $projectInv = ProjectInventory::firstOrCreate( ['project_id' => $locationId, 'material_id' => $material->id], ['on_hand_qty' => 0, 'floated_qty' => 0, 'allocated_qty' => 0, 'consumed_qty' => 0, 'unit_cost' => $material->unit_cost] ); if ($direction === 'in') { $projectInv->increment('on_hand_qty', $quantity); } else { $available = (float) $projectInv->on_hand_qty - (float) $projectInv->allocated_qty; if ($available < $quantity) { throw new \InvalidArgumentException("Adjustment would result in negative available stock."); } $projectInv->decrement('on_hand_qty', $quantity); } $projectInv->refresh(); $balanceAfter = $projectInv->on_hand_qty; } return $this->recordMovement( material: $material, type: MovementType::Adjustment, direction: $direction, quantity: $quantity, balanceAfter: $balanceAfter, warehouseId: $locationType === 'warehouse' ? $locationId : null, projectId: $locationType === 'project' ? $locationId : null, fromType: $direction === 'out' ? $locationType : 'external', fromId: $direction === 'out' ? $locationId : null, toType: $direction === 'in' ? $locationType : 'external', toId: $direction === 'in' ? $locationId : null, performedBy: $performedBy, adjustedBy: $adjustedBy, notes: "Adjustment: {$reason}", ); }); } /** * Record a movement in the ledger. */ private function recordMovement( Material $material, MovementType $type, string $direction, float $quantity, float $balanceAfter, ?int $warehouseId = null, ?int $projectId = null, ?int $taskId = null, ?string $fromType = null, ?int $fromId = null, ?string $toType = null, ?int $toId = null, ?object $reference = null, ?int $performedBy = null, ?int $dispatchedBy = null, ?int $receivedBy = null, ?int $returnedBy = null, ?int $adjustedBy = null, ?string $notes = null, ?int $inventoryBatchId = null, ): InventoryMovement { return InventoryMovement::create([ 'material_id' => $material->id, 'warehouse_id' => $warehouseId, 'inventory_batch_id' => $inventoryBatchId, 'project_id' => $projectId, 'task_id' => $taskId, 'movement_type' => $type, 'direction' => $direction, 'quantity' => $quantity, 'balance_after' => $balanceAfter, 'from_location_type' => $fromType, 'from_location_id' => $fromId, 'to_location_type' => $toType, 'to_location_id' => $toId, 'reference_type' => $reference ? get_class($reference) : null, 'reference_id' => $reference?->id, 'performed_by' => $performedBy, 'dispatched_by' => $dispatchedBy, 'received_by' => $receivedBy, 'returned_by' => $returnedBy, 'adjusted_by' => $adjustedBy, 'notes' => $notes, ]); } }