diff --git a/app/app/Models/SalesAttachmentRequest.php b/app/app/Models/SalesAttachmentRequest.php new file mode 100644 index 00000000..68600f0d --- /dev/null +++ b/app/app/Models/SalesAttachmentRequest.php @@ -0,0 +1,72 @@ + 'datetime', + 'created_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function salesUser(): BelongsTo + { + return $this->belongsTo(SalesUser::class, 'sales_user_id'); + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_id'); + } + + /** @return BelongsTo */ + public function decider(): BelongsTo + { + return $this->belongsTo(SalesUser::class, 'decided_by'); + } +} diff --git a/app/app/Models/SalesClientAssignment.php b/app/app/Models/SalesClientAssignment.php new file mode 100644 index 00000000..6fb3125b --- /dev/null +++ b/app/app/Models/SalesClientAssignment.php @@ -0,0 +1,72 @@ + $tariff_params + * @property \Carbon\Carbon $assigned_at + * @property \Carbon\Carbon $created_at + */ +class SalesClientAssignment extends Model +{ + public $timestamps = false; + + protected $fillable = [ + 'sales_user_id', + 'tenant_id', + 'tariff_id', + 'tariff_kind', + 'tariff_params', + 'assigned_at', + ]; + + protected function casts(): array + { + return [ + 'tariff_params' => 'array', + 'assigned_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function salesUser(): BelongsTo + { + return $this->belongsTo(SalesUser::class, 'sales_user_id'); + } + + /** @return BelongsTo */ + public function tariff(): BelongsTo + { + return $this->belongsTo(SalesTariff::class, 'tariff_id'); + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_id'); + } +} diff --git a/app/app/Models/SalesPayout.php b/app/app/Models/SalesPayout.php new file mode 100644 index 00000000..a28fa9c0 --- /dev/null +++ b/app/app/Models/SalesPayout.php @@ -0,0 +1,62 @@ + 'decimal:2', + 'paid_on' => 'date', + 'created_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function salesUser(): BelongsTo + { + return $this->belongsTo(SalesUser::class, 'sales_user_id'); + } + + /** @return BelongsTo */ + public function creator(): BelongsTo + { + return $this->belongsTo(SalesUser::class, 'created_by'); + } +} diff --git a/app/app/Models/SalesTariff.php b/app/app/Models/SalesTariff.php new file mode 100644 index 00000000..046a91f3 --- /dev/null +++ b/app/app/Models/SalesTariff.php @@ -0,0 +1,57 @@ + $params + * @property bool $is_active + */ +class SalesTariff extends Model +{ + protected $fillable = [ + 'name', + 'kind', + 'params', + 'is_active', + ]; + + protected function casts(): array + { + return [ + 'params' => 'array', + 'is_active' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** @return HasMany */ + public function salesUsers(): HasMany + { + return $this->hasMany(SalesUser::class, 'current_tariff_id'); + } + + /** @return HasMany */ + public function assignments(): HasMany + { + return $this->hasMany(SalesClientAssignment::class, 'tariff_id'); + } +} diff --git a/app/app/Models/SalesUser.php b/app/app/Models/SalesUser.php new file mode 100644 index 00000000..a26348a7 --- /dev/null +++ b/app/app/Models/SalesUser.php @@ -0,0 +1,89 @@ + 'boolean', + 'base_salary_rub' => 'decimal:2', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * Менеджер является руководителем (head) отдела продаж. + */ + public function isHead(): bool + { + return $this->role === 'head'; + } + + /** @return BelongsTo */ + public function currentTariff(): BelongsTo + { + return $this->belongsTo(SalesTariff::class, 'current_tariff_id'); + } + + /** @return HasMany */ + public function assignments(): HasMany + { + return $this->hasMany(SalesClientAssignment::class, 'sales_user_id'); + } + + /** @return HasMany */ + public function payouts(): HasMany + { + return $this->hasMany(SalesPayout::class, 'sales_user_id'); + } +} diff --git a/app/tests/Feature/Sales/SalesModelsTest.php b/app/tests/Feature/Sales/SalesModelsTest.php new file mode 100644 index 00000000..a56f84e4 --- /dev/null +++ b/app/tests/Feature/Sales/SalesModelsTest.php @@ -0,0 +1,179 @@ + 'Test tariff ' . uniqid(), + 'kind' => 'topup_step', + 'params' => ['step' => 1000, 'bonus' => 100], + 'is_active' => true, + ], $attrs)); +} + +function makeSalesUser(array $attrs = []): SalesUser +{ + return SalesUser::create(array_merge([ + 'name' => 'Manager ' . uniqid(), + 'email' => 'mgr' . uniqid() . '@test.local', + 'password' => bcrypt('secret'), + 'role' => 'manager', + ], $attrs)); +} + +// ── 1. SalesTariff ───────────────────────────────────────────────────────── + +test('SalesTariff можно создать и params отдаётся массивом', function () { + $tariff = makeSalesTariff([ + 'params' => ['step' => 5000, 'bonus' => 500], + ]); + + expect($tariff->id)->toBeInt() + ->and($tariff->name)->toStartWith('Test tariff') + ->and($tariff->kind)->toBe('topup_step') + ->and($tariff->params)->toBeArray() + ->and($tariff->params['step'])->toBe(5000) + ->and($tariff->is_active)->toBeTrue(); +}); + +// ── 2. SalesUser ─────────────────────────────────────────────────────────── + +test('SalesUser создаётся с role=manager и current_tariff_id', function () { + $tariff = makeSalesTariff(); + $user = makeSalesUser([ + 'role' => 'manager', + 'current_tariff_id' => $tariff->id, + 'base_salary_rub' => '50000.00', + ]); + + expect($user->id)->toBeInt() + ->and($user->role)->toBe('manager') + ->and($user->current_tariff_id)->toBe($tariff->id) + ->and($user->fresh()->is_active)->toBeTrue(); // DB default=TRUE, fresh() синхронизирует +}); + +test('SalesUser::isHead() верен для role=head и false для role=manager', function () { + $head = makeSalesUser(['role' => 'head']); + $manager = makeSalesUser(['role' => 'manager']); + + expect($head->isHead())->toBeTrue() + ->and($manager->isHead())->toBeFalse(); +}); + +test('SalesUser is_active приходит булевым', function () { + $user = makeSalesUser(['is_active' => true]); + + expect($user->is_active)->toBeBool()->toBeTrue(); +}); + +// ── 3. SalesClientAssignment ─────────────────────────────────────────────── + +test('SalesClientAssignment связывает пользователя с тенантом и tariff_params — массив', function () { + $tariff = makeSalesTariff(['kind' => 'percent_oborot', 'params' => ['percent' => 5]]); + $user = makeSalesUser(['current_tariff_id' => $tariff->id]); + $tenant = Tenant::factory()->create(); // реальный тенант (FK tenants.id) + + $assignment = SalesClientAssignment::create([ + 'sales_user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'tariff_id' => $tariff->id, + 'tariff_kind' => $tariff->kind, + 'tariff_params' => ['percent' => 5], + 'assigned_at' => now(), + ]); + + expect($assignment->id)->toBeInt() + ->and($assignment->tariff_params)->toBeArray() + ->and($assignment->tariff_params['percent'])->toBe(5) + ->and($assignment->assigned_at)->toBeInstanceOf(\Carbon\Carbon::class); +}); + +test('SalesUser->assignments() возвращает HasMany-коллекцию', function () { + $user = makeSalesUser(); + $tenant = Tenant::factory()->create(); + + SalesClientAssignment::create([ + 'sales_user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'tariff_params' => [], + 'assigned_at' => now(), + ]); + + $relation = $user->assignments(); + expect($relation)->toBeInstanceOf(HasMany::class); + + $collection = $user->assignments; + expect($collection)->toHaveCount(1) + ->and($collection->first())->toBeInstanceOf(SalesClientAssignment::class); +}); + +// ── 4. SalesAttachmentRequest ────────────────────────────────────────────── + +test('SalesAttachmentRequest создаётся со статусом pending', function () { + $user = makeSalesUser(); + + $req = SalesAttachmentRequest::create([ + 'sales_user_id' => $user->id, + 'login_input' => 'client@example.com', + 'status' => 'pending', + ]); + + expect($req->id)->toBeInt() + ->and($req->status)->toBe('pending') + ->and($req->decided_at)->toBeNull(); +}); + +// ── 5. SalesPayout ───────────────────────────────────────────────────────── + +test('SalesPayout создаётся, amount_rub — decimal, paid_on — date', function () { + $user = makeSalesUser(); + + $payout = SalesPayout::create([ + 'sales_user_id' => $user->id, + 'amount_rub' => '12500.50', + 'paid_on' => today(), + 'created_by' => $user->id, + ]); + + expect($payout->id)->toBeInt() + ->and((float) $payout->amount_rub)->toBe(12500.50) + ->and($payout->paid_on)->toBeInstanceOf(\Carbon\Carbon::class); +}); + +test('SalesPayout append-only: UPDATE бросает исключение', function () { + $user = makeSalesUser(); + + $payout = SalesPayout::create([ + 'sales_user_id' => $user->id, + 'amount_rub' => '100.00', + 'paid_on' => today(), + 'created_by' => $user->id, + ]); + + expect(fn () => $payout->update(['amount_rub' => '999.00'])) + ->toThrow(\Illuminate\Database\QueryException::class); +});