add: full multi-tenancy control

This commit is contained in:
Cauê Faleiros
2026-02-02 15:31:15 -03:00
commit c6ec92802b
1711 changed files with 258106 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
{
"name": "krayin/laravel-lead",
"license": "MIT",
"authors": [
{
"name": "Jitendra Singh",
"email": "jitendra@webkul.com"
}
],
"require": {
"krayin/laravel-activity": "^1.0",
"krayin/laravel-attribute": "^1.0",
"krayin/laravel-contact": "^1.0",
"krayin/laravel-core": "^1.0",
"krayin/laravel-email": "^1.0",
"krayin/laravel-product": "^1.0",
"krayin/laravel-quote": "^1.0",
"krayin/laravel-tag": "^1.0",
"krayin/laravel-user": "^1.0"
},
"autoload": {
"psr-4": {
"Webkul\\Lead\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Webkul\\Lead\\Providers\\LeadServiceProvider"
],
"aliases": {}
}
},
"minimum-stability": "dev"
}

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Lead\Contracts;
interface Lead {}

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Lead\Contracts;
interface Pipeline {}

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Lead\Contracts;
interface Product {}

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Lead\Contracts;
interface Source {}

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Lead\Contracts;
interface Stage {}

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Lead\Contracts;
interface Type {}

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('lead_sources', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('lead_sources');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('lead_types', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('lead_types');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('lead_stages', function (Blueprint $table) {
$table->increments('id');
$table->string('code');
$table->string('name');
$table->boolean('is_user_defined')->default(1);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('lead_stages');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('lead_pipelines', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->boolean('is_default')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('lead_pipelines');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('lead_pipeline_stages', function (Blueprint $table) {
$table->increments('id');
$table->integer('probability')->default(0);
$table->integer('sort_order')->default(0);
$table->integer('lead_stage_id')->unsigned();
$table->foreign('lead_stage_id')->references('id')->on('lead_stages')->onDelete('cascade');
$table->integer('lead_pipeline_id')->unsigned();
$table->foreign('lead_pipeline_id')->references('id')->on('lead_pipelines')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('lead_pipeline_stages');
}
};

View File

@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('leads', function (Blueprint $table) {
$table->increments('id');
$table->string('title');
$table->text('description')->nullable();
$table->decimal('lead_value', 12, 4)->nullable();
$table->boolean('status')->nullable();
$table->text('lost_reason')->nullable();
$table->datetime('closed_at')->nullable();
$table->integer('user_id')->unsigned();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->integer('person_id')->unsigned();
$table->foreign('person_id')->references('id')->on('persons')->onDelete('cascade');
$table->integer('lead_source_id')->unsigned();
$table->foreign('lead_source_id')->references('id')->on('lead_sources')->onDelete('cascade');
$table->integer('lead_type_id')->unsigned();
$table->foreign('lead_type_id')->references('id')->on('lead_types')->onDelete('cascade');
$table->integer('lead_pipeline_id')->unsigned()->nullable();
$table->foreign('lead_pipeline_id')->references('id')->on('lead_pipelines')->onDelete('cascade');
$table->integer('lead_stage_id')->unsigned();
$table->foreign('lead_stage_id')->references('id')->on('lead_stages')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('leads');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('lead_products', function (Blueprint $table) {
$table->increments('id');
$table->integer('quantity')->default(0);
$table->decimal('price', 12, 4)->nullable();
$table->decimal('amount', 12, 4)->nullable();
$table->integer('lead_id')->unsigned();
$table->foreign('lead_id')->references('id')->on('leads')->onDelete('cascade');
$table->integer('product_id')->unsigned();
$table->foreign('product_id')->references('id')->on('products')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('lead_products');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('lead_activities', function (Blueprint $table) {
$table->integer('activity_id')->unsigned();
$table->foreign('activity_id')->references('id')->on('activities')->onDelete('cascade');
$table->integer('lead_id')->unsigned();
$table->foreign('lead_id')->references('id')->on('leads')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('lead_activities');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('lead_tags', function (Blueprint $table) {
$table->integer('tag_id')->unsigned();
$table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
$table->integer('lead_id')->unsigned();
$table->foreign('lead_id')->references('id')->on('leads')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('lead_tags');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('lead_quotes', function (Blueprint $table) {
$table->integer('quote_id')->unsigned();
$table->foreign('quote_id')->references('id')->on('quotes')->onDelete('cascade');
$table->integer('lead_id')->unsigned();
$table->foreign('lead_id')->references('id')->on('leads')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('lead_quotes');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('leads', function (Blueprint $table) {
$table->date('expected_close_date')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('leads', function (Blueprint $table) {
$table->dropColumn('expected_close_date');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('lead_pipelines', function (Blueprint $table) {
$table->integer('rotten_days')->after('is_default')->default(30);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('lead_pipelines', function (Blueprint $table) {
$table->dropColumn('rotten_days');
});
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$tablePrefix = DB::getTablePrefix();
Schema::table('lead_pipeline_stages', function (Blueprint $table) {
$table->string('code')->after('id')->nullable();
$table->string('name')->after('code')->nullable();
});
DB::table('lead_pipeline_stages')
->join('lead_stages', 'lead_pipeline_stages.lead_stage_id', '=', 'lead_stages.id')
->update([
'lead_pipeline_stages.code' => DB::raw($tablePrefix.'lead_stages.code'),
'lead_pipeline_stages.name' => DB::raw($tablePrefix.'lead_stages.name'),
]);
Schema::table('lead_pipeline_stages', function (Blueprint $table) use ($tablePrefix) {
$table->dropForeign($tablePrefix.'lead_pipeline_stages_lead_stage_id_foreign');
$table->dropColumn('lead_stage_id');
$table->unique(['code', 'lead_pipeline_id']);
$table->unique(['name', 'lead_pipeline_id']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('lead_pipeline_stages', function (Blueprint $table) {
$table->dropColumn('code');
$table->dropColumn('name');
$table->integer('lead_stage_id')->unsigned();
$table->foreign('lead_stage_id')->references('id')->on('lead_stages')->onDelete('cascade');
$table->dropUnique(['lead_pipeline_stages_code_lead_pipeline_id_unique', 'lead_pipeline_stages_name_lead_pipeline_id_unique']);
});
}
};

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$tablePrefix = DB::getTablePrefix();
Schema::table('leads', function (Blueprint $table) {
$table->integer('lead_pipeline_stage_id')->after('lead_pipeline_id')->unsigned()->nullable();
$table->foreign('lead_pipeline_stage_id')->references('id')->on('lead_pipeline_stages')->onDelete('cascade');
});
DB::table('leads')
->update([
'leads.lead_pipeline_stage_id' => DB::raw($tablePrefix.'leads.lead_stage_id'),
]);
Schema::table('leads', function (Blueprint $table) use ($tablePrefix) {
$table->dropForeign($tablePrefix.'leads_lead_stage_id_foreign');
$table->dropColumn('lead_stage_id');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('leads', function (Blueprint $table) {
$table->dropForeign(DB::getTablePrefix().'leads_lead_pipeline_stage_id_foreign');
$table->dropColumn('lead_pipeline_stage_id');
$table->integer('lead_stage_id')->unsigned();
$table->foreign('lead_stage_id')->references('id')->on('lead_stages')->onDelete('cascade');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('leads', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->nullable()->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('leads', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->nullable()->change();
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('leads', function (Blueprint $table) {
$table->dropForeign(['lead_pipeline_stage_id']);
$table->foreign('lead_pipeline_stage_id')->references('id')->on('lead_pipeline_stages')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('leads', function (Blueprint $table) {
$table->dropForeign(['lead_pipeline_stage_id']);
$table->foreign('lead_pipeline_stage_id')->references('id')->on('lead_pipeline_stages')->onDelete('cascade');
});
}
};

View File

@@ -0,0 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('leads', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->nullable()->change();
$table->integer('person_id')->unsigned()->nullable()->change();
$table->integer('lead_source_id')->unsigned()->nullable()->change();
$table->integer('lead_type_id')->unsigned()->nullable()->change();
$table->dropForeign(['user_id']);
$table->dropForeign(['person_id']);
$table->dropForeign(['lead_source_id']);
$table->dropForeign(['lead_type_id']);
$table->foreign('user_id')
->references('id')->on('users')
->onDelete('set null');
$table->foreign('person_id')
->references('id')->on('persons')
->onDelete('restrict');
$table->foreign('lead_source_id')
->references('id')->on('lead_sources')
->onDelete('restrict');
$table->foreign('lead_type_id')
->references('id')->on('lead_types')
->onDelete('restrict');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('leads', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->dropForeign(['person_id']);
$table->dropForeign(['lead_source_id']);
$table->dropForeign(['lead_type_id']);
$table->integer('user_id')->unsigned()->nullable()->change();
$table->integer('person_id')->unsigned()->nullable(false)->change();
$table->integer('lead_source_id')->unsigned()->nullable(false)->change();
$table->integer('lead_type_id')->unsigned()->nullable(false)->change();
$table->foreign('user_id')
->references('id')->on('users')
->onDelete('cascade');
$table->foreign('person_id')
->references('id')->on('persons')
->onDelete('cascade');
$table->foreign('lead_source_id')
->references('id')->on('lead_sources')
->onDelete('cascade');
$table->foreign('lead_type_id')
->references('id')->on('lead_types')
->onDelete('cascade');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('lead_pipelines', function (Blueprint $table) {
$table->string('name')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('lead_pipelines', function (Blueprint $table) {
$table->dropUnique(['name']);
$table->string('name')->change();
});
}
};

View File

@@ -0,0 +1,127 @@
<?php
namespace Webkul\Lead\Helpers;
use Illuminate\Support\Facades\Validator;
use Webkul\Admin\Http\Requests\LeadForm;
class MagicAI
{
/**
* Const Variable of LEAD_ENTITY.
*/
const LEAD_ENTITY = 'leads';
/**
* Const Variable of PERSON_ENTITY.
*/
const PERSON_ENTITY = 'persons';
/**
* Mapped the receive Extracted AI data.
*/
public static function mapAIDataToLead($aiData)
{
if (! empty($aiData['error'])) {
return self::errorHandler($aiData['error']);
}
$content = strip_tags($aiData['choices'][0]['message']['content']);
if (empty($content)) {
return self::errorHandler(trans('admin::app.leads.file.data-not-found'));
}
preg_match('/\{.*\}/s', $content, $matches);
if (isset($matches[0])) {
$jsonString = $matches[0];
} else {
return self::errorHandler(trans('admin::app.leads.file.invalid-response'));
}
$finalData = json_decode($jsonString);
if (json_last_error() !== JSON_ERROR_NONE) {
return self::errorHandler(trans('admin::app.leads.file.invalid-format'));
}
try {
self::validateLeadData($finalData);
$validatedData = app(LeadForm::class)->validated();
return array_merge($validatedData, self::prepareLeadData($finalData));
} catch (\Exception $e) {
return self::errorHandler($e->getMessage());
}
}
/**
* Validate the lead data.
*/
private static function validateLeadData($data)
{
$dataArray = json_decode(json_encode($data), true);
$validator = Validator::make($dataArray, [
'title' => 'required|string|max:255',
'lead_value' => 'required|numeric|min:0',
'person.name' => 'required|string|max:255',
'person.emails.value' => 'required|email',
'person.contact_numbers.value' => 'required|string|max:20',
]);
if ($validator->fails()) {
throw new \Illuminate\Validation\ValidationException(
$validator,
response()->json(self::errorHandler($validator->errors()->getMessages()), 400)
);
}
return $data;
}
/**
* Prepares the lead prompt data.
*/
private static function prepareLeadData($finalData)
{
return [
'status' => 1,
'title' => $finalData->title ?? 'N/A',
'description' => $finalData->description ?? null,
'lead_source_id' => 1,
'lead_type_id' => 1,
'lead_value' => $finalData->lead_value ?? 0,
'person' => [
'name' => $finalData->person->name ?? 'Unknown',
'emails' => [
[
'value' => $finalData->person->emails->value ?? null,
'label' => $finalData->person->emails->label ?? 'work',
],
],
'contact_numbers' => [
[
'value' => $finalData->person->contact_numbers->value ?? null,
'label' => $finalData->person->contact_numbers->label ?? 'work',
],
],
'entity_type' => self::PERSON_ENTITY,
],
'entity_type' => self::LEAD_ENTITY,
];
}
/**
* Prepares method for error handler.
*/
public static function errorHandler($message)
{
return [
'status' => 'error',
'message' => $message,
];
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace Webkul\Lead\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Webkul\Activity\Models\ActivityProxy;
use Webkul\Activity\Traits\LogsActivity;
use Webkul\Attribute\Traits\CustomAttribute;
use Webkul\Contact\Models\PersonProxy;
use Webkul\Email\Models\EmailProxy;
use Webkul\Lead\Contracts\Lead as LeadContract;
use Webkul\Quote\Models\QuoteProxy;
use Webkul\Tag\Models\TagProxy;
use Webkul\User\Models\UserProxy;
class Lead extends Model implements LeadContract
{
use CustomAttribute, LogsActivity;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'title',
'description',
'lead_value',
'status',
'lost_reason',
'expected_close_date',
'closed_at',
'user_id',
'person_id',
'lead_source_id',
'lead_type_id',
'lead_pipeline_id',
'lead_pipeline_stage_id',
];
/**
* Cast the attributes to their respective types.
*
* @var array
*/
protected $casts = [
'closed_at' => 'datetime:D M d, Y H:i A',
'expected_close_date' => 'date:D M d, Y',
];
/**
* The attributes that are appended.
*
* @var array
*/
protected $appends = [
'rotten_days',
];
/**
* Get the user that owns the lead.
*/
public function user(): BelongsTo
{
return $this->belongsTo(UserProxy::modelClass());
}
/**
* Get the person that owns the lead.
*/
public function person(): BelongsTo
{
return $this->belongsTo(PersonProxy::modelClass());
}
/**
* Get the type that owns the lead.
*/
public function type(): BelongsTo
{
return $this->belongsTo(TypeProxy::modelClass(), 'lead_type_id');
}
/**
* Get the source that owns the lead.
*/
public function source(): BelongsTo
{
return $this->belongsTo(SourceProxy::modelClass(), 'lead_source_id');
}
/**
* Get the pipeline that owns the lead.
*/
public function pipeline(): BelongsTo
{
return $this->belongsTo(PipelineProxy::modelClass(), 'lead_pipeline_id');
}
/**
* Get the pipeline stage that owns the lead.
*/
public function stage(): BelongsTo
{
return $this->belongsTo(StageProxy::modelClass(), 'lead_pipeline_stage_id');
}
/**
* Get the activities.
*/
public function activities(): BelongsToMany
{
return $this->belongsToMany(ActivityProxy::modelClass(), 'lead_activities');
}
/**
* Get the products.
*/
public function products(): HasMany
{
return $this->hasMany(ProductProxy::modelClass());
}
/**
* Get the emails.
*/
public function emails(): HasMany
{
return $this->hasMany(EmailProxy::modelClass());
}
/**
* The quotes that belong to the lead.
*/
public function quotes(): BelongsToMany
{
return $this->belongsToMany(QuoteProxy::modelClass(), 'lead_quotes');
}
/**
* The tags that belong to the lead.
*/
public function tags(): BelongsToMany
{
return $this->belongsToMany(TagProxy::modelClass(), 'lead_tags');
}
/**
* Returns the rotten days
*/
public function getRottenDaysAttribute()
{
if (! $this->stage) {
return 0;
}
if (in_array($this->stage->code, ['won', 'lost'])) {
return 0;
}
if (! $this->created_at) {
return 0;
}
$rottenDate = $this->created_at->addDays($this->pipeline->rotten_days);
return $rottenDate->diffInDays(Carbon::now(), false);
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Lead\Models;
use Konekt\Concord\Proxies\ModelProxy;
class LeadProxy extends ModelProxy {}

View File

@@ -0,0 +1,38 @@
<?php
namespace Webkul\Lead\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Lead\Contracts\Pipeline as PipelineContract;
class Pipeline extends Model implements PipelineContract
{
protected $table = 'lead_pipelines';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'rotten_days',
'is_default',
];
/**
* Get the leads.
*/
public function leads()
{
return $this->hasMany(LeadProxy::modelClass(), 'lead_pipeline_id');
}
/**
* Get the stages that owns the pipeline.
*/
public function stages()
{
return $this->hasMany(StageProxy::modelClass(), 'lead_pipeline_id')->orderBy('sort_order', 'ASC');
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Lead\Models;
use Konekt\Concord\Proxies\ModelProxy;
class PipelineProxy extends ModelProxy {}

View File

@@ -0,0 +1,61 @@
<?php
namespace Webkul\Lead\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Lead\Contracts\Product as ProductContract;
use Webkul\Product\Models\ProductProxy;
class Product extends Model implements ProductContract
{
protected $table = 'lead_products';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'quantity',
'price',
'amount',
'product_id',
'lead_id',
];
/**
* Get the product owns the lead product.
*/
public function product()
{
return $this->belongsTo(ProductProxy::modelClass());
}
/**
* Get the lead that owns the lead product.
*/
public function lead()
{
return $this->belongsTo(LeadProxy::modelClass());
}
/**
* Get the customer full name.
*/
public function getNameAttribute()
{
return $this->product->name;
}
/**
* @return array
*/
public function toArray()
{
$array = parent::toArray();
$array['name'] = $this->name;
return $array;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Lead\Models;
use Konekt\Concord\Proxies\ModelProxy;
class ProductProxy extends ModelProxy {}

View File

@@ -0,0 +1,28 @@
<?php
namespace Webkul\Lead\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Lead\Contracts\Source as SourceContract;
class Source extends Model implements SourceContract
{
protected $table = 'lead_sources';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
];
/**
* Get the leads.
*/
public function leads()
{
return $this->hasMany(LeadProxy::modelClass(), 'lead_source_id', 'id');
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Lead\Models;
use Konekt\Concord\Proxies\ModelProxy;
class SourceProxy extends ModelProxy {}

View File

@@ -0,0 +1,42 @@
<?php
namespace Webkul\Lead\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Lead\Contracts\Stage as StageContract;
class Stage extends Model implements StageContract
{
public $timestamps = false;
protected $table = 'lead_pipeline_stages';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'code',
'name',
'probability',
'sort_order',
'lead_pipeline_id',
];
/**
* Get the pipeline that owns the pipeline stage.
*/
public function pipeline()
{
return $this->belongsTo(PipelineProxy::modelClass(), 'lead_pipeline_id');
}
/**
* Get the leads.
*/
public function leads()
{
return $this->hasMany(LeadProxy::modelClass(), 'lead_pipeline_stage_id');
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Lead\Models;
use Konekt\Concord\Proxies\ModelProxy;
class StageProxy extends ModelProxy {}

View File

@@ -0,0 +1,28 @@
<?php
namespace Webkul\Lead\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Lead\Contracts\Type as TypeContract;
class Type extends Model implements TypeContract
{
protected $table = 'lead_types';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
];
/**
* Get the leads.
*/
public function leads()
{
return $this->hasMany(LeadProxy::modelClass());
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Lead\Models;
use Konekt\Concord\Proxies\ModelProxy;
class TypeProxy extends ModelProxy {}

View File

@@ -0,0 +1,26 @@
<?php
namespace Webkul\Lead\Providers;
use Illuminate\Routing\Router;
use Illuminate\Support\ServiceProvider;
class LeadServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot(Router $router)
{
$this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
}
/**
* Register services.
*
* @return void
*/
public function register() {}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Webkul\Lead\Providers;
use Webkul\Core\Providers\BaseModuleServiceProvider;
class ModuleServiceProvider extends BaseModuleServiceProvider
{
protected $models = [
\Webkul\Lead\Models\Lead::class,
\Webkul\Lead\Models\Pipeline::class,
\Webkul\Lead\Models\Product::class,
\Webkul\Lead\Models\Source::class,
\Webkul\Lead\Models\Stage::class,
\Webkul\Lead\Models\Type::class,
];
}

View File

@@ -0,0 +1,254 @@
<?php
namespace Webkul\Lead\Repositories;
use Carbon\Carbon;
use Illuminate\Container\Container;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Webkul\Attribute\Repositories\AttributeRepository;
use Webkul\Attribute\Repositories\AttributeValueRepository;
use Webkul\Contact\Repositories\PersonRepository;
use Webkul\Core\Eloquent\Repository;
use Webkul\Lead\Contracts\Lead;
class LeadRepository extends Repository
{
/**
* Searchable fields.
*/
protected $fieldSearchable = [
'title',
'lead_value',
'status',
'user_id',
'user.name',
'person_id',
'person.name',
'lead_source_id',
'lead_type_id',
'lead_pipeline_id',
'lead_pipeline_stage_id',
'created_at',
'closed_at',
'expected_close_date',
];
/**
* Create a new repository instance.
*
* @return void
*/
public function __construct(
protected StageRepository $stageRepository,
protected PersonRepository $personRepository,
protected ProductRepository $productRepository,
protected AttributeRepository $attributeRepository,
protected AttributeValueRepository $attributeValueRepository,
Container $container
) {
parent::__construct($container);
}
/**
* Specify model class name.
*
* @return mixed
*/
public function model()
{
return Lead::class;
}
/**
* Get leads query.
*
* @param int $pipelineId
* @param int $pipelineStageId
* @param string $term
* @param string $createdAtRange
* @return mixed
*/
public function getLeadsQuery($pipelineId, $pipelineStageId, $term, $createdAtRange)
{
return $this->with([
'attribute_values',
'pipeline',
'stage',
])->scopeQuery(function ($query) use ($pipelineId, $pipelineStageId, $term, $createdAtRange) {
return $query->select(
'leads.id as id',
'leads.created_at as created_at',
'title',
'lead_value',
'persons.name as person_name',
'leads.person_id as person_id',
'lead_pipelines.id as lead_pipeline_id',
'lead_pipeline_stages.name as status',
'lead_pipeline_stages.id as lead_pipeline_stage_id'
)
->addSelect(DB::raw('DATEDIFF('.DB::getTablePrefix().'leads.created_at + INTERVAL lead_pipelines.rotten_days DAY, now()) as rotten_days'))
->leftJoin('persons', 'leads.person_id', '=', 'persons.id')
->leftJoin('lead_pipelines', 'leads.lead_pipeline_id', '=', 'lead_pipelines.id')
->leftJoin('lead_pipeline_stages', 'leads.lead_pipeline_stage_id', '=', 'lead_pipeline_stages.id')
->where('title', 'like', "%$term%")
->where('leads.lead_pipeline_id', $pipelineId)
->where('leads.lead_pipeline_stage_id', $pipelineStageId)
->when($createdAtRange, function ($query) use ($createdAtRange) {
return $query->whereBetween('leads.created_at', $createdAtRange);
})
->where(function ($query) {
if ($userIds = bouncer()->getAuthorizedUserIds()) {
$query->whereIn('leads.user_id', $userIds);
}
});
});
}
/**
* Create.
*
* @return \Webkul\Lead\Contracts\Lead
*/
public function create(array $data)
{
/**
* If a person is provided, create or update the person and set the `person_id`.
*/
if (isset($data['person'])) {
if (! empty($data['person']['id'])) {
$person = $this->personRepository->findOrFail($data['person']['id']);
} else {
$person = $this->personRepository->create(array_merge($data['person'], [
'entity_type' => 'persons',
]));
}
$data['person_id'] = $person->id;
}
if (empty($data['expected_close_date'])) {
$data['expected_close_date'] = null;
}
$lead = parent::create(array_merge([
'lead_pipeline_id' => 1,
'lead_pipeline_stage_id' => 1,
], $data));
$this->attributeValueRepository->save(array_merge($data, [
'entity_id' => $lead->id,
]));
if (isset($data['products'])) {
foreach ($data['products'] as $product) {
$this->productRepository->create(array_merge($product, [
'lead_id' => $lead->id,
'amount' => $product['price'] * $product['quantity'],
]));
}
}
return $lead;
}
/**
* Update.
*
* @param int $id
* @param array|\Illuminate\Database\Eloquent\Collection $attributes
* @return \Webkul\Lead\Contracts\Lead
*/
public function update(array $data, $id, $attributes = [])
{
/**
* If a person is provided, create or update the person and set the `person_id`.
* Be cautious, as a lead can be updated without providing person data.
* For example, in the lead Kanban section, when switching stages, only the stage will be updated.
*/
if (isset($data['person'])) {
if (! empty($data['person']['id'])) {
$person = $this->personRepository->findOrFail($data['person']['id']);
} else {
$person = $this->personRepository->create(array_merge($data['person'], [
'entity_type' => 'persons',
]));
}
$data['person_id'] = $person->id;
}
if (isset($data['lead_pipeline_stage_id'])) {
$stage = $this->stageRepository->find($data['lead_pipeline_stage_id']);
if (in_array($stage->code, ['won', 'lost'])) {
$data['closed_at'] = $data['closed_at'] ?? Carbon::now();
} else {
$data['closed_at'] = null;
}
}
if (empty($data['expected_close_date'])) {
$data['expected_close_date'] = null;
}
$lead = parent::update($data, $id);
/**
* If attributes are provided, only save the provided attributes and return.
* A collection of attributes may also be provided, which will be treated as valid,
* regardless of whether it is empty or not.
*/
if (! empty($attributes)) {
/**
* If attributes are provided as an array, then fetch the attributes from the database;
* otherwise, use the provided collection of attributes.
*/
if (is_array($attributes)) {
$conditions = ['entity_type' => $data['entity_type']];
if (isset($data['quick_add'])) {
$conditions['quick_add'] = 1;
}
$attributes = $this->attributeRepository->where($conditions)
->whereIn('code', $attributes)
->get();
}
$this->attributeValueRepository->save(array_merge($data, [
'entity_id' => $lead->id,
]), $attributes);
return $lead;
}
$this->attributeValueRepository->save(array_merge($data, [
'entity_id' => $lead->id,
]));
$previousProductIds = $lead->products()->pluck('id');
if (isset($data['products'])) {
foreach ($data['products'] as $productId => $productInputs) {
if (Str::contains($productId, 'product_')) {
$this->productRepository->create(array_merge([
'lead_id' => $lead->id,
], $productInputs));
} else {
if (is_numeric($index = $previousProductIds->search($productId))) {
$previousProductIds->forget($index);
}
$this->productRepository->update($productInputs, $productId);
}
}
}
foreach ($previousProductIds as $productId) {
$this->productRepository->delete($productId);
}
return $lead;
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Webkul\Lead\Repositories;
use Illuminate\Container\Container;
use Illuminate\Support\Str;
use Webkul\Core\Eloquent\Repository;
class PipelineRepository extends Repository
{
/**
* Create a new repository instance.
*
* @return void
*/
public function __construct(
protected StageRepository $stageRepository,
Container $container
) {
parent::__construct($container);
}
/**
* Specify model class name.
*
* @return mixed
*/
public function model()
{
return 'Webkul\Lead\Contracts\Pipeline';
}
/**
* Create pipeline.
*
* @return \Webkul\Lead\Contracts\Pipeline
*/
public function create(array $data)
{
if ($data['is_default'] ?? false) {
$this->model->query()->update(['is_default' => 0]);
}
$pipeline = $this->model->create($data);
foreach ($data['stages'] as $stageData) {
$this->stageRepository->create(array_merge([
'lead_pipeline_id' => $pipeline->id,
], $stageData));
}
return $pipeline;
}
/**
* Update pipeline.
*
* @param int $id
* @param string $attribute
* @return \Webkul\Lead\Contracts\Pipeline
*/
public function update(array $data, $id, $attribute = 'id')
{
$pipeline = $this->find($id);
if ($data['is_default'] ?? false) {
$this->model->query()->where('id', '<>', $id)->update(['is_default' => 0]);
}
$pipeline->update($data);
$previousStageIds = $pipeline->stages()->pluck('id');
foreach ($data['stages'] as $stageId => $stageData) {
if (Str::contains($stageId, 'stage_')) {
$this->stageRepository->create(array_merge([
'lead_pipeline_id' => $pipeline->id,
], $stageData));
} else {
if (is_numeric($index = $previousStageIds->search($stageId))) {
$previousStageIds->forget($index);
}
$this->stageRepository->update($stageData, $stageId);
}
}
foreach ($previousStageIds as $stageId) {
$pipeline->leads()->where('lead_pipeline_stage_id', $stageId)->update([
'lead_pipeline_stage_id' => $pipeline->stages()->first()->id,
]);
$this->stageRepository->delete($stageId);
}
return $pipeline;
}
/**
* Return the default pipeline.
*
* @return \Webkul\Lead\Contracts\Pipeline
*/
public function getDefaultPipeline()
{
$pipeline = $this->findOneByField('is_default', 1);
if (! $pipeline) {
$pipeline = $this->first();
}
return $pipeline;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Webkul\Lead\Repositories;
use Webkul\Core\Eloquent\Repository;
class ProductRepository extends Repository
{
/**
* Specify Model class name
*
* @return mixed
*/
public function model()
{
return 'Webkul\Lead\Contracts\Product';
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Webkul\Lead\Repositories;
use Webkul\Core\Eloquent\Repository;
class SourceRepository extends Repository
{
/**
* Specify Model class name
*
* @return mixed
*/
public function model()
{
return 'Webkul\Lead\Contracts\Source';
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Webkul\Lead\Repositories;
use Webkul\Core\Eloquent\Repository;
class StageRepository extends Repository
{
/**
* Specify Model class name
*
* @return mixed
*/
public function model()
{
return 'Webkul\Lead\Contracts\Stage';
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Webkul\Lead\Repositories;
use Webkul\Core\Eloquent\Repository;
class TypeRepository extends Repository
{
/**
* Specify Model class name
*
* @return mixed
*/
public function model()
{
return 'Webkul\Lead\Contracts\Type';
}
}

View File

@@ -0,0 +1,238 @@
<?php
namespace Webkul\Lead\Services;
use Exception;
use Smalot\PdfParser\Parser;
class MagicAIService
{
/**
* API endpoint for OpenRouter AI service.
*/
const OPEN_ROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
/**
* Maximum token limit for AI prompt.
*/
const MAX_TOKENS = 100000;
/**
* Flag to prevent re-entrant calls.
*/
private static $isExtracting = false;
/**
* Extract data from base64-encoded file.
*/
public static function extractDataFromFile($base64File)
{
if (self::$isExtracting) {
throw new Exception(trans('admin::app.leads.file.recursive-call'));
}
self::$isExtracting = true;
try {
$text = self::extractTextFromBase64File($base64File);
if (empty($text)) {
throw new Exception(trans('admin::app.leads.file.failed-extract'));
}
return self::processPromptWithAI($text);
} catch (Exception $e) {
return ['error' => $e->getMessage()];
} finally {
self::$isExtracting = false;
}
}
/**
* Extract text from base64-encoded file.
*/
private static function extractTextFromBase64File($base64File)
{
if (
empty($base64File)
|| ! base64_decode($base64File, true)
) {
throw new Exception(trans('admin::app.leads.file.invalid-base64'));
}
$tempFile = tempnam(sys_get_temp_dir(), 'file_');
file_put_contents($tempFile, self::handleBase64($base64File, 'decode'));
$mimeType = mime_content_type($tempFile);
$data = [];
try {
if ($mimeType === 'application/pdf') {
$pdfParser = (new Parser)->parseFile($tempFile);
$data['text'] = $pdfParser->getText();
$data['images'][] = '';
$images = $pdfParser->getObjectsByType('XObject', 'Image');
foreach ($images as $image) {
$data['images'][] = self::handleBase64($image->getContent());
}
} else {
$data['text'] = '';
$data['images'][] = self::handleBase64(self::handleBase64($base64File, 'decode'));
}
if (empty($data)) {
throw new Exception(trans('admin::app.leads.file.data-extraction-failed'));
}
return $data;
} catch (Exception $e) {
throw new Exception($e->getMessage());
} finally {
@unlink($tempFile);
}
}
/**
* Send extracted data to AI for processing.
*/
private static function processPromptWithAI($prompt)
{
$model = core()->getConfigData('general.magic_ai.settings.other_model') ?: core()->getConfigData('general.magic_ai.settings.model');
$apiKey = core()->getConfigData('general.magic_ai.settings.api_key');
if (! $apiKey || ! $model) {
return ['error' => trans('admin::app.leads.file.missing-api-key')];
}
$promptText = self::truncatePrompt($prompt['text'] ?? '');
$promptImages = $prompt['images'] ?? [];
$prompt = array_filter(array_merge([$promptText], $promptImages), function ($value) {
return ! empty($value);
});
return self::ask(array_values($prompt), $model, $apiKey);
}
/**
* Truncate prompt to fit within token limit.
*/
private static function truncatePrompt($prompt)
{
if (strlen($prompt) > self::MAX_TOKENS) {
$start = mb_substr($prompt, 0, self::MAX_TOKENS * 0.4);
$end = mb_substr($prompt, -self::MAX_TOKENS * 0.4);
return $start."\n...\n".$end;
}
return $prompt;
}
/**
* Send prompt request to AI for processing.
*/
private static function ask($prompt, $model, $apiKey)
{
try {
$response = \Http::withHeaders([
'Content-Type' => 'application/json',
'Authorization' => 'Bearer '.$apiKey,
])->post(self::OPEN_ROUTER_URL, [
'model' => $model,
'messages' => [
[
'role' => 'system',
'content' => self::getSystemPrompt(),
], [
'role' => 'user',
'content' => [
[
'type' => 'text',
'text' => $prompt[0],
],
],
],
],
]);
if ($response->failed()) {
throw new Exception($response->body());
}
$data = $response->json();
if (isset($data['error'])) {
throw new Exception($data['error']['message']);
}
return $data;
} catch (Exception $e) {
return ['error' => trans('admin::app.leads.file.insufficient-info')];
}
}
/**
* Define system prompt for AI processing.
*
* @return string System prompt for AI model.
*/
private static function getSystemPrompt()
{
return <<<'PROMPT'
You are an AI assistant specialized in extracting structured data from text.
The user will provide text extracted from a file (in Base64 or plain text).
Your task is to accurately extract the following fields. If the value is not available, use the default values provided:
### **Output Format:**
```json
{
"status": 1,
"title": "Untitled Lead",
"lead_value": 0,
"person": {
"name": "Unknown",
"emails": {
"value": null,
"label": null
},
"contact_numbers": {
"value": null,
"label": null
}
}
}
```
### **Fields to Extract:**
- **Title:** Title of the lead. Default: "Untitled Lead"
- **Lead Value:** Value of the lead. Default: 0
- **Person Name:** Name of the person. Default: "Unknown"
- **Person Email:** Email of the person. Default: null
- **Person Email Label:** Label for the email. Default: null
- **Person Contact Number:** Contact number of the person. Default: null
- **Person Contact Number Label:** Label for the contact number. Default: null
PROMPT;
}
/**
* process for encoding and decoding base64 data.
*/
private static function handleBase64($base64, string $type = 'encode')
{
if ($type === 'encode') {
return base64_encode($base64);
}
return base64_decode($base64);
}
}