feat: Implement staff management features; add controller, service, resource, routes, and tests for staff and category assignments

This commit is contained in:
2026-08-04 16:20:30 -03:00
parent 3a039a6055
commit 2a15dc92be
18 changed files with 618 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Staff\Controllers;
use App\Domains\Staff\Requests\StoreStaffRequest;
use App\Domains\Staff\Requests\UpdateStaffRequest;
use App\Domains\Staff\Resources\StaffResource;
use App\Domains\Staff\Services\StaffService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class AdminAppStaffController extends Controller
{
public function __construct(private readonly StaffService $staffService) {}
public function index(Request $request): AnonymousResourceCollection
{
return StaffResource::collection($this->staffService->list(
$request->user()->tenant()->firstOrFail(),
$request->string('search')->trim()->toString() ?: null,
));
}
public function store(StoreStaffRequest $request): StaffResource
{
return StaffResource::make($this->staffService->create(
$request->user()->tenant()->firstOrFail(),
$request->validated(),
));
}
public function update(UpdateStaffRequest $request, int $staff): StaffResource
{
return StaffResource::make($this->staffService->update(
$request->user()->tenant()->firstOrFail(),
$staff,
$request->validated(),
));
}
public function destroy(Request $request, int $staff): Response
{
$this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff);
return response()->noContent();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Staff\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreStaffRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'nombre_apellido' => ['required', 'string', 'max:255'],
'dni' => ['required', 'string', 'max:50'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'category_ids' => ['required', 'array', 'min:1'],
'category_ids.*' => ['required', 'integer', 'distinct', Rule::exists('categorias', 'id')],
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Domains\Staff\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateStaffRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$staffId = (int) $this->route('staff');
return [
'nombre_apellido' => ['required', 'string', 'max:255'],
'dni' => ['required', 'string', 'max:50'],
'email' => [
'required',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($staffId),
],
'category_ids' => ['required', 'array', 'min:1'],
'category_ids.*' => ['required', 'integer', 'distinct', Rule::exists('categorias', 'id')],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Staff\Resources;
use App\Domains\Auth\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin User */
class StaffResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nombre_apellido' => $this->nombre_apellido,
'dni' => $this->dni,
'email' => $this->email,
'rol_codigo' => $this->rol_codigo,
'role' => $this->whenLoaded('role', fn () => [
'codigo' => $this->role?->codigo,
'nombre' => $this->role?->nombre,
]),
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
->map(fn ($category) => [
'id' => $category->id,
'nombre' => $category->nombre,
])->values()),
];
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Domains\Staff\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class StaffService
{
/** @return Collection<int, User> */
public function list(Tenant $tenant, ?string $search = null): Collection
{
return $this->staffQuery($tenant)
->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')])
->when($search, function (Builder $query, string $search): void {
$query->where(function (Builder $query) use ($search): void {
$query->where('nombre_apellido', 'like', "%{$search}%")
->orWhere('dni', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
})
->orderBy('nombre_apellido')
->get();
}
/** @return Collection<int, Category> */
private function assignableCategories(Tenant $tenant): Collection
{
return Category::query()
->whereNull('categoria_id')
->where(function (Builder $query) use ($tenant): void {
$query->where('tenant_code', $tenant->codigo)
->orWhereHas('catalogItems', fn (Builder $items) => $items
->where('tenant_code', $tenant->codigo));
})
->orderBy('nombre')
->get();
}
/** @param array<string, mixed> $data */
public function create(Tenant $tenant, array $data): User
{
$this->assertCategoriesBelongToTenant($tenant, $data['category_ids']);
return DB::transaction(function () use ($tenant, $data): User {
$staff = User::query()->create([
...Arr::only($data, ['nombre_apellido', 'dni', 'email']),
'email' => mb_strtolower(trim((string) $data['email'])),
'password' => Str::random(64),
'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $tenant->codigo,
]);
$staff->scanCategories()->sync($data['category_ids']);
return $staff->load('role', 'scanCategories');
});
}
/** @param array<string, mixed> $data */
public function update(Tenant $tenant, int $staffId, array $data): User
{
$staff = $this->find($tenant, $staffId);
$this->assertCategoriesBelongToTenant($tenant, $data['category_ids']);
return DB::transaction(function () use ($staff, $data): User {
$attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']);
$attributes['email'] = mb_strtolower(trim((string) $data['email']));
$staff->update($attributes);
$staff->scanCategories()->sync($data['category_ids']);
return $staff->load('role', 'scanCategories');
});
}
public function delete(Tenant $tenant, int $staffId): void
{
$this->find($tenant, $staffId)->delete();
}
public function find(Tenant $tenant, int $staffId): User
{
return $this->staffQuery($tenant)->findOrFail($staffId);
}
private function staffQuery(Tenant $tenant): Builder
{
return User::query()
->where('tenant_codigo', $tenant->codigo)
->where('rol_codigo', RoleCode::Scanner->value);
}
/** @param array<int, int> $categoryIds */
private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds): void
{
$validIds = $this->assignableCategories($tenant)
->whereIn('id', $categoryIds)
->pluck('id');
if ($validIds->count() !== count($categoryIds)) {
throw ValidationException::withMessages([
'category_ids' => 'Una o más categorías no pertenecen al tenant.',
]);
}
}
}

View File

@@ -0,0 +1,10 @@
<?php
use App\Domains\Staff\Controllers\AdminAppStaffController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::apiResource('staff', AdminAppStaffController::class)->except('show');
});