Files
GSB-Construction/Modules/MaterialLogistics/app/Services/WarehouseService.php

418 lines
16 KiB
PHP

<?php
namespace Modules\MaterialLogistics\Services;
use Illuminate\Support\Facades\DB;
use Modules\MaterialLogistics\Enums\MovementType;
use Modules\MaterialLogistics\Models\InventoryMovement;
use Modules\MasterData\Models\Material;
use Modules\MaterialLogistics\Models\ProjectInventory;
use Modules\MaterialLogistics\Models\PurchaseOrder;
use Modules\MaterialLogistics\Models\Warehouse;
use Modules\MaterialLogistics\Models\WarehouseStock;
use Modules\ProjectManagement\Models\Project;
use Modules\ProjectManagement\Models\Task;
use Modules\ProjectManagement\Models\TaskMaterial;
use Modules\MaterialLogistics\Models\InventoryBatch;
class WarehouseService
{
/**
* Receive materials into warehouse (triggered when PO is paid).
*/
public function receiveStock(
Warehouse $warehouse,
Material $material,
float $quantity,
float $unitCost,
?PurchaseOrder $purchaseOrder = null,
?int $materialRequisitionId = null,
?int $performedBy = null,
): WarehouseStock {
return DB::transaction(function () use ($warehouse, $material, $quantity, $unitCost, $purchaseOrder, $materialRequisitionId, $performedBy) {
$stock = WarehouseStock::firstOrCreate(
['warehouse_id' => $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,
]);
}
}