feat(scanner): add scanner category validation logic and related tests
This commit is contained in:
@@ -15,12 +15,17 @@ class StoreStaffRequest extends FormRequest
|
|||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$categoryRules = $this->user()->tenant()->firstOrFail()
|
||||||
|
->requiresScannerCategoryValidation()
|
||||||
|
? ['required', 'array', 'min:1']
|
||||||
|
: ['sometimes', 'array'];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
'dni' => ['required', 'string', 'max:50'],
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||||
'category_ids' => ['required', 'array', 'min:1'],
|
'category_ids' => $categoryRules,
|
||||||
'category_ids.*' => ['required', 'integer', 'distinct', Rule::exists('categorias', 'id')],
|
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ class UpdateStaffRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
$staffId = (int) $this->route('staff');
|
$staffId = (int) $this->route('staff');
|
||||||
|
$categoryRules = $this->user()->tenant()->firstOrFail()
|
||||||
|
->requiresScannerCategoryValidation()
|
||||||
|
? ['required', 'array', 'min:1']
|
||||||
|
: ['sometimes', 'array'];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
@@ -26,8 +30,8 @@ class UpdateStaffRequest extends FormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('users', 'email')->ignore($staffId),
|
Rule::unique('users', 'email')->ignore($staffId),
|
||||||
],
|
],
|
||||||
'category_ids' => ['required', 'array', 'min:1'],
|
'category_ids' => $categoryRules,
|
||||||
'category_ids.*' => ['required', 'integer', 'distinct', Rule::exists('categorias', 'id')],
|
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ class StaffService
|
|||||||
/** @param array<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
public function create(Tenant $tenant, array $data): User
|
public function create(Tenant $tenant, array $data): User
|
||||||
{
|
{
|
||||||
$this->assertCategoriesBelongToTenant($tenant, $data['category_ids']);
|
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
||||||
|
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
||||||
|
|
||||||
return DB::transaction(function () use ($tenant, $data): User {
|
return DB::transaction(function () use ($tenant, $data, $categoryIds): User {
|
||||||
$staff = User::query()->create([
|
$staff = User::query()->create([
|
||||||
...Arr::only($data, ['nombre_apellido', 'dni', 'email']),
|
...Arr::only($data, ['nombre_apellido', 'dni', 'email']),
|
||||||
'email' => mb_strtolower(trim((string) $data['email'])),
|
'email' => mb_strtolower(trim((string) $data['email'])),
|
||||||
@@ -58,7 +59,7 @@ class StaffService
|
|||||||
'rol_codigo' => RoleCode::Scanner->value,
|
'rol_codigo' => RoleCode::Scanner->value,
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
]);
|
]);
|
||||||
$staff->scanCategories()->sync($data['category_ids']);
|
$staff->scanCategories()->sync($categoryIds);
|
||||||
|
|
||||||
return $staff->load('role', 'scanCategories');
|
return $staff->load('role', 'scanCategories');
|
||||||
});
|
});
|
||||||
@@ -68,13 +69,14 @@ class StaffService
|
|||||||
public function update(Tenant $tenant, int $staffId, array $data): User
|
public function update(Tenant $tenant, int $staffId, array $data): User
|
||||||
{
|
{
|
||||||
$staff = $this->find($tenant, $staffId);
|
$staff = $this->find($tenant, $staffId);
|
||||||
$this->assertCategoriesBelongToTenant($tenant, $data['category_ids']);
|
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
||||||
|
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
||||||
|
|
||||||
return DB::transaction(function () use ($staff, $data): User {
|
return DB::transaction(function () use ($staff, $data, $categoryIds): User {
|
||||||
$attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']);
|
$attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']);
|
||||||
$attributes['email'] = mb_strtolower(trim((string) $data['email']));
|
$attributes['email'] = mb_strtolower(trim((string) $data['email']));
|
||||||
$staff->update($attributes);
|
$staff->update($attributes);
|
||||||
$staff->scanCategories()->sync($data['category_ids']);
|
$staff->scanCategories()->sync($categoryIds);
|
||||||
|
|
||||||
return $staff->load('role', 'scanCategories');
|
return $staff->load('role', 'scanCategories');
|
||||||
});
|
});
|
||||||
@@ -97,6 +99,19 @@ class StaffService
|
|||||||
->where('rol_codigo', RoleCode::Scanner->value);
|
->where('rol_codigo', RoleCode::Scanner->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function categoryIdsFor(Tenant $tenant, array $data): array
|
||||||
|
{
|
||||||
|
if (! $tenant->requiresScannerCategoryValidation()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data['category_ids'];
|
||||||
|
}
|
||||||
|
|
||||||
/** @param array<int, int> $categoryIds */
|
/** @param array<int, int> $categoryIds */
|
||||||
private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds): void
|
private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'display_categories',
|
'display_categories',
|
||||||
'display_seach_bar',
|
'display_seach_bar',
|
||||||
'display_cart',
|
'display_cart',
|
||||||
|
'scanner_category_validation_enabled',
|
||||||
'event_title',
|
'event_title',
|
||||||
'event_location',
|
'event_location',
|
||||||
'event_date_text',
|
'event_date_text',
|
||||||
@@ -53,6 +54,7 @@ class Tenant extends Model
|
|||||||
'display_categories' => true,
|
'display_categories' => true,
|
||||||
'display_seach_bar' => true,
|
'display_seach_bar' => true,
|
||||||
'display_cart' => true,
|
'display_cart' => true,
|
||||||
|
'scanner_category_validation_enabled' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getRouteKeyName(): string
|
public function getRouteKeyName(): string
|
||||||
@@ -60,6 +62,11 @@ class Tenant extends Model
|
|||||||
return 'codigo';
|
return 'codigo';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function requiresScannerCategoryValidation(): bool
|
||||||
|
{
|
||||||
|
return $this->scanner_category_validation_enabled;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
@@ -74,6 +81,7 @@ class Tenant extends Model
|
|||||||
'display_categories' => 'boolean',
|
'display_categories' => 'boolean',
|
||||||
'display_seach_bar' => 'boolean',
|
'display_seach_bar' => 'boolean',
|
||||||
'display_cart' => 'boolean',
|
'display_cart' => 'boolean',
|
||||||
|
'scanner_category_validation_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class StoreTenantRequest extends FormRequest
|
|||||||
'display_categories' => ['sometimes', 'boolean'],
|
'display_categories' => ['sometimes', 'boolean'],
|
||||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||||
'display_cart' => ['sometimes', 'boolean'],
|
'display_cart' => ['sometimes', 'boolean'],
|
||||||
|
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||||
'website_type_code' => [
|
'website_type_code' => [
|
||||||
'required_with:extras',
|
'required_with:extras',
|
||||||
'sometimes',
|
'sometimes',
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
'display_categories' => ['sometimes', 'boolean'],
|
'display_categories' => ['sometimes', 'boolean'],
|
||||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||||
'display_cart' => ['sometimes', 'boolean'],
|
'display_cart' => ['sometimes', 'boolean'],
|
||||||
|
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class TenantResource extends JsonResource
|
|||||||
'display_categories' => $this->display_categories,
|
'display_categories' => $this->display_categories,
|
||||||
'display_seach_bar' => $this->display_seach_bar,
|
'display_seach_bar' => $this->display_seach_bar,
|
||||||
'display_cart' => $this->display_cart,
|
'display_cart' => $this->display_cart,
|
||||||
|
'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled,
|
||||||
'social_media' => $this->whenLoaded(
|
'social_media' => $this->whenLoaded(
|
||||||
'socialMedia',
|
'socialMedia',
|
||||||
fn () => $this->socialMedia
|
fn () => $this->socialMedia
|
||||||
|
|||||||
@@ -69,12 +69,14 @@ class ScannerTicketService
|
|||||||
|
|
||||||
public function detail(User $scanner, string $ticketUuid): Ticket
|
public function detail(User $scanner, string $ticketUuid): Ticket
|
||||||
{
|
{
|
||||||
$categoryIds = $this->scannerCategoryIds($scanner);
|
$query = $this->baseQuery()
|
||||||
|
|
||||||
return $this->baseQuery()
|
|
||||||
->where('tenant_code', $scanner->tenant_codigo)
|
->where('tenant_code', $scanner->tenant_codigo)
|
||||||
->where('ticket', $ticketUuid)
|
->where('ticket', $ticketUuid);
|
||||||
->where(function (Builder $query) use ($scanner, $categoryIds): void {
|
|
||||||
|
if ($scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation()) {
|
||||||
|
$categoryIds = $this->scannerCategoryIds($scanner);
|
||||||
|
|
||||||
|
$query->where(function (Builder $query) use ($scanner, $categoryIds): void {
|
||||||
$query
|
$query
|
||||||
->where('scanner_user_id', $scanner->getKey())
|
->where('scanner_user_id', $scanner->getKey())
|
||||||
->orWhereHas(
|
->orWhereHas(
|
||||||
@@ -82,8 +84,10 @@ class ScannerTicketService
|
|||||||
fn (Builder $catalogItemQuery): Builder => $catalogItemQuery
|
fn (Builder $catalogItemQuery): Builder => $catalogItemQuery
|
||||||
->whereIn('category_id', $categoryIds)
|
->whereIn('category_id', $categoryIds)
|
||||||
);
|
);
|
||||||
})
|
});
|
||||||
->firstOrFail();
|
}
|
||||||
|
|
||||||
|
return $query->firstOrFail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scan(User $scanner, string $ticketUuid): Ticket
|
public function scan(User $scanner, string $ticketUuid): Ticket
|
||||||
@@ -153,6 +157,10 @@ class ScannerTicketService
|
|||||||
|
|
||||||
private function scannerCanScan(User $scanner, Ticket $ticket): bool
|
private function scannerCanScan(User $scanner, Ticket $ticket): bool
|
||||||
{
|
{
|
||||||
|
if (! $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$categoryId = $ticket->sourceCatalogItem?->category_id;
|
$categoryId = $ticket->sourceCatalogItem?->category_id;
|
||||||
|
|
||||||
return $categoryId !== null
|
return $categoryId !== null
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
private const DESFILE_TENANT_CODE = 'desfile_pura_tendencia';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table): void {
|
||||||
|
$table->boolean('scanner_category_validation_enabled')
|
||||||
|
->default(true)
|
||||||
|
->after('display_cart');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('tenants')
|
||||||
|
->where('codigo', self::DESFILE_TENANT_CODE)
|
||||||
|
->update(['scanner_category_validation_enabled' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('scanner_category_validation_enabled');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AddScannerCategoryValidationToTenantsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_disables_scanner_category_validation_only_for_desfile(): void
|
||||||
|
{
|
||||||
|
$migration = require database_path(
|
||||||
|
'migrations/2026_08_13_020000_add_scanner_category_validation_to_tenants_table.php'
|
||||||
|
);
|
||||||
|
|
||||||
|
$migration->down();
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'codigo' => 'desfile_pura_tendencia',
|
||||||
|
'scanner_category_validation_enabled' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('tenants')->insert([
|
||||||
|
'codigo' => 'scanner-validation-default',
|
||||||
|
'nombre' => 'Scanner validation default',
|
||||||
|
'dominio' => 'scanner-validation-default.test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tenants', [
|
||||||
|
'codigo' => 'scanner-validation-default',
|
||||||
|
'scanner_category_validation_enabled' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,47 @@ class StaffControllerTest extends TestCase
|
|||||||
->assertJsonValidationErrors('category_ids');
|
->assertJsonValidationErrors('category_ids');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_categories_are_required_by_default_for_scanner_staff(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/staff', [
|
||||||
|
'nombre_apellido' => 'Grace Hopper',
|
||||||
|
'dni' => '87654321',
|
||||||
|
'email' => 'grace@example.test',
|
||||||
|
'category_ids' => [],
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('category_ids');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_manage_staff_without_categories_when_tenant_disables_validation(): void
|
||||||
|
{
|
||||||
|
$this->tenant->update(['scanner_category_validation_enabled' => false]);
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/adminapp/tenant/staff', [
|
||||||
|
'nombre_apellido' => 'Grace Hopper',
|
||||||
|
'dni' => '87654321',
|
||||||
|
'email' => 'grace@example.test',
|
||||||
|
])->assertCreated()
|
||||||
|
->assertJsonCount(0, 'data.categories');
|
||||||
|
|
||||||
|
$staffId = $response->json('data.id');
|
||||||
|
$category = $this->createCategory('Entradas');
|
||||||
|
|
||||||
|
$this->putJson("/api/v1/adminapp/tenant/staff/{$staffId}", [
|
||||||
|
'nombre_apellido' => 'Grace Murray Hopper',
|
||||||
|
'dni' => '87654321',
|
||||||
|
'email' => 'grace@example.test',
|
||||||
|
'category_ids' => [$category->id],
|
||||||
|
])->assertOk()
|
||||||
|
->assertJsonCount(0, 'data.categories');
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('category_scanners', [
|
||||||
|
'user_id' => $staffId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_customer_cannot_manage_staff(): void
|
public function test_customer_cannot_manage_staff(): void
|
||||||
{
|
{
|
||||||
Sanctum::actingAs(User::factory()->create([
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
|||||||
@@ -254,6 +254,52 @@ class ScannerTicketControllerTest extends TestCase
|
|||||||
$this->assertNull($ticket->fresh()->used_at);
|
$this->assertNull($ticket->fresh()->used_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_scanner_can_read_and_scan_any_category_when_tenant_disables_validation(): void
|
||||||
|
{
|
||||||
|
$this->tenant->update(['scanner_category_validation_enabled' => false]);
|
||||||
|
$this->scanner->scanCategories()->detach();
|
||||||
|
$otherCategory = Category::query()->create([
|
||||||
|
'tenant_code' => $this->tenant->codigo,
|
||||||
|
'nombre' => 'Comidas',
|
||||||
|
]);
|
||||||
|
$ticket = $this->createTicket(
|
||||||
|
'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa',
|
||||||
|
[],
|
||||||
|
$otherCategory,
|
||||||
|
);
|
||||||
|
Sanctum::actingAs($this->scanner);
|
||||||
|
|
||||||
|
$this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.id', $ticket->id);
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.scanner_user_id', $this->scanner->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disabled_category_validation_does_not_allow_scanning_another_tenant(): void
|
||||||
|
{
|
||||||
|
$this->tenant->update(['scanner_category_validation_enabled' => false]);
|
||||||
|
$otherTenant = $this->createTenant('other');
|
||||||
|
$foreignCategory = Category::query()->create([
|
||||||
|
'tenant_code' => $otherTenant->codigo,
|
||||||
|
'nombre' => 'Externas',
|
||||||
|
]);
|
||||||
|
$foreignTicket = $this->createTicket(
|
||||||
|
'bbbbbbbb-1111-4111-8111-bbbbbbbbbbbb',
|
||||||
|
[],
|
||||||
|
$foreignCategory,
|
||||||
|
$otherTenant,
|
||||||
|
);
|
||||||
|
Sanctum::actingAs($this->scanner);
|
||||||
|
|
||||||
|
$this->getJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}")
|
||||||
|
->assertNotFound();
|
||||||
|
$this->postJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}/scan")
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $attributes */
|
/** @param array<string, mixed> $attributes */
|
||||||
private function createTicket(
|
private function createTicket(
|
||||||
string $uuid,
|
string $uuid,
|
||||||
|
|||||||
Reference in New Issue
Block a user