192 lines
6.5 KiB
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');
|
|
}
|
|
}
|