Commit 4cb2acc4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Remove 23 hardcoded business decisions: all financial/operational behaviors...

Remove 23 hardcoded business decisions: all financial/operational behaviors now configurable per-academy

Every penalty, compensation, auto-invoice, and automation is now guarded by a
SettingsService toggle (default OFF for financial, ON for operational). Added
88 system settings across 8 groups with Arabic labels, conditional UI visibility
for dependent fields, and a new "رواتب وتعويضات" admin tab.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 101a0f35
......@@ -17,9 +17,6 @@
class AttendanceMarkingService
{
private const DEFAULT_PARTICIPANT_GRACE_MINUTES = 15;
private const DEFAULT_TRAINER_GRACE_MINUTES = 10;
public function __construct(
private SettingsService $settings,
) {}
......@@ -111,9 +108,10 @@ public function markCheckOut(AttendanceRecord $record, User $marker, ?Carbon $ch
// If left early (before session end), mark accordingly
$session = $record->session;
$sessionEnd = Carbon::parse($session->session_date->format('Y-m-d') . ' ' . $session->end_time);
$earlyLeaveMinutes = (int) $this->settings->get('early_leave_threshold_minutes', 10);
$status = $record->status;
if ($checkOutTime->lt($sessionEnd->subMinutes(10))) {
if ($checkOutTime->lt($sessionEnd->subMinutes($earlyLeaveMinutes))) {
$status = AttendanceStatus::LeftEarly;
}
......@@ -184,10 +182,10 @@ private function getGraceMinutes(AttendanceRecord $record): int
{
// Trainers (users) get less grace
if ($record->subject_type === \App\Models\User::class) {
return self::DEFAULT_TRAINER_GRACE_MINUTES;
return (int) $this->settings->get('trainer_grace_minutes', 10);
}
return self::DEFAULT_PARTICIPANT_GRACE_MINUTES;
return (int) $this->settings->get('participant_grace_minutes', 15);
}
private function enforceMedicalCertificate(AttendanceRecord $record): void
......
......@@ -5,6 +5,7 @@
use App\Domain\Attendance\Events\AttendanceMarked;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Services\CompensationCalculatorService;
use App\Domain\Shared\Services\SettingsService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
......@@ -12,10 +13,15 @@ class GenerateTrainerCompensation implements ShouldQueue
{
public function __construct(
private CompensationCalculatorService $calculator,
private SettingsService $settings,
) {}
public function handle(AttendanceMarked $event): void
{
if (!(bool) $this->settings->get('auto_trainer_compensation_enabled', false)) {
return;
}
try {
$record = $event->record;
......
......@@ -5,6 +5,7 @@
use App\Domain\Training\Events\SessionCancelled;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Services\CompensationCalculatorService;
use App\Domain\Shared\Services\SettingsService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
......@@ -12,10 +13,15 @@ class HandleSessionCancelled implements ShouldQueue
{
public function __construct(
private CompensationCalculatorService $calculator,
private SettingsService $settings,
) {}
public function handle(SessionCancelled $event): void
{
if (!(bool) $this->settings->get('cancelled_session_pay_enabled', false)) {
return;
}
try {
$session = $event->session;
......
......@@ -28,6 +28,10 @@ public function calculateForSession(
string $attendanceStatus, // 'present', 'late', 'substitute'
User $actor,
): ?TrainerCompensation {
if (!(bool) $this->settings->get('auto_trainer_compensation_enabled', false)) {
return null;
}
// Don't double-create
$existing = TrainerCompensation::where('trainer_id', $trainer->id)
->where('training_session_id', $trainingSessionId)
......@@ -75,7 +79,11 @@ public function calculatePenalty(
int $attendanceRecordId,
User $actor,
): ?TrainerCompensation {
$penaltyAmount = (int) $this->settings->get('absence_penalty_amount', 10000);
if (!(bool) $this->settings->get('trainer_absence_penalty_enabled', false)) {
return null;
}
$penaltyAmount = (int) $this->settings->get('absence_penalty_amount', 0);
if ($penaltyAmount <= 0) {
return null;
}
......@@ -117,7 +125,11 @@ public function calculateLatePenalty(
int $attendanceRecordId,
User $actor,
): ?TrainerCompensation {
$latePenalty = (int) $this->settings->get('late_penalty_amount', 5000);
if (!(bool) $this->settings->get('trainer_late_penalty_enabled', false)) {
return null;
}
$latePenalty = (int) $this->settings->get('late_penalty_amount', 0);
if ($latePenalty <= 0) {
return null;
}
......@@ -159,7 +171,11 @@ public function calculateCancelledSessionPay(
int $trainingSessionId,
User $actor,
): ?TrainerCompensation {
$payPercent = (int) $this->settings->get('cancelled_session_pay_percent', 50);
if (!(bool) $this->settings->get('cancelled_session_pay_enabled', false)) {
return null;
}
$payPercent = (int) $this->settings->get('cancelled_session_pay_percent', 0);
if ($payPercent <= 0) {
return null;
}
......@@ -208,6 +224,10 @@ public function calculatePlayerPay(
return null;
}
if (!(bool) $this->settings->get('per_player_compensation_enabled', false)) {
return null;
}
// Resolve trainer's user_id (head_trainer_id on groups references users.id)
$userId = $trainer->employee?->user_id;
if (!$userId) {
......@@ -270,6 +290,10 @@ public function calculateRevenueShare(
return null;
}
if (!(bool) $this->settings->get('revenue_share_enabled', false)) {
return null;
}
// Sum payments received for groups where trainer is head_trainer
$userId = $trainer->employee?->user_id;
if (!$userId) {
......
......@@ -70,6 +70,10 @@ public function getOrCreateCurrentPeriod(int $academyId, User $actor): PayrollPe
public function calculatePeriod(PayrollPeriod $period, User $actor): PayrollPeriod
{
return DB::transaction(function () use ($period, $actor) {
if (!(bool) $this->settings->get('payroll_enabled', false)) {
throw new DomainException('نظام الرواتب غير مفعل — يرجى تفعيله من إعدادات النظام');
}
$period->update(['status' => PayrollPeriodStatus::Calculating]);
$academyId = $period->academy_id;
......@@ -158,8 +162,9 @@ public function generatePayslip(
// Advance deduction
$advanceDeduction = $this->calculateAdvanceDeduction($trainer);
// Insurance — Egypt: 11% employee share applied to base salary only
$insurancePercent = (float) $this->settings->get('social_insurance_percent', 11);
// Insurance — Egypt: employee share applied to base salary only
$insuranceEnabled = (bool) $this->settings->get('social_insurance_enabled', false);
$insurancePercent = $insuranceEnabled ? (float) $this->settings->get('social_insurance_percent', 0) : 0;
$insuranceAmount = 0;
if ($baseSalary > 0 && $insurancePercent > 0) {
$insuranceAmount = (int) round($baseSalary * $insurancePercent / 100);
......
......@@ -7,6 +7,7 @@
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Shared\Services\SettingsService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
......@@ -14,10 +15,15 @@ class CreateReceivingMovements implements ShouldQueue
{
public function __construct(
private InventoryService $inventoryService,
private SettingsService $settings,
) {}
public function handle(PurchaseOrderReceived $event): void
{
if (!(bool) $this->settings->get('auto_receive_inventory_enabled', true)) {
return;
}
try {
$purchaseOrder = $event->purchaseOrder;
$warehouse = $purchaseOrder->warehouse;
......
......@@ -12,6 +12,7 @@
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Shared\Services\PlatformFeeService;
use App\Domain\Shared\Services\SettingsService;
use App\Domain\Participant\Models\Participant;
use App\Domain\POS\Enums\POSItemType;
use App\Domain\POS\Enums\POSPaymentMethod;
......@@ -33,6 +34,7 @@ public function __construct(
private CashSessionService $cashSessionService,
private PlatformFeeService $platformFeeService,
private InventoryService $inventoryService,
private SettingsService $settings,
) {}
/**
......@@ -315,6 +317,10 @@ private function createEnrollmentIfNeeded(Participant $participant, int $program
*/
private function deductInventoryIfTracked(int $productId, int $quantity, int $branchId, POSTransaction $posTransaction, User $cashier): void
{
if (!(bool) $this->settings->get('auto_inventory_deduction_on_sale', true)) {
return;
}
$product = Product::find($productId);
if (!$product || !$product->track_inventory) {
return;
......
......@@ -5,6 +5,7 @@
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Scheduling\Events\AssignmentCreated;
use App\Domain\Shared\Services\SettingsService;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
......@@ -13,8 +14,16 @@
class GenerateTrainerAttendance implements ShouldQueue
{
public function __construct(
private SettingsService $settings,
) {}
public function handle(AssignmentCreated $event): void
{
if (!(bool) $this->settings->get('trainer_attendance_tracking_enabled', true)) {
return;
}
try {
$assignment = $event->assignment;
......
......@@ -16,6 +16,7 @@ class SystemSetting extends Model
'key',
'value',
'type',
'label_ar',
'description_ar',
'is_public',
];
......
......@@ -6,6 +6,7 @@
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\SettingsService;
use App\Domain\Training\Events\EnrollmentCancelled;
use App\Domain\Training\Events\EnrollmentCompleted;
use App\Domain\Training\Events\EnrollmentCreated;
......@@ -23,6 +24,7 @@ public function __construct(
private readonly TrainingGroupService $groupService,
private readonly PricingService $pricingService,
private readonly InvoiceService $invoiceService,
private readonly SettingsService $settings,
) {}
public function enroll(Participant $participant, TrainingGroup $group, User $actor, ?array $options = []): Enrollment
......@@ -88,7 +90,7 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
$this->groupService->incrementCount($group);
// Auto-create invoice if program has a price (skip if invoice already provided via options)
if (empty($options['invoice_id'])) {
if (empty($options['invoice_id']) && (bool) $this->settings->get('auto_invoice_on_enrollment', false)) {
$this->createEnrollmentInvoice($enrollment, $participant, $group, $actor);
}
......@@ -161,7 +163,11 @@ private function findOrCreateGroup(TrainingProgram $program, User $actor, bool $
return $leastFullGroup;
}
// Auto-create a new group
// Auto-create a new group (only if enabled)
if (!(bool) $this->settings->get('auto_create_groups_enabled', false)) {
throw new DomainException('لا توجد مجموعة متاحة — يرجى إنشاء مجموعة يدوياً');
}
return $this->autoCreateGroup($program, $actor);
}
......
......@@ -38,6 +38,7 @@ public function loadSettings(): void
$this->settingsMeta = $records->mapWithKeys(fn ($s) => [
$s->key => [
'type' => $s->type,
'label_ar' => $s->label_ar,
'description_ar' => $s->description_ar,
],
])->toArray();
......@@ -57,6 +58,17 @@ public function save(SettingsService $service): void
session()->flash('success', __('تم حفظ الإعدادات بنجاح'));
}
/**
* Settings that should only be visible when their parent boolean is enabled.
* Format: 'child_key' => 'parent_boolean_key'
*/
public static array $dependsOn = [
'absence_penalty_amount' => 'trainer_absence_penalty_enabled',
'late_penalty_amount' => 'trainer_late_penalty_enabled',
'cancelled_session_pay_percent' => 'cancelled_session_pay_enabled',
'social_insurance_percent' => 'social_insurance_enabled',
];
public function render()
{
$groups = [
......@@ -66,10 +78,13 @@ public function render()
'pricing' => 'تسعير',
'notifications' => 'إشعارات',
'enrollment' => 'تسجيلات',
'inventory' => 'مخزون',
'payroll' => 'رواتب وتعويضات',
];
return view('livewire.settings.system-settings-form', [
'groups' => $groups,
'dependsOn' => static::$dependsOn,
]);
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('system_settings', function (Blueprint $table) {
$table->string('label_ar')->nullable()->after('type');
});
}
public function down(): void
{
Schema::table('system_settings', function (Blueprint $table) {
$table->dropColumn('label_ar');
});
}
};
This diff is collapsed.
......@@ -54,16 +54,26 @@ class="px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-c
<p class="mt-4 text-sm text-gray-500">{{ __('لا توجد إعدادات في هذه المجموعة بعد') }}</p>
</div>
@else
<div class="space-y-6">
<div class="space-y-6" x-data="{ settings: @js($settings) }">
@foreach($settings as $key => $value)
@php
$meta = $settingsMeta[$key] ?? ['type' => 'string', 'description_ar' => ''];
$type = $meta['type'] ?? 'string';
$description = $meta['description_ar'] ?? '';
$parentKey = $dependsOn[$key] ?? null;
$label = !empty($meta['label_ar']) ? $meta['label_ar'] : str_replace('_', ' ', $key);
@endphp
<div>
<div
@if($parentKey)
x-show="settings['{{ $parentKey }}'] == '1' || settings['{{ $parentKey }}'] === true"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
class="ps-4 border-s-2 border-blue-200"
@endif
>
<label for="setting-{{ $key }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ str_replace('_', ' ', $key) }}
{{ $label }}
</label>
@if($type === 'boolean')
......@@ -72,6 +82,7 @@ class="px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-c
type="checkbox"
id="setting-{{ $key }}"
wire:model="settings.{{ $key }}"
@change="settings['{{ $key }}'] = $el.checked ? '1' : '0'"
value="1"
@checked(filter_var($value, FILTER_VALIDATE_BOOLEAN))
class="sr-only peer">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment