Files
GSB-Construction/Modules/MaterialLogistics/app/Models/MaterialTransfer.php

192 lines
6.5 KiB
PHP

<?php
namespace Modules\MaterialLogistics\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\ApprovalWorkflow\Models\ApprovalChain;
use Modules\ApprovalWorkflow\Traits\HasApprovable;
use Modules\MaterialLogistics\Enums\MaterialTransferStatus;
use Modules\ProjectManagement\Models\Project;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Modules\MaterialLogistics\Models\WarehouseStock;
use Modules\MaterialLogistics\Models\InventoryMovement;
class MaterialTransfer extends Model
{
use HasApprovable;
protected $fillable = [
'transfer_number',
'from_project_id',
'to_project_id',
'status',
'requested_by',
'approved_by',
'dispatched_by',
'received_by',
'notes',
];
protected function casts(): array
{
return [
'status' => MaterialTransferStatus::class,
];
}
public static function generateTransferNumber(): string
{
$prefix = 'MT-' . date('Ym') . '-';
$last = self::where('transfer_number', 'like', "{$prefix}%")
->orderBy('id', 'desc')
->first();
if (! $last) {
return $prefix . '0001';
}
$lastNumber = (int) substr($last->transfer_number, -4);
return $prefix . str_pad((string) ($lastNumber + 1), 4, '0', STR_PAD_LEFT);
}
public function onApprovalCompleted(ApprovalChain $chain): void
{
if ($chain->status->value === 'approved') {
$lastStep = $chain->steps()->where('status', 'approved')->orderByDesc('acted_at')->first();
$this->update([
'status' => MaterialTransferStatus::Approved,
'approved_by' => $lastStep ? $lastStep->approver_id : null,
]);
} elseif ($chain->status->value === 'rejected') {
$this->update([
'status' => MaterialTransferStatus::Draft,
]);
}
}
public function markAsReceived(int $receivedById): void
{
if ($this->status !== MaterialTransferStatus::Approved && $this->status !== MaterialTransferStatus::InTransit) {
throw new \LogicException('Transfer must be Approved or In Transit to be received.');
}
DB::transaction(function () use ($receivedById) {
$fromWarehouse = $this->fromProject->warehouse;
$toWarehouse = $this->toProject->warehouse;
if (!$fromWarehouse || !$toWarehouse) {
throw new \LogicException('Both projects must have an assigned warehouse.');
}
foreach ($this->items as $item) {
$qty = $item->received_quantity ?? $item->requested_quantity;
if ($qty <= 0) {
continue;
}
// Deduct from Source
$sourceStock = WarehouseStock::where('warehouse_id', $fromWarehouse->id)
->where('material_id', $item->material_id)
->lockForUpdate()
->first();
if (!$sourceStock || $sourceStock->quantity < $qty) {
// Decide: abort or allow negative. We abort for safety.
throw new \LogicException("Insufficient stock in source warehouse for material ID {$item->material_id}.");
}
$sourceStock->decrement('quantity', $qty);
InventoryMovement::create([
'ulid' => (string) Str::ulid(),
'material_id' => $item->material_id,
'warehouse_id' => $fromWarehouse->id,
'project_id' => $this->from_project_id,
'movement_type' => 'dispatch',
'direction' => 'out',
'quantity' => $qty,
'balance_after' => $sourceStock->quantity - $qty,
'to_location_type' => 'warehouse',
'to_location_id' => $toWarehouse->id,
'reference_type' => MaterialTransfer::class,
'reference_id' => $this->id,
'performed_by' => $receivedById,
]);
// Add to Destination
$destStock = WarehouseStock::firstOrCreate(
['warehouse_id' => $toWarehouse->id, 'material_id' => $item->material_id],
['ulid' => (string) Str::ulid(), 'quantity' => 0]
);
// Re-fetch to lock
$destStock = WarehouseStock::where('id', $destStock->id)->lockForUpdate()->first();
$destStock->increment('quantity', $qty);
InventoryMovement::create([
'ulid' => (string) Str::ulid(),
'material_id' => $item->material_id,
'warehouse_id' => $toWarehouse->id,
'project_id' => $this->to_project_id,
'movement_type' => 'site_receipt',
'direction' => 'in',
'quantity' => $qty,
'balance_after' => $destStock->quantity + $qty,
'from_location_type' => 'warehouse',
'from_location_id' => $fromWarehouse->id,
'reference_type' => MaterialTransfer::class,
'reference_id' => $this->id,
'performed_by' => $receivedById,
]);
}
$this->update([
'status' => MaterialTransferStatus::Received,
'received_by' => $receivedById,
]);
});
}
// --- Relationships ---
public function items(): HasMany
{
return $this->hasMany(MaterialTransferItem::class);
}
public function fromProject(): BelongsTo
{
return $this->belongsTo(Project::class, 'from_project_id');
}
public function toProject(): BelongsTo
{
return $this->belongsTo(Project::class, 'to_project_id');
}
public function requester(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by');
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by');
}
public function dispatcher(): BelongsTo
{
return $this->belongsTo(User::class, 'dispatched_by');
}
public function receiver(): BelongsTo
{
return $this->belongsTo(User::class, 'received_by');
}
}