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'); } }