From cdeb1fc61e6b71f5b70bc7e651db9eff2844a70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Flambard?= Date: Mon, 9 Feb 2026 00:13:16 +0100 Subject: [PATCH 1/3] [REFACTO] Refacto API, removed useless models --- .../Http/Controllers/TaxeStatController.php | 158 +++++++----------- .../Http/Requests/StatsByLocationRequest.php | 50 ++++++ api/app/Http/Requests/TaxStatRequest.php | 49 ++++++ api/app/Models/Department.php | 14 +- api/app/Models/Taxe.php | 43 ++++- api/app/Models/User.php | 48 ------ api/app/Services/TaxeStatService.php | 117 +++++++++++++ api/database/factories/UserFactory.php | 44 ----- .../0001_01_01_000000_create_users_table.php | 49 ------ ...2_07_000000_add_indexes_to_taxes_table.php | 46 +++++ 10 files changed, 370 insertions(+), 248 deletions(-) create mode 100644 api/app/Http/Requests/StatsByLocationRequest.php create mode 100644 api/app/Http/Requests/TaxStatRequest.php delete mode 100644 api/app/Models/User.php create mode 100644 api/app/Services/TaxeStatService.php delete mode 100644 api/database/factories/UserFactory.php delete mode 100644 api/database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 api/database/migrations/2026_02_07_000000_add_indexes_to_taxes_table.php diff --git a/api/app/Http/Controllers/TaxeStatController.php b/api/app/Http/Controllers/TaxeStatController.php index 0491dad..2462b50 100644 --- a/api/app/Http/Controllers/TaxeStatController.php +++ b/api/app/Http/Controllers/TaxeStatController.php @@ -2,118 +2,76 @@ namespace App\Http\Controllers; -use App\Models\Taxe; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; +use App\Http\Requests\StatsByLocationRequest; +use App\Http\Requests\TaxStatRequest; +use App\Services\TaxeStatService; +use Illuminate\Http\JsonResponse; +use InvalidArgumentException; class TaxeStatController extends Controller { - public function sum(Request $request, string $field) + public function __construct( + private readonly TaxeStatService $taxeStatService + ) {} + + /** + * Calculate sum of a specific tax field. + */ + public function sum(TaxStatRequest $request, string $field): JsonResponse { - $allowedFields = ['tfpnb_amount', 'tfpb_amount', 'th_amount', 'cfe_amount']; - $column = $field . '_amount'; - - if (!in_array($column, $allowedFields)) { - return response()->json(['error' => 'Champ invalide'], 400); - } - - $query = Taxe::query(); - - if ($request->has('department_id')) { - $query->where('department_id', $request->input('department_id')); + try { + $total = $this->taxeStatService->calculateSum( + $field, + $request->input('department_id'), + $request->input('year') + ); + + return response()->json([ + 'field' => $field, + 'sum' => $total, + 'filters' => $request->validated() + ]); + } catch (InvalidArgumentException $e) { + return response()->json(['error' => $e->getMessage()], 400); } - - if ($request->has('year')) { - $query->where('year', $request->input('year')); - } - - $total = $query->sum($column); - - return response()->json([ - 'field' => $field, - 'sum' => $total, - 'filters' => $request->all() - ]); } - public function average(Request $request, string $field) + /** + * Calculate average of a specific tax field. + */ + public function average(TaxStatRequest $request, string $field): JsonResponse { - $allowedFields = ['tfpnb_amount', 'tfpb_amount', 'th_amount', 'cfe_amount']; - $column = $field . '_amount'; - - if (!in_array($column, $allowedFields)) { - return response()->json(['error' => 'Champ invalide'], 400); - } - - $query = Taxe::query(); - - if ($request->has('department_id')) { - $query->where('department_id', $request->input('department_id')); - } - - if ($request->has('year')) { - $query->where('year', $request->input('year')); + try { + $average = $this->taxeStatService->calculateAverage( + $field, + $request->input('department_id'), + $request->input('year') + ); + + return response()->json([ + 'field' => $field, + 'average' => $average, + 'filters' => $request->validated() + ]); + } catch (InvalidArgumentException $e) { + return response()->json(['error' => $e->getMessage()], 400); } + } - $average = $query->avg($column); + /** + * Get statistics grouped by location (department or region). + */ + public function statsByLocation(StatsByLocationRequest $request): JsonResponse + { + $data = $this->taxeStatService->getStatsByLocation( + $request->input('group_by', 'department'), + $request->input('year') + ); return response()->json([ - 'field' => $field, - 'average' => round($average, 2), - 'filters' => $request->all() + 'group_by' => $request->input('group_by', 'department'), + 'year' => $request->input('year', 'all'), + 'data' => $data ]); } - -public function statsByLocation(Request $request) -{ - $groupBy = $request->input('group_by', 'department'); - - $query = Taxe::query(); - - if ($groupBy === 'region') { - $query->join('departments', 'taxes.department_id', '=', 'departments.department_id'); - $query->groupBy('departments.region_name'); - $query->select('departments.region_name as location'); - } else { - $query->groupBy('taxes.department_id'); - $query->select('taxes.department_id as location'); - } - - if ($request->has('year')) { - $query->where('taxes.year', $request->input('year')); - } - - $sqlSelects = []; - - $amounts = [ - 'tfpnb_amount' => 'tfpnb', - 'tfpb_amount' => 'tfpb', - 'th_amount' => 'th', - 'cfe_amount' => 'cfe' - ]; - - foreach ($amounts as $col => $alias) { - $sqlSelects[] = "SUM($col) as {$alias}_total_amount"; - $sqlSelects[] = "ROUND(AVG($col), 2) as {$alias}_avg_amount"; - } - - $rates = [ - 'tfpnb_percentage' => 'tfpnb', - 'tfpb_percentage' => 'tfpb', - 'th_percentage' => 'th', - 'cfe_percentage' => 'cfe' - ]; - - foreach ($rates as $col => $alias) { - $sqlSelects[] = "ROUND(AVG($col), 2) as {$alias}_avg_rate"; - } - - $query->addSelect(DB::raw(implode(', ', $sqlSelects))); - - return response()->json([ - 'group_by' => $groupBy, - 'year' => $request->input('year', 'all'), - 'data' => $query->get() - ]); -} } \ No newline at end of file diff --git a/api/app/Http/Requests/StatsByLocationRequest.php b/api/app/Http/Requests/StatsByLocationRequest.php new file mode 100644 index 0000000..118c1e6 --- /dev/null +++ b/api/app/Http/Requests/StatsByLocationRequest.php @@ -0,0 +1,50 @@ +has('group_by')) { + $sanitized['group_by'] = strtolower( + preg_replace('/[^a-z_]/', '', trim($this->group_by)) + ); + } + + // Sanitize year: convert to integer, remove any non-numeric characters + if ($this->has('year')) { + $sanitized['year'] = (int) preg_replace('/[^0-9]/', '', $this->year); + } + + $this->merge($sanitized); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'group_by' => ['sometimes', 'string', 'alpha', Rule::in(['department', 'region'])], + 'year' => 'sometimes|integer|min:2000|max:2100', + ]; + } +} diff --git a/api/app/Http/Requests/TaxStatRequest.php b/api/app/Http/Requests/TaxStatRequest.php new file mode 100644 index 0000000..2c7ec78 --- /dev/null +++ b/api/app/Http/Requests/TaxStatRequest.php @@ -0,0 +1,49 @@ +has('department_id')) { + $sanitized['department_id'] = strtoupper( + preg_replace('/[^0-9A-Za-z]/', '', trim($this->department_id)) + ); + } + + // Sanitize year: convert to integer, remove any non-numeric characters + if ($this->has('year')) { + $sanitized['year'] = (int) preg_replace('/[^0-9]/', '', $this->year); + } + + $this->merge($sanitized); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'department_id' => 'sometimes|string|min:2|max:3|alpha_num|regex:/^[0-9A-Z]{2,3}$/', + 'year' => 'sometimes|integer|min:2000|max:2100', + ]; + } +} diff --git a/api/app/Models/Department.php b/api/app/Models/Department.php index 09ea02d..96108ea 100644 --- a/api/app/Models/Department.php +++ b/api/app/Models/Department.php @@ -26,6 +26,14 @@ class Department extends Model 'region_name' ]; - protected $table = 'departments'; - public $timestamps = false; -} \ No newline at end of file + protected $table = 'departments'; + public $timestamps = false; + + /** + * Get the taxes for the department. + */ + public function taxes() + { + return $this->hasMany(Taxe::class, 'department_id', 'department_id'); + } +} diff --git a/api/app/Models/Taxe.php b/api/app/Models/Taxe.php index 1ee6293..6796ae6 100644 --- a/api/app/Models/Taxe.php +++ b/api/app/Models/Taxe.php @@ -19,6 +19,33 @@ class Taxe extends Model { use HasFactory; + // Tax field constants + public const FIELD_TFPNB = 'tfpnb'; + public const FIELD_TFPB = 'tfpb'; + public const FIELD_TH = 'th'; + public const FIELD_CFE = 'cfe'; + + public const ALLOWED_STAT_FIELDS = [ + self::FIELD_TFPNB, + self::FIELD_TFPB, + self::FIELD_TH, + self::FIELD_CFE, + ]; + + public const AMOUNT_FIELDS = [ + 'tfpnb_amount' => self::FIELD_TFPNB, + 'tfpb_amount' => self::FIELD_TFPB, + 'th_amount' => self::FIELD_TH, + 'cfe_amount' => self::FIELD_CFE, + ]; + + public const PERCENTAGE_FIELDS = [ + 'tfpnb_percentage' => self::FIELD_TFPNB, + 'tfpb_percentage' => self::FIELD_TFPB, + 'th_percentage' => self::FIELD_TH, + 'cfe_percentage' => self::FIELD_CFE, + ]; + protected $fillable = [ 'commune_code', 'commune_name', @@ -46,7 +73,15 @@ class Taxe extends Model 'cfe_amount' => 'float', ]; - protected $table = 'taxes'; - - public $timestamps = false; -} \ No newline at end of file + protected $table = 'taxes'; + + public $timestamps = false; + + /** + * Get the department that owns the tax record. + */ + public function department() + { + return $this->belongsTo(Department::class, 'department_id', 'department_id'); + } +} diff --git a/api/app/Models/User.php b/api/app/Models/User.php deleted file mode 100644 index 749c7b7..0000000 --- a/api/app/Models/User.php +++ /dev/null @@ -1,48 +0,0 @@ - */ - use HasFactory, Notifiable; - - /** - * The attributes that are mass assignable. - * - * @var list - */ - protected $fillable = [ - 'name', - 'email', - 'password', - ]; - - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ - protected $hidden = [ - 'password', - 'remember_token', - ]; - - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; - } -} diff --git a/api/app/Services/TaxeStatService.php b/api/app/Services/TaxeStatService.php new file mode 100644 index 0000000..daef893 --- /dev/null +++ b/api/app/Services/TaxeStatService.php @@ -0,0 +1,117 @@ +validateAndGetColumn($field); + $query = $this->buildBaseQuery($departmentId, $year); + + return $query->sum($column); + } + + /** + * Calculate average for a specific tax field with optional filters. + */ + public function calculateAverage(string $field, ?string $departmentId = null, ?int $year = null): float + { + $column = $this->validateAndGetColumn($field); + $query = $this->buildBaseQuery($departmentId, $year); + + return round($query->avg($column), 2); + } + + /** + * Get statistics grouped by location (department or region). + */ + public function getStatsByLocation(string $groupBy = 'department', ?int $year = null): array + { + // Validate groupBy parameter (whitelist approach) + if (!in_array($groupBy, ['department', 'region'], true)) { + throw new InvalidArgumentException('Le paramètre group_by doit être "department" ou "region".'); + } + + $query = Taxe::query(); + + // Setup grouping + if ($groupBy === 'region') { + $query->join('departments', 'taxes.department_id', '=', 'departments.department_id'); + $query->groupBy('departments.region_name'); + $query->select('departments.region_name as location'); + } else { + $query->groupBy('taxes.department_id'); + $query->select('taxes.department_id as location'); + } + + // Apply year filter if provided + if ($year !== null) { + $query->where('taxes.year', $year); + } + + // Build aggregate selects + $sqlSelects = $this->buildAggregateSelects(); + $query->addSelect(DB::raw(implode(', ', $sqlSelects))); + + return $query->get()->toArray(); + } + + /** + * Validate field and return the corresponding column name. + */ + private function validateAndGetColumn(string $field): string + { + if (!in_array($field, Taxe::ALLOWED_STAT_FIELDS)) { + throw new InvalidArgumentException('Champ invalide'); + } + + return $field . '_amount'; + } + + /** + * Build base query with optional filters. + */ + private function buildBaseQuery(?string $departmentId, ?int $year) + { + $query = Taxe::query(); + + if ($departmentId !== null) { + $query->where('department_id', $departmentId); + } + + if ($year !== null) { + $query->where('year', $year); + } + + return $query; + } + + /** + * Build aggregate SELECT statements for stats query. + */ + private function buildAggregateSelects(): array + { + $sqlSelects = []; + + // Aggregate amount fields + foreach (Taxe::AMOUNT_FIELDS as $col => $alias) { + $sqlSelects[] = "SUM({$col}) as {$alias}_total_amount"; + $sqlSelects[] = "ROUND(AVG({$col}), 2) as {$alias}_avg_amount"; + } + + // Aggregate percentage fields + foreach (Taxe::PERCENTAGE_FIELDS as $col => $alias) { + $sqlSelects[] = "ROUND(AVG({$col}), 2) as {$alias}_avg_rate"; + } + + return $sqlSelects; + } +} diff --git a/api/database/factories/UserFactory.php b/api/database/factories/UserFactory.php deleted file mode 100644 index 584104c..0000000 --- a/api/database/factories/UserFactory.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ -class UserFactory extends Factory -{ - /** - * The current password being used by the factory. - */ - protected static ?string $password; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'name' => fake()->name(), - 'email' => fake()->unique()->safeEmail(), - 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), - ]; - } - - /** - * Indicate that the model's email address should be unverified. - */ - public function unverified(): static - { - return $this->state(fn (array $attributes) => [ - 'email_verified_at' => null, - ]); - } -} diff --git a/api/database/migrations/0001_01_01_000000_create_users_table.php b/api/database/migrations/0001_01_01_000000_create_users_table.php deleted file mode 100644 index 05fb5d9..0000000 --- a/api/database/migrations/0001_01_01_000000_create_users_table.php +++ /dev/null @@ -1,49 +0,0 @@ -id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); - $table->timestamps(); - }); - - Schema::create('password_reset_tokens', function (Blueprint $table) { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - - Schema::create('sessions', function (Blueprint $table) { - $table->string('id')->primary(); - $table->foreignId('user_id')->nullable()->index(); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->longText('payload'); - $table->integer('last_activity')->index(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('users'); - Schema::dropIfExists('password_reset_tokens'); - Schema::dropIfExists('sessions'); - } -}; diff --git a/api/database/migrations/2026_02_07_000000_add_indexes_to_taxes_table.php b/api/database/migrations/2026_02_07_000000_add_indexes_to_taxes_table.php new file mode 100644 index 0000000..1f78437 --- /dev/null +++ b/api/database/migrations/2026_02_07_000000_add_indexes_to_taxes_table.php @@ -0,0 +1,46 @@ +index('year', 'idx_taxes_year'); + + // Index for commune code lookups + $table->index('commune_code', 'idx_taxes_commune_code'); + + // Composite index for common query pattern (department + year) + $table->index(['department_id', 'year'], 'idx_taxes_dept_year'); + }); + + Schema::table('departments', function (Blueprint $table) { + // Index for region filtering (used in statsByLocation with group_by=region) + $table->index('region_name', 'idx_departments_region'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('taxes', function (Blueprint $table) { + $table->dropIndex('idx_taxes_year'); + $table->dropIndex('idx_taxes_commune_code'); + $table->dropIndex('idx_taxes_dept_year'); + }); + + Schema::table('departments', function (Blueprint $table) { + $table->dropIndex('idx_departments_region'); + }); + } +}; -- GitLab From a24a3d6b68c01eb1d95f6f374d1d600b3c4ea349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Flambard?= Date: Mon, 9 Feb 2026 17:29:38 +0100 Subject: [PATCH 2/3] [REFACTO] - Change code architecture and refacto current endpoints --- api/app/Dto/TaxFieldStat.php | 38 +++++++++ api/app/Dto/TaxGlobalStat.php | 31 ++++++++ .../Http/Controllers/TaxeStatController.php | 77 ------------------- .../Http/Requests/StatsByLocationRequest.php | 50 ------------ api/app/Http/Requests/TaxStatRequest.php | 49 ------------ api/app/Models/Department.php | 30 +++++++- api/app/Models/Taxe.php | 27 ++++++- api/app/Services/TaxeStatService.php | 63 +++++++++++++++ api/app/State/TaxFieldStatProvider.php | 50 ++++++++++++ api/app/State/TaxGlobalStatProvider.php | 32 ++++++++ api/config/api-platform.php | 1 + api/routes/api.php | 6 +- 12 files changed, 268 insertions(+), 186 deletions(-) create mode 100644 api/app/Dto/TaxFieldStat.php create mode 100644 api/app/Dto/TaxGlobalStat.php delete mode 100644 api/app/Http/Controllers/TaxeStatController.php delete mode 100644 api/app/Http/Requests/StatsByLocationRequest.php delete mode 100644 api/app/Http/Requests/TaxStatRequest.php create mode 100644 api/app/State/TaxFieldStatProvider.php create mode 100644 api/app/State/TaxGlobalStatProvider.php diff --git a/api/app/Dto/TaxFieldStat.php b/api/app/Dto/TaxFieldStat.php new file mode 100644 index 0000000..f0ceecc --- /dev/null +++ b/api/app/Dto/TaxFieldStat.php @@ -0,0 +1,38 @@ + 'string'])] +#[QueryParameter(key: 'year', schema: ['type' => 'integer'])] +class TaxFieldStat +{ + public function __construct( + public readonly string $field = '', + public readonly ?float $sum = null, + public readonly ?float $average = null, + public readonly array $filters = [], + ) {} +} diff --git a/api/app/Dto/TaxGlobalStat.php b/api/app/Dto/TaxGlobalStat.php new file mode 100644 index 0000000..eef1cd4 --- /dev/null +++ b/api/app/Dto/TaxGlobalStat.php @@ -0,0 +1,31 @@ + 'string', 'enum' => ['department', 'region']])] +#[QueryParameter(key: 'year', schema: ['type' => 'integer'])] +class TaxGlobalStat +{ + public function __construct( + public readonly string $group_by = 'department', + public readonly string|int|null $year = null, + public readonly array $data = [], + ) {} +} diff --git a/api/app/Http/Controllers/TaxeStatController.php b/api/app/Http/Controllers/TaxeStatController.php deleted file mode 100644 index 2462b50..0000000 --- a/api/app/Http/Controllers/TaxeStatController.php +++ /dev/null @@ -1,77 +0,0 @@ -taxeStatService->calculateSum( - $field, - $request->input('department_id'), - $request->input('year') - ); - - return response()->json([ - 'field' => $field, - 'sum' => $total, - 'filters' => $request->validated() - ]); - } catch (InvalidArgumentException $e) { - return response()->json(['error' => $e->getMessage()], 400); - } - } - - /** - * Calculate average of a specific tax field. - */ - public function average(TaxStatRequest $request, string $field): JsonResponse - { - try { - $average = $this->taxeStatService->calculateAverage( - $field, - $request->input('department_id'), - $request->input('year') - ); - - return response()->json([ - 'field' => $field, - 'average' => $average, - 'filters' => $request->validated() - ]); - } catch (InvalidArgumentException $e) { - return response()->json(['error' => $e->getMessage()], 400); - } - } - - /** - * Get statistics grouped by location (department or region). - */ - public function statsByLocation(StatsByLocationRequest $request): JsonResponse - { - $data = $this->taxeStatService->getStatsByLocation( - $request->input('group_by', 'department'), - $request->input('year') - ); - - return response()->json([ - 'group_by' => $request->input('group_by', 'department'), - 'year' => $request->input('year', 'all'), - 'data' => $data - ]); - } -} \ No newline at end of file diff --git a/api/app/Http/Requests/StatsByLocationRequest.php b/api/app/Http/Requests/StatsByLocationRequest.php deleted file mode 100644 index 118c1e6..0000000 --- a/api/app/Http/Requests/StatsByLocationRequest.php +++ /dev/null @@ -1,50 +0,0 @@ -has('group_by')) { - $sanitized['group_by'] = strtolower( - preg_replace('/[^a-z_]/', '', trim($this->group_by)) - ); - } - - // Sanitize year: convert to integer, remove any non-numeric characters - if ($this->has('year')) { - $sanitized['year'] = (int) preg_replace('/[^0-9]/', '', $this->year); - } - - $this->merge($sanitized); - } - - /** - * Get the validation rules that apply to the request. - */ - public function rules(): array - { - return [ - 'group_by' => ['sometimes', 'string', 'alpha', Rule::in(['department', 'region'])], - 'year' => 'sometimes|integer|min:2000|max:2100', - ]; - } -} diff --git a/api/app/Http/Requests/TaxStatRequest.php b/api/app/Http/Requests/TaxStatRequest.php deleted file mode 100644 index 2c7ec78..0000000 --- a/api/app/Http/Requests/TaxStatRequest.php +++ /dev/null @@ -1,49 +0,0 @@ -has('department_id')) { - $sanitized['department_id'] = strtoupper( - preg_replace('/[^0-9A-Za-z]/', '', trim($this->department_id)) - ); - } - - // Sanitize year: convert to integer, remove any non-numeric characters - if ($this->has('year')) { - $sanitized['year'] = (int) preg_replace('/[^0-9]/', '', $this->year); - } - - $this->merge($sanitized); - } - - /** - * Get the validation rules that apply to the request. - */ - public function rules(): array - { - return [ - 'department_id' => 'sometimes|string|min:2|max:3|alpha_num|regex:/^[0-9A-Z]{2,3}$/', - 'year' => 'sometimes|integer|min:2000|max:2100', - ]; - } -} diff --git a/api/app/Models/Department.php b/api/app/Models/Department.php index 96108ea..e2fba76 100644 --- a/api/app/Models/Department.php +++ b/api/app/Models/Department.php @@ -3,16 +3,38 @@ namespace App\Models; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\QueryParameter; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use ApiPlatform\Laravel\Eloquent\Filter\EqualsFilter; +#[ApiResource( + operations: [ + new GetCollection(), + new Get( + uriTemplate: '/departments/{department_id}', + uriVariables: ['department_id' => new Link(fromClass: Department::class, identifiers: ['department_id'])] + ), + new Post(), + new Patch( + uriTemplate: '/departments/{department_id}', + uriVariables: ['department_id' => new Link(fromClass: Department::class, identifiers: ['department_id'])] + ), + new Delete( + uriTemplate: '/departments/{department_id}', + uriVariables: ['department_id' => new Link(fromClass: Department::class, identifiers: ['department_id'])] + ), + ] +)] #[QueryParameter(key: 'department_id', filter: EqualsFilter::class)] #[QueryParameter(key: 'department_name', filter: EqualsFilter::class)] #[QueryParameter(key: 'region', filter: EqualsFilter::class)] - -#[ApiResource] class Department extends Model { use HasFactory; @@ -25,7 +47,9 @@ class Department extends Model 'department_name', 'region_name' ]; - + + protected $hidden = ['taxes']; + protected $table = 'departments'; public $timestamps = false; diff --git a/api/app/Models/Taxe.php b/api/app/Models/Taxe.php index 6796ae6..ddcaadc 100644 --- a/api/app/Models/Taxe.php +++ b/api/app/Models/Taxe.php @@ -3,15 +3,38 @@ namespace App\Models; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\QueryParameter; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use ApiPlatform\Laravel\Eloquent\Filter\EqualsFilter; -#[ApiResource] +#[ApiResource( + operations: [ + new GetCollection(), + new Get( + uriTemplate: '/taxes/{id}', + uriVariables: ['id' => new Link(fromClass: Taxe::class, identifiers: ['id'])] + ), + new Post(), + new Patch( + uriTemplate: '/taxes/{id}', + uriVariables: ['id' => new Link(fromClass: Taxe::class, identifiers: ['id'])] + ), + new Delete( + uriTemplate: '/taxes/{id}', + uriVariables: ['id' => new Link(fromClass: Taxe::class, identifiers: ['id'])] + ), + ] +)] #[QueryParameter(key: 'commune_code', filter: EqualsFilter::class)] #[QueryParameter(key: 'commune_name', filter: EqualsFilter::class)] -#[QueryParameter(key: 'department_id', filter: EqualsFilter::class)] +#[QueryParameter(key: 'department_id', filter: EqualsFilter::class, property: 'department_id')] #[QueryParameter(key: 'year', filter: EqualsFilter::class)] #[QueryParameter(key: 'sort[:property]', filter: EqualsFilter::class)] diff --git a/api/app/Services/TaxeStatService.php b/api/app/Services/TaxeStatService.php index daef893..83d61b4 100644 --- a/api/app/Services/TaxeStatService.php +++ b/api/app/Services/TaxeStatService.php @@ -64,6 +64,69 @@ class TaxeStatService return $query->get()->toArray(); } + /** + * Get time series data: average tax rate by year for a given region. + */ + public function getTimeSeries(string $region, string $taxType, ?int $startYear = null, ?int $endYear = null): array + { + $this->validateAndGetColumn($taxType); + $rateColumn = $taxType . '_percentage'; + + $query = Taxe::query() + ->join('departments', 'taxes.department_id', '=', 'departments.department_id') + ->where('departments.region_name', $region) + ->groupBy('taxes.year') + ->orderBy('taxes.year') + ->select('taxes.year', DB::raw("ROUND(AVG(taxes.{$rateColumn}), 2) as avg_rate")); + + if ($startYear !== null) { + $query->where('taxes.year', '>=', $startYear); + } + if ($endYear !== null) { + $query->where('taxes.year', '<=', $endYear); + } + + return $query->get()->toArray(); + } + + /** + * Get correlation data: rate vs amount per commune for scatter plot. + */ + public function getCorrelation(string $departmentId, string $taxType, int $year): array + { + $this->validateAndGetColumn($taxType); + $rateColumn = $taxType . '_percentage'; + $amountColumn = $taxType . '_amount'; + + return Taxe::query() + ->where('department_id', $departmentId) + ->where('year', $year) + ->select('commune_name', "{$rateColumn} as rate", "{$amountColumn} as amount") + ->get() + ->toArray(); + } + + /** + * Get distribution data: total collected volume per region for pie chart. + */ + public function getDistribution(string $taxType, ?int $year = null): array + { + $this->validateAndGetColumn($taxType); + $amountColumn = $taxType . '_amount'; + + $query = Taxe::query() + ->join('departments', 'taxes.department_id', '=', 'departments.department_id') + ->groupBy('departments.region_name') + ->select('departments.region_name as region', DB::raw("SUM(taxes.{$amountColumn}) as total_amount")) + ->orderByDesc('total_amount'); + + if ($year !== null) { + $query->where('taxes.year', $year); + } + + return $query->get()->toArray(); + } + /** * Validate field and return the corresponding column name. */ diff --git a/api/app/State/TaxFieldStatProvider.php b/api/app/State/TaxFieldStatProvider.php new file mode 100644 index 0000000..8f53966 --- /dev/null +++ b/api/app/State/TaxFieldStatProvider.php @@ -0,0 +1,50 @@ +query->get('department_id'); + $year = $request?->query->get('year'); + $yearInt = $year !== null ? (int) $year : null; + + $filters = array_filter([ + 'department_id' => $departmentId, + 'year' => $yearInt, + ], fn ($v) => $v !== null); + + $operationName = $operation->getName(); + + if (str_contains($operationName, 'sum')) { + $sum = $this->taxeStatService->calculateSum($field, $departmentId, $yearInt); + + return new TaxFieldStat(field: $field, sum: $sum, filters: $filters); + } + + $average = $this->taxeStatService->calculateAverage($field, $departmentId, $yearInt); + + return new TaxFieldStat(field: $field, average: $average, filters: $filters); + } +} diff --git a/api/app/State/TaxGlobalStatProvider.php b/api/app/State/TaxGlobalStatProvider.php new file mode 100644 index 0000000..3565bd8 --- /dev/null +++ b/api/app/State/TaxGlobalStatProvider.php @@ -0,0 +1,32 @@ +query->get('group_by', 'department') ?? 'department'; + $year = $request?->query->get('year'); + $yearInt = $year !== null ? (int) $year : null; + + $data = $this->taxeStatService->getStatsByLocation($groupBy, $yearInt); + + return new TaxGlobalStat( + group_by: $groupBy, + year: $yearInt ?? 'all', + data: $data, + ); + } +} diff --git a/api/config/api-platform.php b/api/config/api-platform.php index ecc875f..cfee337 100644 --- a/api/config/api-platform.php +++ b/api/config/api-platform.php @@ -30,6 +30,7 @@ return [ 'resources' => [ app_path('Models'), + app_path('Dto'), ], 'formats' => [ diff --git a/api/routes/api.php b/api/routes/api.php index bc856bf..7c4d255 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -1,7 +1,3 @@ Date: Mon, 9 Feb 2026 17:30:13 +0100 Subject: [PATCH 3/3] [FEAT] - Add remaining endpoints --- api/app/Dto/CommuneCorrelation.php | 33 +++++++++++++ api/app/Dto/RegionDistribution.php | 31 ++++++++++++ api/app/Dto/TaxTimeSeries.php | 35 ++++++++++++++ api/app/State/CommuneCorrelationProvider.php | 51 ++++++++++++++++++++ api/app/State/RegionDistributionProvider.php | 41 ++++++++++++++++ api/app/State/TaxTimeSeriesProvider.php | 50 +++++++++++++++++++ 6 files changed, 241 insertions(+) create mode 100644 api/app/Dto/CommuneCorrelation.php create mode 100644 api/app/Dto/RegionDistribution.php create mode 100644 api/app/Dto/TaxTimeSeries.php create mode 100644 api/app/State/CommuneCorrelationProvider.php create mode 100644 api/app/State/RegionDistributionProvider.php create mode 100644 api/app/State/TaxTimeSeriesProvider.php diff --git a/api/app/Dto/CommuneCorrelation.php b/api/app/Dto/CommuneCorrelation.php new file mode 100644 index 0000000..18c21ff --- /dev/null +++ b/api/app/Dto/CommuneCorrelation.php @@ -0,0 +1,33 @@ + 'string'], required: true)] +#[QueryParameter(key: 'tax_type', schema: ['type' => 'string', 'enum' => ['tfpnb', 'tfpb', 'th', 'cfe']], required: true)] +#[QueryParameter(key: 'year', schema: ['type' => 'integer'], required: true)] +class CommuneCorrelation +{ + public function __construct( + public readonly string $department_id = '', + public readonly string $tax_type = '', + public readonly ?int $year = null, + public readonly array $data = [], + ) {} +} diff --git a/api/app/Dto/RegionDistribution.php b/api/app/Dto/RegionDistribution.php new file mode 100644 index 0000000..699c83d --- /dev/null +++ b/api/app/Dto/RegionDistribution.php @@ -0,0 +1,31 @@ + 'string', 'enum' => ['tfpnb', 'tfpb', 'th', 'cfe']], required: true)] +#[QueryParameter(key: 'year', schema: ['type' => 'integer'])] +class RegionDistribution +{ + public function __construct( + public readonly string $tax_type = '', + public readonly ?int $year = null, + public readonly array $data = [], + ) {} +} diff --git a/api/app/Dto/TaxTimeSeries.php b/api/app/Dto/TaxTimeSeries.php new file mode 100644 index 0000000..542ff08 --- /dev/null +++ b/api/app/Dto/TaxTimeSeries.php @@ -0,0 +1,35 @@ + 'string'], required: true)] +#[QueryParameter(key: 'tax_type', schema: ['type' => 'string', 'enum' => ['tfpnb', 'tfpb', 'th', 'cfe']], required: true)] +#[QueryParameter(key: 'start_year', schema: ['type' => 'integer'])] +#[QueryParameter(key: 'end_year', schema: ['type' => 'integer'])] +class TaxTimeSeries +{ + public function __construct( + public readonly string $region = '', + public readonly string $tax_type = '', + public readonly ?int $start_year = null, + public readonly ?int $end_year = null, + public readonly array $data = [], + ) {} +} diff --git a/api/app/State/CommuneCorrelationProvider.php b/api/app/State/CommuneCorrelationProvider.php new file mode 100644 index 0000000..525a7cd --- /dev/null +++ b/api/app/State/CommuneCorrelationProvider.php @@ -0,0 +1,51 @@ +query->get('department_id'); + $taxType = $request?->query->get('tax_type'); + $year = $request?->query->get('year'); + + if (!$departmentId) { + throw new BadRequestHttpException('The "department_id" parameter is required.'); + } + + if (!$taxType || !in_array($taxType, Taxe::ALLOWED_STAT_FIELDS, true)) { + throw new BadRequestHttpException( + sprintf('Invalid tax_type "%s". Allowed: %s', $taxType, implode(', ', Taxe::ALLOWED_STAT_FIELDS)) + ); + } + + if (!$year) { + throw new BadRequestHttpException('The "year" parameter is required.'); + } + + $yearInt = (int) $year; + + $data = $this->taxeStatService->getCorrelation($departmentId, $taxType, $yearInt); + + return new CommuneCorrelation( + department_id: $departmentId, + tax_type: $taxType, + year: $yearInt, + data: $data, + ); + } +} diff --git a/api/app/State/RegionDistributionProvider.php b/api/app/State/RegionDistributionProvider.php new file mode 100644 index 0000000..7f4e1d2 --- /dev/null +++ b/api/app/State/RegionDistributionProvider.php @@ -0,0 +1,41 @@ +query->get('tax_type'); + $year = $request?->query->get('year'); + + if (!$taxType || !in_array($taxType, Taxe::ALLOWED_STAT_FIELDS, true)) { + throw new BadRequestHttpException( + sprintf('Invalid tax_type "%s". Allowed: %s', $taxType, implode(', ', Taxe::ALLOWED_STAT_FIELDS)) + ); + } + + $yearInt = $year !== null ? (int) $year : null; + + $data = $this->taxeStatService->getDistribution($taxType, $yearInt); + + return new RegionDistribution( + tax_type: $taxType, + year: $yearInt, + data: $data, + ); + } +} diff --git a/api/app/State/TaxTimeSeriesProvider.php b/api/app/State/TaxTimeSeriesProvider.php new file mode 100644 index 0000000..73d6131 --- /dev/null +++ b/api/app/State/TaxTimeSeriesProvider.php @@ -0,0 +1,50 @@ +query->get('region'); + $taxType = $request?->query->get('tax_type'); + $startYear = $request?->query->get('start_year'); + $endYear = $request?->query->get('end_year'); + + if (!$region) { + throw new BadRequestHttpException('The "region" parameter is required.'); + } + + if (!$taxType || !in_array($taxType, Taxe::ALLOWED_STAT_FIELDS, true)) { + throw new BadRequestHttpException( + sprintf('Invalid tax_type "%s". Allowed: %s', $taxType, implode(', ', Taxe::ALLOWED_STAT_FIELDS)) + ); + } + + $startYearInt = $startYear !== null ? (int) $startYear : null; + $endYearInt = $endYear !== null ? (int) $endYear : null; + + $data = $this->taxeStatService->getTimeSeries($region, $taxType, $startYearInt, $endYearInt); + + return new TaxTimeSeries( + region: $region, + tax_type: $taxType, + start_year: $startYearInt, + end_year: $endYearInt, + data: $data, + ); + } +} -- GitLab