418 lines
16 KiB
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,
|
|
]);
|
|
}
|
|
}
|