Compare commits
11 Commits
c4b317d5fc
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| b294e5c46e | |||
| d02b0c5551 | |||
| ee911171be | |||
| e17d1fa15e | |||
| 2a15dc92be | |||
| 3a039a6055 | |||
| 84e45bb964 | |||
| 8aa3a26ee7 | |||
| 1de08c1ca4 | |||
| 3f9bcd84c4 | |||
| 96f26e2e9d |
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable(['user_id', 'codigo', 'status'])]
|
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
||||||
#[Hidden(['codigo'])]
|
#[Hidden(['codigo'])]
|
||||||
class ResetPasswordAttempt extends Model
|
class ResetPasswordAttempt extends Model
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ namespace App\Domains\Auth\Models;
|
|||||||
|
|
||||||
use App\Domains\Authorization\Enums\RoleCode;
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Domains\Authorization\Models\Role;
|
use App\Domains\Authorization\Models\Role;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
@@ -59,6 +61,17 @@ class User extends Authenticatable
|
|||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<Category, $this> */
|
||||||
|
public function scanCategories(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
Category::class,
|
||||||
|
'category_scanners',
|
||||||
|
'user_id',
|
||||||
|
'categoria_id',
|
||||||
|
)->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ use App\Domains\Authorization\Enums\RoleCode;
|
|||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class PasswordLoginService
|
class PasswordLoginService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws AccountLockedException
|
* @throws AccountLockedException
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
@@ -118,7 +123,7 @@ class PasswordLoginService
|
|||||||
|
|
||||||
if ($user === null || ! Hash::check($password, $user->password)) {
|
if ($user === null || ! Hash::check($password, $user->password)) {
|
||||||
if ($user !== null) {
|
if ($user !== null) {
|
||||||
$this->registerFailure($user, $now);
|
$this->registerFailure($user, $now, $tenantCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
$outcome = $user?->locked_until?->isFuture()
|
$outcome = $user?->locked_until?->isFuture()
|
||||||
@@ -177,7 +182,7 @@ class PasswordLoginService
|
|||||||
return $result['user'];
|
return $result['user'];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function registerFailure(User $user, CarbonImmutable $now): void
|
private function registerFailure(User $user, CarbonImmutable $now, string $tenantCode): void
|
||||||
{
|
{
|
||||||
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
|
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
|
||||||
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
|
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
|
||||||
@@ -189,6 +194,8 @@ class PasswordLoginService
|
|||||||
? $user->failed_login_attempts + 1
|
? $user->failed_login_attempts + 1
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
|
$previousAttempts = $user->failed_login_attempts;
|
||||||
|
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'failed_login_attempts' => $attempts,
|
'failed_login_attempts' => $attempts,
|
||||||
'last_failed_login_at' => $now,
|
'last_failed_login_at' => $now,
|
||||||
@@ -196,6 +203,17 @@ class PasswordLoginService
|
|||||||
? $now->addMinutes($lockMinutes)
|
? $now->addMinutes($lockMinutes)
|
||||||
: null,
|
: null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
|
||||||
|
try {
|
||||||
|
$this->resetPasswordAttemptService->createForEmail($user->email, $tenantCode, 'account_locked');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Failed to trigger reset password on account lock', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'exception' => $e
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordAttempt(
|
private function recordAttempt(
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ use Throwable;
|
|||||||
|
|
||||||
class ResetPasswordAttemptService
|
class ResetPasswordAttemptService
|
||||||
{
|
{
|
||||||
public function createForEmail(string $email, string $tenantCode): void
|
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint): ?int {
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('email', $email)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -39,6 +39,7 @@ class ResetPasswordAttemptService
|
|||||||
|
|
||||||
$attempt = $user->resetPasswordAttempts()->create([
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
'codigo' => $this->generateCode(),
|
'codigo' => $this->generateCode(),
|
||||||
|
'reason' => $reason,
|
||||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ enum RoleCode: string
|
|||||||
{
|
{
|
||||||
case Admin = 'admin';
|
case Admin = 'admin';
|
||||||
case AdminApp = 'adminapp';
|
case AdminApp = 'adminapp';
|
||||||
|
case Scanner = 'scanner';
|
||||||
case User = 'user';
|
case User = 'user';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
|
||||||
|
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
|
||||||
|
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class OnTicketFeaturedGroupController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OnTicketFeaturedGroupService $featuredGroupService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return OnTicketFeaturedGroupResource::collection(
|
||||||
|
$this->featuredGroupService->forTenant($this->onTicketTenant($request))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$featuredGroup = $this->featuredGroupService->create(
|
||||||
|
$this->onTicketTenant($request),
|
||||||
|
$request->validated(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return OnTicketFeaturedGroupResource::make($featuredGroup)
|
||||||
|
->response()
|
||||||
|
->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
UpsertOnTicketFeaturedGroupRequest $request,
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
): OnTicketFeaturedGroupResource {
|
||||||
|
$tenant = $this->onTicketTenant($request);
|
||||||
|
|
||||||
|
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
|
return OnTicketFeaturedGroupResource::make(
|
||||||
|
$this->featuredGroupService->update(
|
||||||
|
$tenant,
|
||||||
|
$featuredGroup,
|
||||||
|
$request->validated(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onTicketTenant(Request $request): Tenant
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
abort_unless($tenant->website_type_code === 'onticket', 404);
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,34 +2,28 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
namespace App\Domains\Catalog\Controllers;
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Category;
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Models\FeaturedItem;
|
|
||||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||||
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
||||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||||
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
||||||
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||||
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
|
||||||
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogItemResource;
|
use App\Domains\Catalog\Resources\CatalogItemResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
||||||
use App\Domains\Catalog\Services\CatalogService;
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
|
use App\Domains\Catalog\Services\FeaturedGroupService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
|
|
||||||
class CatalogController extends Controller
|
class CatalogController extends Controller
|
||||||
{
|
{
|
||||||
private const ITEMS_PER_PAGE = 12;
|
public function index(Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
|
||||||
|
|
||||||
public function index(Tenant $tenant): JsonResponse
|
|
||||||
{
|
{
|
||||||
$featuredGroups = FeaturedGroup::query()
|
$featuredGroups = FeaturedGroup::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
@@ -39,7 +33,7 @@ class CatalogController extends Controller
|
|||||||
return response()->json($featuredGroups->map(
|
return response()->json($featuredGroups->map(
|
||||||
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
|
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
|
||||||
$featuredGroup,
|
$featuredGroup,
|
||||||
$this->featuredItemsResponse($featuredGroup, 1),
|
$featuredGroupService->itemsResponse($featuredGroup, 1),
|
||||||
))->resolve()
|
))->resolve()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -89,12 +83,13 @@ class CatalogController extends Controller
|
|||||||
FeaturedGroupPageRequest $request,
|
FeaturedGroupPageRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
FeaturedGroup $featuredGroup,
|
FeaturedGroup $featuredGroup,
|
||||||
|
FeaturedGroupService $featuredGroupService,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
$page = (int) $request->validated('page', 1);
|
$page = (int) $request->validated('page', 1);
|
||||||
|
|
||||||
return response()->json($this->featuredItemsResponse($featuredGroup, $page));
|
return response()->json($featuredGroupService->itemsResponse($featuredGroup, $page));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(
|
public function show(
|
||||||
@@ -129,59 +124,4 @@ class CatalogController extends Controller
|
|||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<array-key, mixed> */
|
|
||||||
private function featuredItemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
|
||||||
{
|
|
||||||
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
|
|
||||||
$featuredItems = $this->featuredItemsQuery($featuredGroup)->get();
|
|
||||||
|
|
||||||
$featuredItems->each(
|
|
||||||
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
|
|
||||||
);
|
|
||||||
|
|
||||||
return CatalogFeaturedItemResource::collection($featuredItems)->resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
$paginator = $this->paginateFeaturedItems($featuredGroup, $page);
|
|
||||||
|
|
||||||
$paginator->getCollection()->each(
|
|
||||||
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
|
|
||||||
);
|
|
||||||
|
|
||||||
return CatalogFeaturedItemResource::collection($paginator)
|
|
||||||
->response()
|
|
||||||
->getData(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function paginateFeaturedItems(
|
|
||||||
FeaturedGroup $featuredGroup,
|
|
||||||
int $page,
|
|
||||||
): LengthAwarePaginator {
|
|
||||||
$paginator = $this->featuredItemsQuery($featuredGroup)->paginate(
|
|
||||||
perPage: self::ITEMS_PER_PAGE,
|
|
||||||
pageName: 'page',
|
|
||||||
page: $page,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $paginator->withPath(route('catalog.featured-groups.items.index', [
|
|
||||||
'tenant' => $featuredGroup->tenant_code,
|
|
||||||
'featuredGroup' => $featuredGroup->id,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<FeaturedItem, FeaturedGroup> */
|
|
||||||
private function featuredItemsQuery(FeaturedGroup $featuredGroup): HasMany
|
|
||||||
{
|
|
||||||
return $featuredGroup->featuredItems()->with([
|
|
||||||
'catalogItem.inventory',
|
|
||||||
'catalogItem.attachments',
|
|
||||||
'catalogItem.variants.inventory',
|
|
||||||
'catalogItem.variants.attachments',
|
|
||||||
'catalogItem.variants.eventDate',
|
|
||||||
'catalogItem.variants.definitions.itemAttribute.attribute',
|
|
||||||
'catalogItem.bundleComponents.catalogItem',
|
|
||||||
'catalogItem.bundleComponents.variant.catalogItem',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/Domains/Catalog/Enums/FeaturedGroupSource.php
Normal file
18
app/Domains/Catalog/Enums/FeaturedGroupSource.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Enums;
|
||||||
|
|
||||||
|
enum FeaturedGroupSource: string
|
||||||
|
{
|
||||||
|
case Manual = 'manual';
|
||||||
|
case Category = 'category';
|
||||||
|
case All = 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -64,4 +66,15 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(CatalogItem::class);
|
return $this->hasMany(CatalogItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<User, $this> */
|
||||||
|
public function scanners(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
User::class,
|
||||||
|
'category_scanners',
|
||||||
|
'categoria_id',
|
||||||
|
'user_id',
|
||||||
|
)->withTimestamps();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -13,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
|
'source_type',
|
||||||
|
'category_id',
|
||||||
'product_layout',
|
'product_layout',
|
||||||
'group_layout',
|
'group_layout',
|
||||||
'group_name',
|
'group_name',
|
||||||
@@ -26,9 +29,15 @@ class FeaturedGroup extends Model
|
|||||||
|
|
||||||
protected $table = 'featured_groups';
|
protected $table = 'featured_groups';
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'source_type' => FeaturedGroupSource::Manual->value,
|
||||||
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'source_type' => FeaturedGroupSource::class,
|
||||||
|
'category_id' => 'integer',
|
||||||
'product_layout' => ProductLayout::class,
|
'product_layout' => ProductLayout::class,
|
||||||
'group_layout' => GroupLayout::class,
|
'group_layout' => GroupLayout::class,
|
||||||
'group_order' => 'integer',
|
'group_order' => 'integer',
|
||||||
@@ -41,6 +50,12 @@ class FeaturedGroup extends Model
|
|||||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Category, $this> */
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return HasMany<FeaturedItem, $this> */
|
/** @return HasMany<FeaturedItem, $this> */
|
||||||
public function featuredItems(): HasMany
|
public function featuredItems(): HasMany
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -95,25 +95,7 @@ class Variant extends Model
|
|||||||
|
|
||||||
public function getName(): string
|
public function getName(): string
|
||||||
{
|
{
|
||||||
$name = $this->catalogItem->nombre;
|
return $this->catalogItem->nombre;
|
||||||
$this->loadMissing(['definitions.itemAttribute.attribute', 'eventDate']);
|
|
||||||
$definitions = $this->definitions
|
|
||||||
->map(function (VariantDefinition $definition): ?string {
|
|
||||||
$attributeName = $definition->itemAttribute?->attribute?->nombre;
|
|
||||||
|
|
||||||
return $attributeName
|
|
||||||
? "{$attributeName}: {$definition->value}"
|
|
||||||
: $definition->value;
|
|
||||||
})
|
|
||||||
->filter();
|
|
||||||
|
|
||||||
if ($this->eventDate !== null) {
|
|
||||||
$definitions->push('Fecha: '.$this->eventDate->date->format('Y-m-d'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$description = $definitions->implode(', ');
|
|
||||||
|
|
||||||
return $description === '' ? $name : "{$name} ({$description})";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMinimumUseDate(): ?CarbonInterface
|
public function getMinimumUseDate(): ?CarbonInterface
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests\AdminApp;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpsertOnTicketFeaturedGroupRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category_name' => ['required', 'string', 'max:255'],
|
||||||
|
'is_featured' => ['required', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin FeaturedGroup */
|
||||||
|
class OnTicketFeaturedGroupResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'category_id' => $this->category_id,
|
||||||
|
'category_name' => $this->category->nombre,
|
||||||
|
'group_name' => $this->group_name,
|
||||||
|
'is_featured' => $this->product_layout === ProductLayout::Row,
|
||||||
|
'type' => $this->source_type->value,
|
||||||
|
'product_layout' => $this->product_layout->value,
|
||||||
|
'group_layout' => $this->group_layout->value,
|
||||||
|
'order' => $this->group_order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,20 +5,22 @@ namespace App\Domains\Catalog\Resources;
|
|||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\FeaturedItem;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
/** @mixin FeaturedItem */
|
/** @mixin CatalogItem */
|
||||||
class CatalogFeaturedItemResource extends JsonResource
|
class CatalogFeaturedItemResource extends JsonResource
|
||||||
{
|
{
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$catalogItem = $this->catalogItem;
|
$catalogItem = $this->resource;
|
||||||
|
/** @var FeaturedGroup $featuredGroup */
|
||||||
|
$featuredGroup = $catalogItem->getRelation('featuredGroup');
|
||||||
|
|
||||||
if ($this->featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
|
if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
|
||||||
return $this->columnWithImageData($catalogItem);
|
return $this->columnWithImageData($catalogItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
app/Domains/Catalog/Services/FeaturedGroupService.php
Normal file
87
app/Domains/Catalog/Services/FeaturedGroupService.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
class FeaturedGroupService
|
||||||
|
{
|
||||||
|
private const ITEMS_PER_PAGE = 12;
|
||||||
|
|
||||||
|
/** @return array<array-key, mixed> */
|
||||||
|
public function itemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
||||||
|
{
|
||||||
|
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
|
||||||
|
$items = $this->itemsQuery($featuredGroup)->get();
|
||||||
|
$this->attachGroup($items, $featuredGroup);
|
||||||
|
|
||||||
|
return CatalogFeaturedItemResource::collection($items)->resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = $this->paginateItems($featuredGroup, $page);
|
||||||
|
$this->attachGroup($paginator->getCollection(), $featuredGroup);
|
||||||
|
|
||||||
|
return CatalogFeaturedItemResource::collection($paginator)
|
||||||
|
->response()
|
||||||
|
->getData(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Builder<CatalogItem> */
|
||||||
|
private function itemsQuery(FeaturedGroup $featuredGroup): Builder
|
||||||
|
{
|
||||||
|
$query = CatalogItem::query()
|
||||||
|
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
||||||
|
->with([
|
||||||
|
'inventory',
|
||||||
|
'attachments',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.attachments',
|
||||||
|
'variants.eventDate',
|
||||||
|
'variants.definitions.itemAttribute.attribute',
|
||||||
|
'bundleComponents.catalogItem',
|
||||||
|
'bundleComponents.variant.catalogItem',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return match ($featuredGroup->source_type) {
|
||||||
|
FeaturedGroupSource::Manual => $query
|
||||||
|
->select('catalog_items.*')
|
||||||
|
->join('featured_items', 'featured_items.catalog_item_id', '=', 'catalog_items.id')
|
||||||
|
->where('featured_items.featured_group_id', $featuredGroup->id)
|
||||||
|
->orderBy('featured_items.order')
|
||||||
|
->orderBy('featured_items.id'),
|
||||||
|
FeaturedGroupSource::Category => $query
|
||||||
|
->where('catalog_items.category_id', $featuredGroup->category_id)
|
||||||
|
->orderBy('catalog_items.id'),
|
||||||
|
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paginateItems(
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
int $page,
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$paginator = $this->itemsQuery($featuredGroup)->paginate(
|
||||||
|
perPage: self::ITEMS_PER_PAGE,
|
||||||
|
pageName: 'page',
|
||||||
|
page: $page,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $paginator->withPath(route('catalog.featured-groups.items.index', [
|
||||||
|
'tenant' => $featuredGroup->tenant_code,
|
||||||
|
'featuredGroup' => $featuredGroup->id,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attachGroup(iterable $items, FeaturedGroup $featuredGroup): void
|
||||||
|
{
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item->setRelation('featuredGroup', $featuredGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class OnTicketFeaturedGroupService
|
||||||
|
{
|
||||||
|
/** @return Collection<int, FeaturedGroup> */
|
||||||
|
public function forTenant(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
return FeaturedGroup::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('source_type', FeaturedGroupSource::Category)
|
||||||
|
->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo))
|
||||||
|
->with('category')
|
||||||
|
->orderBy('group_order')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{category_name: string, is_featured: bool} $data */
|
||||||
|
public function create(Tenant $tenant, array $data): FeaturedGroup
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): FeaturedGroup {
|
||||||
|
$category = $tenant->categories()->create([
|
||||||
|
'nombre' => $data['category_name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$featuredGroup = FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::Category,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'product_layout' => $this->productLayout($data['is_featured']),
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
'group_name' => $data['category_name'],
|
||||||
|
'group_order' => $this->nextOrder($tenant),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $featuredGroup->setRelation('category', $category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{category_name: string, is_featured: bool} $data */
|
||||||
|
public function update(
|
||||||
|
Tenant $tenant,
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
array $data,
|
||||||
|
): FeaturedGroup {
|
||||||
|
return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup {
|
||||||
|
$featuredGroup = FeaturedGroup::query()
|
||||||
|
->whereKey($featuredGroup->getKey())
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('source_type', FeaturedGroupSource::Category)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$category = Category::query()
|
||||||
|
->whereKey($featuredGroup->category_id)
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$category->update(['nombre' => $data['category_name']]);
|
||||||
|
$featuredGroup->update([
|
||||||
|
'group_name' => $data['category_name'],
|
||||||
|
'product_layout' => $this->productLayout($data['is_featured']),
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $featuredGroup->setRelation('category', $category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function productLayout(bool $isFeatured): ProductLayout
|
||||||
|
{
|
||||||
|
return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextOrder(Tenant $tenant): int
|
||||||
|
{
|
||||||
|
$maximumOrder = FeaturedGroup::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->max('group_order');
|
||||||
|
|
||||||
|
return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Domains/Catalog/routes/adminapp.php
Normal file
15
app/Domains/Catalog/routes/adminapp.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
|
||||||
|
->name('adminapp.featured-groups.index');
|
||||||
|
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
|
||||||
|
->name('adminapp.featured-groups.store');
|
||||||
|
Route::put('featured-groups/{featuredGroup}', [OnTicketFeaturedGroupController::class, 'update'])
|
||||||
|
->name('adminapp.featured-groups.update');
|
||||||
|
});
|
||||||
@@ -14,3 +14,5 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
|||||||
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Event\Models;
|
namespace App\Domains\Event\Models;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -38,4 +39,10 @@ class Event extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(CatalogItem::class);
|
return $this->hasMany(CatalogItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Purchase, $this> */
|
||||||
|
public function purchases(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Purchase::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\SaleFormResource;
|
||||||
|
use App\Domains\Forms\Services\SaleFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class SaleFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected SaleFormService $saleFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(): SaleFormResource
|
||||||
|
{
|
||||||
|
return SaleFormResource::make($this->saleFormService->get());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\StaffFormResource;
|
||||||
|
use App\Domains\Forms\Services\StaffFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class StaffFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected StaffFormService $staffFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): StaffFormResource
|
||||||
|
{
|
||||||
|
return StaffFormResource::make(
|
||||||
|
$this->staffFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Domains/Forms/Resources/SaleFormResource.php
Normal file
17
app/Domains/Forms/Resources/SaleFormResource.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class SaleFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'statuses' => $this->resource['statuses'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Domains/Forms/Resources/StaffFormResource.php
Normal file
20
app/Domains/Forms/Resources/StaffFormResource.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class StaffFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'categories' => $this->resource['categories']->map(fn ($category) => [
|
||||||
|
'id' => $category->id,
|
||||||
|
'nombre' => $category->nombre,
|
||||||
|
])->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Domains/Forms/Services/SaleFormService.php
Normal file
31
app/Domains/Forms/Services/SaleFormService.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
|
||||||
|
class SaleFormService
|
||||||
|
{
|
||||||
|
/** @return array{statuses: list<array{code: string, name: string}>} */
|
||||||
|
public function get(): array
|
||||||
|
{
|
||||||
|
$names = [
|
||||||
|
Purchase::STATUS_CREATED => 'Creada',
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
|
||||||
|
Purchase::STATUS_PAID => 'Confirmada',
|
||||||
|
Purchase::STATUS_CANCELLED => 'Cancelada',
|
||||||
|
Purchase::STATUS_REJECTED => 'Rechazada',
|
||||||
|
Purchase::STATUS_EXPIRED => 'Vencida',
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'statuses' => array_map(
|
||||||
|
fn (string $status): array => [
|
||||||
|
'code' => $status,
|
||||||
|
'name' => $names[$status],
|
||||||
|
],
|
||||||
|
Purchase::statuses(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Domains/Forms/Services/StaffFormService.php
Normal file
27
app/Domains/Forms/Services/StaffFormService.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class StaffFormService
|
||||||
|
{
|
||||||
|
/** @return array{categories: Collection<int, Category>} */
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'categories' => 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
|
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/forms')
|
Route::prefix('v1/adminapp/forms')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('event', EventFormController::class);
|
Route::get('event', EventFormController::class);
|
||||||
|
Route::get('sale', SaleFormController::class);
|
||||||
|
Route::get('staff', StaffFormController::class);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,13 +57,9 @@ class TelepagosWebhookService
|
|||||||
->whereIn('status', [
|
->whereIn('status', [
|
||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
Purchase::STATUS_IN_REVIEW,
|
|
||||||
])
|
])
|
||||||
->where('payment_method', 'transfer')
|
->where('payment_method', 'transfer')
|
||||||
->where('total', $amount)
|
->where('total', $amount)
|
||||||
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [
|
|
||||||
Purchase::STATUS_IN_REVIEW,
|
|
||||||
])
|
|
||||||
->latest()
|
->latest()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -97,7 +93,6 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
if (! in_array($compra->status, [
|
if (! in_array($compra->status, [
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
Purchase::STATUS_IN_REVIEW,
|
|
||||||
], true)) {
|
], true)) {
|
||||||
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use LogicException;
|
|||||||
|
|
||||||
trait LogsValueChanges
|
trait LogsValueChanges
|
||||||
{
|
{
|
||||||
|
abstract protected function valueChangeTenantCode(): string;
|
||||||
|
|
||||||
public static function bootLogsValueChanges(): void
|
public static function bootLogsValueChanges(): void
|
||||||
{
|
{
|
||||||
static::updated(function (Model $model): void {
|
static::updated(function (Model $model): void {
|
||||||
@@ -30,6 +32,7 @@ trait LogsValueChanges
|
|||||||
|
|
||||||
foreach ($changedAttributes as $attribute) {
|
foreach ($changedAttributes as $attribute) {
|
||||||
$model->valueChanges()->create([
|
$model->valueChanges()->create([
|
||||||
|
'tenant_code' => $model->valueChangeTenantCode(),
|
||||||
'attribute' => $attribute,
|
'attribute' => $attribute,
|
||||||
'old_value' => $model->getRawOriginal($attribute),
|
'old_value' => $model->getRawOriginal($attribute),
|
||||||
'new_value' => $model->getAttributes()[$attribute] ?? null,
|
'new_value' => $model->getAttributes()[$attribute] ?? null,
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ namespace App\Domains\Logging\Models;
|
|||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Logging\Enums\ValueChangeActorType;
|
use App\Domains\Logging\Enums\ValueChangeActorType;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
|
'tenant_code',
|
||||||
'trackable_type',
|
'trackable_type',
|
||||||
'trackable_id',
|
'trackable_id',
|
||||||
'attribute',
|
'attribute',
|
||||||
@@ -35,6 +37,12 @@ class ValueChange extends Model
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Tenant, $this> */
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Domains\Tenant\Models\Tenant;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
@@ -124,14 +125,28 @@ class PurchaseController extends Controller
|
|||||||
$purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
|
$purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$updated = Purchase::query()
|
$updated = DB::transaction(function () use ($compra, $purchaseUpdate): bool {
|
||||||
->whereKey($compra->getKey())
|
/** @var Purchase|null $purchase */
|
||||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
$purchase = Purchase::query()
|
||||||
->where(function ($query): void {
|
->whereKey($compra->getKey())
|
||||||
$query->whereNull('expires_at')
|
->lockForUpdate()
|
||||||
->orWhere('expires_at', '>', now());
|
->first();
|
||||||
})
|
|
||||||
->update($purchaseUpdate);
|
if (
|
||||||
|
$purchase === null
|
||||||
|
|| ! in_array($purchase->status, [
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
], true)
|
||||||
|
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->update($purchaseUpdate);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
if ($updated === 0) {
|
if ($updated === 0) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace App\Domains\Purchase\Models;
|
|||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Event\Models\Event;
|
||||||
|
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
@@ -18,6 +20,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
#[Fillable([
|
#[Fillable([
|
||||||
'cart_id',
|
'cart_id',
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
|
'event_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
'status',
|
'status',
|
||||||
'payment_method',
|
'payment_method',
|
||||||
@@ -31,14 +34,12 @@ use Illuminate\Support\Facades\DB;
|
|||||||
])]
|
])]
|
||||||
class Purchase extends Model
|
class Purchase extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory, LogsValueChanges;
|
||||||
|
|
||||||
public const STATUS_CREATED = 'created';
|
public const STATUS_CREATED = 'created';
|
||||||
|
|
||||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||||
|
|
||||||
public const STATUS_IN_REVIEW = 'in_review';
|
|
||||||
|
|
||||||
public const STATUS_PAID = 'paid';
|
public const STATUS_PAID = 'paid';
|
||||||
|
|
||||||
public const STATUS_CANCELLED = 'cancelled';
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
@@ -47,12 +48,31 @@ class Purchase extends Model
|
|||||||
|
|
||||||
public const STATUS_EXPIRED = 'expired';
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function statuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::STATUS_CREATED,
|
||||||
|
self::STATUS_PENDING_PAYMENT,
|
||||||
|
self::STATUS_PAID,
|
||||||
|
self::STATUS_CANCELLED,
|
||||||
|
self::STATUS_REJECTED,
|
||||||
|
self::STATUS_EXPIRED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected $table = 'compras';
|
protected $table = 'compras';
|
||||||
|
|
||||||
|
/** @var array<int, string> */
|
||||||
|
protected array $loggedAttributes = [
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'cart_id' => 'integer',
|
'cart_id' => 'integer',
|
||||||
|
'event_id' => 'integer',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'total' => 'decimal:2',
|
'total' => 'decimal:2',
|
||||||
@@ -67,6 +87,12 @@ class Purchase extends Model
|
|||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Event, $this> */
|
||||||
|
public function event(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Event::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return BelongsTo<User, $this>
|
* @return BelongsTo<User, $this>
|
||||||
*/
|
*/
|
||||||
@@ -133,6 +159,11 @@ class Purchase extends Model
|
|||||||
return (float) $this->items()->sum('total');
|
return (float) $this->items()->sum('total');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function valueChangeTenantCode(): string
|
||||||
|
{
|
||||||
|
return $this->tenant_codigo;
|
||||||
|
}
|
||||||
|
|
||||||
public function markAsPendingPayment(): void
|
public function markAsPendingPayment(): void
|
||||||
{
|
{
|
||||||
$this->update([
|
$this->update([
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class PurchaseResource extends JsonResource
|
|||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'cart_id' => $this->cart_id,
|
'cart_id' => $this->cart_id,
|
||||||
'tenant_codigo' => $this->tenant_codigo,
|
'tenant_codigo' => $this->tenant_codigo,
|
||||||
|
'event_id' => $this->event_id,
|
||||||
'user_id' => $this->user_id,
|
'user_id' => $this->user_id,
|
||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ class CheckoutService
|
|||||||
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
|
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase {
|
return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase {
|
||||||
|
/** @var Tenant $tenant */
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($tenant->getKey());
|
||||||
|
|
||||||
$directItem = $purchaseData['direct_item'] ?? null;
|
$directItem = $purchaseData['direct_item'] ?? null;
|
||||||
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
|
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
|
||||||
unset($purchaseData['direct_item'], $purchaseData['cart_id']);
|
unset($purchaseData['direct_item'], $purchaseData['cart_id']);
|
||||||
@@ -70,7 +75,6 @@ class CheckoutService
|
|||||||
|
|
||||||
if (in_array($purchase->status, [
|
if (in_array($purchase->status, [
|
||||||
Purchase::STATUS_PAID,
|
Purchase::STATUS_PAID,
|
||||||
Purchase::STATUS_IN_REVIEW,
|
|
||||||
Purchase::STATUS_CANCELLED,
|
Purchase::STATUS_CANCELLED,
|
||||||
Purchase::STATUS_REJECTED,
|
Purchase::STATUS_REJECTED,
|
||||||
Purchase::STATUS_EXPIRED,
|
Purchase::STATUS_EXPIRED,
|
||||||
@@ -96,7 +100,6 @@ class CheckoutService
|
|||||||
->findOrFail($purchase->getKey());
|
->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
if (in_array($purchase->status, [
|
if (in_array($purchase->status, [
|
||||||
Purchase::STATUS_IN_REVIEW,
|
|
||||||
Purchase::STATUS_PAID,
|
Purchase::STATUS_PAID,
|
||||||
], true)) {
|
], true)) {
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
@@ -112,7 +115,6 @@ class CheckoutService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$purchase->update([
|
$purchase->update([
|
||||||
'status' => Purchase::STATUS_IN_REVIEW,
|
|
||||||
'expires_at' => null,
|
'expires_at' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -618,6 +620,7 @@ class CheckoutService
|
|||||||
...$purchaseData,
|
...$purchaseData,
|
||||||
'cart_id' => $cartId,
|
'cart_id' => $cartId,
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'event_id' => $tenant->active_event_id,
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'status' => Purchase::STATUS_CREATED,
|
'status' => Purchase::STATUS_CREATED,
|
||||||
'payment_method' => null,
|
'payment_method' => null,
|
||||||
|
|||||||
61
app/Domains/Sale/Controllers/AdminApp/SaleController.php
Normal file
61
app/Domains/Sale/Controllers/AdminApp/SaleController.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
||||||
|
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
|
||||||
|
use App\Domains\Sale\Resources\AdminApp\SaleResource;
|
||||||
|
use App\Domains\Sale\Services\AdminAppSalePdfService;
|
||||||
|
use App\Domains\Sale\Services\AdminAppSaleService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class SaleController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected AdminAppSaleService $saleService,
|
||||||
|
protected AdminAppSalePdfService $salePdfService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return SaleResource::collection(
|
||||||
|
$this->saleService->sales($tenant, $request->validated())
|
||||||
|
)->additional([
|
||||||
|
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function modifications(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return SaleModificationResource::collection(
|
||||||
|
$this->saleService->modifications(
|
||||||
|
$request->user()->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadPdf(AdminAppSaleIndexRequest $request): Response
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return $this->salePdfService->downloadSales(
|
||||||
|
$tenant,
|
||||||
|
$this->saleService->salesForExport($tenant, $request->validated()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadModificationsPdf(Request $request): Response
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return $this->salePdfService->downloadModifications(
|
||||||
|
$tenant,
|
||||||
|
$this->saleService->modificationsForExport($tenant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php
Normal file
30
app/Domains/Sale/Requests/AdminAppSaleIndexRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class AdminAppSaleIndexRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
|
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||||
|
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())],
|
||||||
|
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
|
||||||
|
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
|
||||||
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin ValueChange */
|
||||||
|
class SaleModificationResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
/** @var Purchase|null $sale */
|
||||||
|
$sale = $this->whenLoaded('trackable');
|
||||||
|
$user = $this->whenLoaded('user');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'sale_id' => $this->trackable_id,
|
||||||
|
'attribute' => $this->attribute,
|
||||||
|
'old_value' => $this->old_value,
|
||||||
|
'new_value' => $this->new_value,
|
||||||
|
'date' => $this->changed_at->format('Y-m-d'),
|
||||||
|
'time' => $this->changed_at->format('H:i:s'),
|
||||||
|
'actor_type' => $this->actor_type->value,
|
||||||
|
'sale' => $sale instanceof Purchase ? [
|
||||||
|
'id' => $sale->id,
|
||||||
|
'customer_name' => $sale->nombre_apellido,
|
||||||
|
'status' => $sale->status,
|
||||||
|
] : null,
|
||||||
|
'modified_by' => $user ? [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->nombre_apellido,
|
||||||
|
'email' => $user->email,
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Domains/Sale/Resources/AdminApp/SaleResource.php
Normal file
28
app/Domains/Sale/Resources/AdminApp/SaleResource.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin Purchase */
|
||||||
|
class SaleResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$ticketsCount = (int) ($this->tickets_count ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'customer_name' => $this->nombre_apellido,
|
||||||
|
'quantity' => (int) ($this->quantity ?? 0),
|
||||||
|
'status' => $this->status,
|
||||||
|
'total' => number_format((float) $this->total, 2, '.', ''),
|
||||||
|
'tickets_count' => $ticketsCount,
|
||||||
|
'has_generated_tickets' => $ticketsCount > 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Domains/Sale/Services/AdminAppSalePdfService.php
Normal file
68
app/Domains/Sale/Services/AdminAppSalePdfService.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Services;
|
||||||
|
|
||||||
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Barryvdh\DomPDF\PDF as DomPdf;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppSalePdfService
|
||||||
|
{
|
||||||
|
/** @param Collection<int, Purchase> $sales */
|
||||||
|
public function downloadSales(Tenant $tenant, Collection $sales): Response
|
||||||
|
{
|
||||||
|
$pdf = Pdf::loadView('pdf.adminapp.sales', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'sales' => $sales,
|
||||||
|
'generatedAt' => now(),
|
||||||
|
'confirmedSalesTotal' => number_format(
|
||||||
|
(float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'),
|
||||||
|
2,
|
||||||
|
'.',
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
])->setPaper('a4', 'landscape');
|
||||||
|
|
||||||
|
$this->addPageNumbers($pdf);
|
||||||
|
|
||||||
|
return $pdf->download(
|
||||||
|
'ventas_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<int, ValueChange> $modifications */
|
||||||
|
public function downloadModifications(Tenant $tenant, Collection $modifications): Response
|
||||||
|
{
|
||||||
|
$pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'modifications' => $modifications,
|
||||||
|
'generatedAt' => now(),
|
||||||
|
])->setPaper('a4', 'landscape');
|
||||||
|
|
||||||
|
$this->addPageNumbers($pdf);
|
||||||
|
|
||||||
|
return $pdf->download(
|
||||||
|
'historial_modificaciones_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addPageNumbers(DomPdf $pdf): void
|
||||||
|
{
|
||||||
|
$pdf->render();
|
||||||
|
$domPdf = $pdf->getDomPDF();
|
||||||
|
$font = $domPdf->getFontMetrics()->getFont('DejaVu Sans');
|
||||||
|
|
||||||
|
$domPdf->getCanvas()->page_text(
|
||||||
|
385,
|
||||||
|
575,
|
||||||
|
'Página {PAGE_NUM} de {PAGE_COUNT}',
|
||||||
|
$font,
|
||||||
|
7,
|
||||||
|
[0.48, 0.52, 0.49],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/Domains/Sale/Services/AdminAppSaleService.php
Normal file
120
app/Domains/Sale/Services/AdminAppSaleService.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Services;
|
||||||
|
|
||||||
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppSaleService
|
||||||
|
{
|
||||||
|
public function confirmedSalesTotal(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
$total = Purchase::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('status', Purchase::STATUS_PAID)
|
||||||
|
->sum('total');
|
||||||
|
|
||||||
|
return number_format((float) $total, 2, '.', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* q?: string|null,
|
||||||
|
* id?: int|null,
|
||||||
|
* sale_date?: string|null,
|
||||||
|
* status?: string|null,
|
||||||
|
* sort_by?: string,
|
||||||
|
* sort_direction?: string
|
||||||
|
* } $filters
|
||||||
|
* @return LengthAwarePaginator<Purchase>
|
||||||
|
*/
|
||||||
|
public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return $this->salesQuery($tenant, $filters)
|
||||||
|
->paginateFromRequest()
|
||||||
|
->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Collection<int, Purchase>
|
||||||
|
*/
|
||||||
|
public function salesForExport(Tenant $tenant, array $filters = []): Collection
|
||||||
|
{
|
||||||
|
return $this->salesQuery($tenant, $filters)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return LengthAwarePaginator<ValueChange> */
|
||||||
|
public function modifications(Tenant $tenant): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return $this->modificationsQuery($tenant)
|
||||||
|
->paginateFromRequest()
|
||||||
|
->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, ValueChange> */
|
||||||
|
public function modificationsForExport(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
return $this->modificationsQuery($tenant)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $filters */
|
||||||
|
protected function salesQuery(Tenant $tenant, array $filters): Builder
|
||||||
|
{
|
||||||
|
$sortColumns = [
|
||||||
|
'id' => 'id',
|
||||||
|
'date' => 'created_at',
|
||||||
|
'customer_name' => 'nombre_apellido',
|
||||||
|
'quantity' => 'quantity',
|
||||||
|
'status' => 'status',
|
||||||
|
'total' => 'total',
|
||||||
|
];
|
||||||
|
$requestedSort = $filters['sort_by'] ?? 'date';
|
||||||
|
$sortBy = array_key_exists($requestedSort, $sortColumns) ? $requestedSort : 'date';
|
||||||
|
$requestedDirection = $filters['sort_direction'] ?? 'desc';
|
||||||
|
$sortDirection = in_array($requestedDirection, ['asc', 'desc'], true)
|
||||||
|
? $requestedDirection
|
||||||
|
: 'desc';
|
||||||
|
|
||||||
|
return Purchase::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
||||||
|
$term = trim($search);
|
||||||
|
|
||||||
|
$query->where(function (Builder $query) use ($term): void {
|
||||||
|
$query
|
||||||
|
->where('id', 'like', "%{$term}%")
|
||||||
|
->orWhere('nombre_apellido', 'like', "%{$term}%")
|
||||||
|
->orWhere('created_at', 'like', "%{$term}%");
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->when($filters['id'] ?? null, fn (Builder $query, int $id): Builder => $query->whereKey($id))
|
||||||
|
->when(
|
||||||
|
$filters['sale_date'] ?? null,
|
||||||
|
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', $date)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$filters['status'] ?? null,
|
||||||
|
fn (Builder $query, string $status): Builder => $query->where('status', $status)
|
||||||
|
)
|
||||||
|
->withSum('items as quantity', 'cantidad')
|
||||||
|
->withCount('tickets')
|
||||||
|
->orderBy($sortColumns[$sortBy], $sortDirection)
|
||||||
|
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Builder<ValueChange> */
|
||||||
|
protected function modificationsQuery(Tenant $tenant): Builder
|
||||||
|
{
|
||||||
|
return ValueChange::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('trackable_type', (new Purchase)->getMorphClass())
|
||||||
|
->with(['trackable', 'user'])
|
||||||
|
->orderByDesc('changed_at')
|
||||||
|
->orderByDesc('id');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Domains/Sale/routes/adminapp.php
Normal file
13
app/Domains/Sale/routes/adminapp.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Sale\Controllers\AdminApp\SaleController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('sales', [SaleController::class, 'index']);
|
||||||
|
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
|
||||||
|
Route::get('sales/modifications', [SaleController::class, 'modifications']);
|
||||||
|
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
|
||||||
|
});
|
||||||
3
app/Domains/Sale/routes/api.php
Normal file
3
app/Domains/Sale/routes/api.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
49
app/Domains/Staff/Controllers/AdminAppStaffController.php
Normal file
49
app/Domains/Staff/Controllers/AdminAppStaffController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Staff/Requests/StoreStaffRequest.php
Normal file
26
app/Domains/Staff/Requests/StoreStaffRequest.php
Normal 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')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Domains/Staff/Requests/UpdateStaffRequest.php
Normal file
33
app/Domains/Staff/Requests/UpdateStaffRequest.php
Normal 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')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Domains/Staff/Resources/StaffResource.php
Normal file
32
app/Domains/Staff/Resources/StaffResource.php
Normal 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()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/Domains/Staff/Services/StaffService.php
Normal file
113
app/Domains/Staff/Services/StaffService.php
Normal 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.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Domains/Staff/routes/api.php
Normal file
10
app/Domains/Staff/routes/api.php
Normal 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');
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
'starts_at',
|
'starts_at',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
'used_at',
|
'used_at',
|
||||||
|
'scanner_user_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
])]
|
])]
|
||||||
class Ticket extends Model
|
class Ticket extends Model
|
||||||
@@ -47,6 +48,7 @@ class Ticket extends Model
|
|||||||
'starts_at' => 'datetime',
|
'starts_at' => 'datetime',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'used_at' => 'datetime',
|
'used_at' => 'datetime',
|
||||||
|
'scanner_user_id' => 'integer',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,12 @@ class Ticket extends Model
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function scannerUser(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'scanner_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Purchase, $this> */
|
/** @return BelongsTo<Purchase, $this> */
|
||||||
public function sourcePurchase(): BelongsTo
|
public function sourcePurchase(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class TicketResource extends JsonResource
|
|||||||
'starts_at' => $this->getEffectiveStartsAt(),
|
'starts_at' => $this->getEffectiveStartsAt(),
|
||||||
'expires_at' => $this->getEffectiveExpiresAt(),
|
'expires_at' => $this->getEffectiveExpiresAt(),
|
||||||
'used_at' => $this->used_at,
|
'used_at' => $this->used_at,
|
||||||
|
'scanner_user_id' => $this->scanner_user_id,
|
||||||
'is_valid' => $this->is_valid,
|
'is_valid' => $this->is_valid,
|
||||||
'is_expired' => $this->is_expired,
|
'is_expired' => $this->is_expired,
|
||||||
'is_used' => $this->is_used,
|
'is_used' => $this->is_used,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('compras')
|
||||||
|
->where('status', 'in_review')
|
||||||
|
->update(['status' => 'pending_payment']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// This cleanup cannot be reversed without changing legitimate pending purchases.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('value_changes', function (Blueprint $table): void {
|
||||||
|
$table->string('tenant_code')->nullable()->after('id');
|
||||||
|
$table->foreign('tenant_code')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->index(['tenant_code', 'changed_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('value_changes')
|
||||||
|
->where('trackable_type', (new Purchase)->getMorphClass())
|
||||||
|
->whereNull('tenant_code')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(500, function ($changes): void {
|
||||||
|
foreach ($changes as $change) {
|
||||||
|
$tenantCode = DB::table('compras')
|
||||||
|
->where('id', $change->trackable_id)
|
||||||
|
->value('tenant_codigo');
|
||||||
|
|
||||||
|
if ($tenantCode !== null) {
|
||||||
|
DB::table('value_changes')
|
||||||
|
->where('id', $change->id)
|
||||||
|
->update(['tenant_code' => $tenantCode]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('value_changes', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign(['tenant_code']);
|
||||||
|
$table->dropIndex(['tenant_code', 'changed_at']);
|
||||||
|
$table->dropColumn('tenant_code');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?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('compras', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('event_id')
|
||||||
|
->nullable()
|
||||||
|
->after('tenant_codigo')
|
||||||
|
->constrained('events')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('event_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
|
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('featured_groups', function (Blueprint $table): void {
|
||||||
|
$table->enum('source_type', FeaturedGroupSource::values())
|
||||||
|
->default(FeaturedGroupSource::Manual->value)
|
||||||
|
->after('tenant_code');
|
||||||
|
$table->foreignId('category_id')
|
||||||
|
->nullable()
|
||||||
|
->after('source_type')
|
||||||
|
->constrained('categorias')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('featured_groups', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('category_id');
|
||||||
|
$table->dropColumn('source_type');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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::create('category_scanners', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnUpdate()->cascadeOnDelete();
|
||||||
|
$table->foreignId('categoria_id')->constrained('categorias')->cascadeOnUpdate()->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'categoria_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('category_scanners');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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('tickets', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('scanner_user_id')
|
||||||
|
->nullable()
|
||||||
|
->after('used_at')
|
||||||
|
->constrained('users')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('scanner_user_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->string('reason')->default('manual')->after('codigo');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('reason');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -81,6 +81,10 @@ class AuthorizationSeeder extends Seeder
|
|||||||
'nombre' => 'Gestionar tickets',
|
'nombre' => 'Gestionar tickets',
|
||||||
'descripcion' => 'Permite emitir, invalidar o regenerar tickets.',
|
'descripcion' => 'Permite emitir, invalidar o regenerar tickets.',
|
||||||
],
|
],
|
||||||
|
'tickets.escanear' => [
|
||||||
|
'nombre' => 'Escanear tickets',
|
||||||
|
'descripcion' => 'Permite validar y consumir tickets de las categorías asignadas al usuario.',
|
||||||
|
],
|
||||||
'contenido.gestionar' => [
|
'contenido.gestionar' => [
|
||||||
'nombre' => 'Gestionar contenido',
|
'nombre' => 'Gestionar contenido',
|
||||||
'descripcion' => 'Permite administrar menús, carruseles, destacados y redes sociales.',
|
'descripcion' => 'Permite administrar menús, carruseles, destacados y redes sociales.',
|
||||||
@@ -117,6 +121,11 @@ class AuthorizationSeeder extends Seeder
|
|||||||
'descripcion' => 'Accede a los menús administrativos de la aplicación.',
|
'descripcion' => 'Accede a los menús administrativos de la aplicación.',
|
||||||
'permisos' => [],
|
'permisos' => [],
|
||||||
],
|
],
|
||||||
|
RoleCode::Scanner->value => [
|
||||||
|
'nombre' => 'Scanner',
|
||||||
|
'descripcion' => 'Valida y consume tickets de las categorías que tiene asignadas.',
|
||||||
|
'permisos' => ['tickets.escanear'],
|
||||||
|
],
|
||||||
RoleCode::User->value => [
|
RoleCode::User->value => [
|
||||||
'nombre' => 'Usuario',
|
'nombre' => 'Usuario',
|
||||||
'descripcion' => 'Cliente final limitado a sus propios datos y operaciones.',
|
'descripcion' => 'Cliente final limitado a sus propios datos y operaciones.',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Database\Seeders;
|
|||||||
|
|
||||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
use App\Domains\Catalog\Enums\EventProductType;
|
use App\Domains\Catalog\Enums\EventProductType;
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
@@ -61,6 +62,18 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
'nombre' => 'Gastronomía',
|
'nombre' => 'Gastronomía',
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
]);
|
]);
|
||||||
|
$mealCategory = Category::query()->updateOrCreate([
|
||||||
|
'nombre' => 'Comidas',
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
], [
|
||||||
|
'categoria_id' => $foodCategory->id,
|
||||||
|
]);
|
||||||
|
$drinkCategory = Category::query()->updateOrCreate([
|
||||||
|
'nombre' => 'Bebidas',
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
], [
|
||||||
|
'categoria_id' => $foodCategory->id,
|
||||||
|
]);
|
||||||
$parkingCategory = Category::query()->firstOrCreate([
|
$parkingCategory = Category::query()->firstOrCreate([
|
||||||
'nombre' => 'Estacionamiento',
|
'nombre' => 'Estacionamiento',
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
@@ -93,10 +106,10 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$items = [
|
$items = [
|
||||||
['slug' => 'hamburguesa-papa-frita', 'nombre' => 'Hamburguesa con papa frita', 'precio' => 8000, 'category_id' => $foodCategory->id],
|
['slug' => 'hamburguesa-papa-frita', 'nombre' => 'Hamburguesa con papa frita', 'precio' => 8000, 'category_id' => $mealCategory->id],
|
||||||
['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $foodCategory->id],
|
['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $mealCategory->id],
|
||||||
['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'category_id' => $foodCategory->id],
|
['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'category_id' => $drinkCategory->id],
|
||||||
['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'category_id' => $foodCategory->id],
|
['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'category_id' => $drinkCategory->id],
|
||||||
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $parkingCategory->id],
|
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $parkingCategory->id],
|
||||||
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $parkingCategory->id],
|
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $parkingCategory->id],
|
||||||
];
|
];
|
||||||
@@ -144,7 +157,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
'nombre' => 'Combo 2 Panchos + 2 Hamburguesas',
|
'nombre' => 'Combo 2 Panchos + 2 Hamburguesas',
|
||||||
'descripcion' => 'Incluye 2 panchos y 2 hamburguesas con papa frita.',
|
'descripcion' => 'Incluye 2 panchos y 2 hamburguesas con papa frita.',
|
||||||
'precio' => 24000,
|
'precio' => 24000,
|
||||||
'category_id' => $foodCategory->id,
|
'category_id' => $mealCategory->id,
|
||||||
'components' => [
|
'components' => [
|
||||||
[
|
[
|
||||||
'catalog_item_id' => $createdItems['pancho']->id,
|
'catalog_item_id' => $createdItems['pancho']->id,
|
||||||
@@ -157,7 +170,12 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->seedFeaturedGroups($tenant);
|
$this->seedFeaturedGroups($tenant, [
|
||||||
|
'Entradas' => $ticketCategory,
|
||||||
|
'Estacionamiento' => $parkingCategory,
|
||||||
|
'Comidas' => $mealCategory,
|
||||||
|
'Bebidas' => $drinkCategory,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function deleteExistingCatalog(Tenant $tenant): void
|
private function deleteExistingCatalog(Tenant $tenant): void
|
||||||
@@ -173,7 +191,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
->each(fn (CatalogItem $item) => $this->catalogService->delete($item));
|
->each(fn (CatalogItem $item) => $this->catalogService->delete($item));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedFeaturedGroups(Tenant $tenant): void
|
/** @param array<string, Category> $categories */
|
||||||
|
private function seedFeaturedGroups(Tenant $tenant, array $categories): void
|
||||||
{
|
{
|
||||||
FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete();
|
FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete();
|
||||||
|
|
||||||
@@ -181,62 +200,33 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
'Entradas' => [
|
'Entradas' => [
|
||||||
'product_layout' => ProductLayout::Row,
|
'product_layout' => ProductLayout::Row,
|
||||||
'group_layout' => GroupLayout::SimpleVertical,
|
'group_layout' => GroupLayout::SimpleVertical,
|
||||||
'slugs' => [
|
|
||||||
'entrada-general',
|
|
||||||
'entrada-general-todos-los-dias',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
'Estacionamiento' => [
|
'Estacionamiento' => [
|
||||||
'product_layout' => ProductLayout::ColumnWithCart,
|
'product_layout' => ProductLayout::ColumnWithCart,
|
||||||
'group_layout' => GroupLayout::Simple,
|
'group_layout' => GroupLayout::Simple,
|
||||||
'slugs' => [
|
|
||||||
'estacionamiento-auto',
|
|
||||||
'estacionamiento-moto',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
'Comidas' => [
|
'Comidas' => [
|
||||||
'product_layout' => ProductLayout::ColumnWithCart,
|
'product_layout' => ProductLayout::ColumnWithCart,
|
||||||
'group_layout' => GroupLayout::Simple,
|
'group_layout' => GroupLayout::Simple,
|
||||||
'slugs' => [
|
|
||||||
'hamburguesa-papa-frita',
|
|
||||||
'pancho',
|
|
||||||
'combo-2-panchos-2-hamburguesas',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
'Bebidas' => [
|
'Bebidas' => [
|
||||||
'product_layout' => ProductLayout::ColumnWithCart,
|
'product_layout' => ProductLayout::ColumnWithCart,
|
||||||
'group_layout' => GroupLayout::Simple,
|
'group_layout' => GroupLayout::Simple,
|
||||||
'slugs' => [
|
|
||||||
'coca-cola-500ml',
|
|
||||||
'agua-mineral-1l',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$catalogItems = CatalogItem::query()
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->whereIn('slug', collect($groups)->pluck('slugs')->flatten()->all())
|
|
||||||
->get()
|
|
||||||
->keyBy('slug');
|
|
||||||
|
|
||||||
$groupOrder = 0;
|
$groupOrder = 0;
|
||||||
foreach ($groups as $groupName => $config) {
|
foreach ($groups as $groupName => $config) {
|
||||||
$featuredGroup = FeaturedGroup::query()->create([
|
FeaturedGroup::query()->create([
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::Category,
|
||||||
|
'category_id' => $categories[$groupName]->id,
|
||||||
'product_layout' => $config['product_layout'],
|
'product_layout' => $config['product_layout'],
|
||||||
'group_layout' => $config['group_layout'],
|
'group_layout' => $config['group_layout'],
|
||||||
'group_name' => $groupName,
|
'group_name' => $groupName,
|
||||||
'group_order' => $groupOrder++,
|
'group_order' => $groupOrder++,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$featuredGroup->featuredItems()->createMany(
|
|
||||||
collect($config['slugs'])->values()->map(
|
|
||||||
fn (string $slug, int $order): array => [
|
|
||||||
'catalog_item_id' => $catalogItems->get($slug)->id,
|
|
||||||
'order' => $order,
|
|
||||||
]
|
|
||||||
)->all()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
@@ -168,30 +169,18 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete();
|
FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete();
|
||||||
|
|
||||||
$paginatedGroup = FeaturedGroup::query()->create([
|
FeaturedGroup::query()->create([
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::All,
|
||||||
'product_layout' => ProductLayout::ColumnWithImage,
|
'product_layout' => ProductLayout::ColumnWithImage,
|
||||||
'group_layout' => GroupLayout::Paginated,
|
'group_layout' => GroupLayout::Paginated,
|
||||||
'group_name' => 'Productos',
|
'group_name' => 'Productos',
|
||||||
'group_order' => 2,
|
'group_order' => 2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$items = CatalogItem::query()
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->orderBy('id')
|
|
||||||
->get('id');
|
|
||||||
|
|
||||||
$paginatedGroup->featuredItems()->createMany(
|
|
||||||
$items->values()->map(
|
|
||||||
fn (CatalogItem $item, int $order): array => [
|
|
||||||
'catalog_item_id' => $item->id,
|
|
||||||
'order' => $order,
|
|
||||||
]
|
|
||||||
)->all()
|
|
||||||
);
|
|
||||||
|
|
||||||
$carouselGroup = FeaturedGroup::query()->create([
|
$carouselGroup = FeaturedGroup::query()->create([
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::Manual,
|
||||||
'product_layout' => ProductLayout::ColumnWithImage,
|
'product_layout' => ProductLayout::ColumnWithImage,
|
||||||
'group_layout' => GroupLayout::Carousel,
|
'group_layout' => GroupLayout::Carousel,
|
||||||
'group_name' => 'Productos destacados',
|
'group_name' => 'Productos destacados',
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
Recuperá tu contraseña
|
Recuperá tu contraseña
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
@if($attempt->reason === 'account_locked')
|
||||||
|
<p>
|
||||||
|
Hola {{ $attempt->user->nombre_apellido }}, registramos varios intentos fallidos de inicio de sesión en tu cuenta. Por seguridad, hemos bloqueado el acceso temporalmente. Puedes utilizar este código para cambiar tu contraseña y desbloquearla inmediatamente.
|
||||||
|
</p>
|
||||||
|
@else
|
||||||
<p>
|
<p>
|
||||||
Hola {{ $attempt->user->nombre_apellido }}, recibimos una solicitud para restablecer
|
Hola {{ $attempt->user->nombre_apellido }}, recibimos una solicitud para restablecer
|
||||||
la contraseña de tu cuenta.
|
la contraseña de tu cuenta.
|
||||||
</p>
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
|
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
|
||||||
|
|
||||||
@@ -15,6 +21,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$recoveryUrl = 'https://' . $tenant->dominio . '/recuperar-contrasena/codigo?email=' . urlencode($attempt->user->email);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-bottom: 28px;">
|
||||||
|
<a href="{{ $recoveryUrl }}"
|
||||||
|
style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||||
|
Ingresar código ahora
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p style="color: #64748b; font-size: 14px;">
|
<p style="color: #64748b; font-size: 14px;">
|
||||||
|
@if($attempt->reason === 'account_locked')
|
||||||
|
Si no fuiste vos, por favor desestimá y borrá este correo. Tu cuenta seguirá protegida.
|
||||||
|
@else
|
||||||
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
|
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
|
||||||
|
@endif
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
63
resources/views/pdf/adminapp/sale-modifications.blade.php
Normal file
63
resources/views/pdf/adminapp/sale-modifications.blade.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<style>
|
||||||
|
@page { margin: 28px 30px 58px; }
|
||||||
|
body { color: #17211b; font-family: DejaVu Sans, sans-serif; font-size: 8px; margin: 0; }
|
||||||
|
h1 { font-size: 21px; margin: 0 0 3px; }
|
||||||
|
.subtitle { color: #66736b; margin: 0 0 18px; }
|
||||||
|
table { border-collapse: collapse; table-layout: fixed; width: 100%; }
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { page-break-inside: avoid; }
|
||||||
|
th { background: #26382e; color: #fff; font-size: 7px; letter-spacing: .35px; padding: 7px 5px; text-align: left; text-transform: uppercase; }
|
||||||
|
td { border-bottom: 1px solid #dfe7e2; overflow-wrap: break-word; padding: 7px 5px; vertical-align: top; }
|
||||||
|
tbody tr:nth-child(even) { background: #f7f9f8; }
|
||||||
|
.date { width: 10%; }
|
||||||
|
.time { width: 7%; }
|
||||||
|
.sale { width: 8%; }
|
||||||
|
.customer { width: 17%; }
|
||||||
|
.attribute { width: 10%; }
|
||||||
|
.value { width: 16%; }
|
||||||
|
.actor { width: 16%; }
|
||||||
|
.empty { color: #66736b; padding: 24px; text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Historial de modificaciones de ventas</h1>
|
||||||
|
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="date">Fecha</th>
|
||||||
|
<th class="time">Hora</th>
|
||||||
|
<th class="sale">Venta</th>
|
||||||
|
<th class="customer">Cliente</th>
|
||||||
|
<th class="attribute">Campo</th>
|
||||||
|
<th class="value">Valor anterior</th>
|
||||||
|
<th class="value">Valor nuevo</th>
|
||||||
|
<th class="actor">Modificado por</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse ($modifications as $modification)
|
||||||
|
@php($sale = $modification->trackable)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $modification->changed_at->format('d/m/Y') }}</td>
|
||||||
|
<td>{{ $modification->changed_at->format('H:i:s') }}</td>
|
||||||
|
<td>#{{ $modification->trackable_id }}</td>
|
||||||
|
<td>{{ $sale?->nombre_apellido ?: 'Sin nombre' }}</td>
|
||||||
|
<td>{{ $modification->attribute }}</td>
|
||||||
|
<td>{{ $modification->old_value ?? '-' }}</td>
|
||||||
|
<td>{{ $modification->new_value ?? '-' }}</td>
|
||||||
|
<td>{{ $modification->user?->nombre_apellido ?? 'Sistema' }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td class="empty" colspan="8">Todavía no hay modificaciones registradas.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
resources/views/pdf/adminapp/sales.blade.php
Normal file
65
resources/views/pdf/adminapp/sales.blade.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<style>
|
||||||
|
@page { margin: 28px 34px 58px; }
|
||||||
|
body { color: #17211b; font-family: DejaVu Sans, sans-serif; font-size: 9px; margin: 0; }
|
||||||
|
h1 { font-size: 21px; margin: 0 0 3px; }
|
||||||
|
.subtitle { color: #66736b; margin: 0 0 18px; }
|
||||||
|
.summary { background: #eef5f1; border-left: 4px solid #198754; margin-bottom: 16px; padding: 9px 12px; }
|
||||||
|
.summary strong { font-size: 14px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
thead { display: table-header-group; }
|
||||||
|
tr { page-break-inside: avoid; }
|
||||||
|
th { background: #26382e; color: #fff; font-size: 8px; letter-spacing: .4px; padding: 7px 6px; text-align: left; text-transform: uppercase; }
|
||||||
|
td { border-bottom: 1px solid #dfe7e2; padding: 7px 6px; vertical-align: top; }
|
||||||
|
tbody tr:nth-child(even) { background: #f7f9f8; }
|
||||||
|
.number { text-align: right; }
|
||||||
|
.center { text-align: center; }
|
||||||
|
.empty { color: #66736b; padding: 24px; text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Historial de ventas</h1>
|
||||||
|
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
Total de ventas confirmadas en este reporte: <strong>${{ number_format((float) $confirmedSalesTotal, 2, ',', '.') }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Cliente</th>
|
||||||
|
<th class="center">Cantidad</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th class="number">Importe</th>
|
||||||
|
<th class="center">Tickets</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse ($sales as $sale)
|
||||||
|
<tr>
|
||||||
|
<td>#{{ $sale->id }}</td>
|
||||||
|
<td>{{ $sale->created_at?->format('d/m/Y H:i') ?? '-' }}</td>
|
||||||
|
<td>{{ $sale->nombre_apellido ?: 'Sin nombre' }}</td>
|
||||||
|
<td class="center">{{ (int) ($sale->quantity ?? 0) }}</td>
|
||||||
|
<td>{{ match ($sale->status) {
|
||||||
|
'paid' => 'Confirmado',
|
||||||
|
'created', 'pending_payment' => 'Esperando pago',
|
||||||
|
default => 'Anulado',
|
||||||
|
} }}</td>
|
||||||
|
<td class="number">${{ number_format((float) $sale->total, 2, ',', '.') }}</td>
|
||||||
|
<td class="center">{{ (int) ($sale->tickets_count ?? 0) }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td class="empty" colspan="7">No hay ventas para los criterios seleccionados.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,6 +6,7 @@ require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
|||||||
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
|
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/MailTest/routes/api.php';
|
require __DIR__.'/../app/Domains/MailTest/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
||||||
|
require __DIR__.'/../app/Domains/Sale/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Integration/routes/api.php';
|
require __DIR__.'/../app/Domains/Integration/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Menu/routes/api.php';
|
require __DIR__.'/../app/Domains/Menu/routes/api.php';
|
||||||
@@ -13,3 +14,4 @@ require __DIR__.'/../app/Domains/Ticket/routes/api.php';
|
|||||||
require __DIR__.'/../app/Domains/Event/routes/api.php';
|
require __DIR__.'/../app/Domains/Event/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Bootstrap/routes/api.php';
|
require __DIR__.'/../app/Domains/Bootstrap/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Forms/routes/api.php';
|
require __DIR__.'/../app/Domains/Forms/routes/api.php';
|
||||||
|
require __DIR__.'/../app/Domains/Staff/routes/api.php';
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ namespace Tests\Feature\Catalog;
|
|||||||
|
|
||||||
use App\Domains\Attachable\Enums\AttachmentType;
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -198,6 +200,46 @@ class CatalogControllerTest extends TestCase
|
|||||||
->assertJsonPath('0.nombre', 'carousel Item 1');
|
->assertJsonPath('0.nombre', 'carousel Item 1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_groups_can_source_items_from_a_category_or_the_entire_catalog(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('catalog-sources');
|
||||||
|
$category = Category::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'nombre' => 'Food',
|
||||||
|
]);
|
||||||
|
|
||||||
|
FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::Category,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'product_layout' => ProductLayout::Row,
|
||||||
|
'group_layout' => GroupLayout::Simple,
|
||||||
|
'group_name' => 'Food',
|
||||||
|
'group_order' => 0,
|
||||||
|
]);
|
||||||
|
FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::All,
|
||||||
|
'product_layout' => ProductLayout::Row,
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
'group_name' => 'All products',
|
||||||
|
'group_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$food = $this->createItem($tenant, 'Hamburger');
|
||||||
|
$food->category()->associate($category)->save();
|
||||||
|
$this->createItem($tenant, 'Parking');
|
||||||
|
|
||||||
|
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('0.title', 'Food')
|
||||||
|
->assertJsonCount(1, '0.items')
|
||||||
|
->assertJsonPath('0.items.0.nombre', 'Hamburger')
|
||||||
|
->assertJsonPath('1.title', 'All products')
|
||||||
|
->assertJsonCount(2, '1.items.data')
|
||||||
|
->assertJsonPath('1.items.meta.total', 2);
|
||||||
|
}
|
||||||
|
|
||||||
private function createGroup(
|
private function createGroup(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
ProductLayout $layout,
|
ProductLayout $layout,
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ class CatalogSchemaTest extends TestCase
|
|||||||
$this->assertEqualsCanonicalizing([
|
$this->assertEqualsCanonicalizing([
|
||||||
'id',
|
'id',
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
|
'source_type',
|
||||||
|
'category_id',
|
||||||
'product_layout',
|
'product_layout',
|
||||||
'group_layout',
|
'group_layout',
|
||||||
'group_name',
|
'group_name',
|
||||||
|
|||||||
262
tests/Feature/Catalog/OnTicketFeaturedGroupControllerTest.php
Normal file
262
tests/Feature/Catalog/OnTicketFeaturedGroupControllerTest.php
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Catalog;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class OnTicketFeaturedGroupControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(AuthorizationSeeder::class);
|
||||||
|
WebsiteType::query()->create([
|
||||||
|
'codigo' => 'onticket',
|
||||||
|
'nombre' => 'OnTicket',
|
||||||
|
]);
|
||||||
|
WebsiteType::query()->create([
|
||||||
|
'codigo' => 'shopit',
|
||||||
|
'nombre' => 'Shopit',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authentication_is_required(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/featured-groups')->assertUnauthorized();
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/featured-groups', $this->payload())
|
||||||
|
->assertUnauthorized();
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/featured-groups/1', $this->payload())
|
||||||
|
->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_index_returns_only_category_groups_for_the_onticket_tenant(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$otherTenant = $this->createTenant('other');
|
||||||
|
$first = $this->createCategoryGroup($tenant, 'Food', order: 2);
|
||||||
|
$second = $this->createCategoryGroup($tenant, 'Tickets', order: 1);
|
||||||
|
$this->createCategoryGroup($otherTenant, 'Other tenant');
|
||||||
|
FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::All,
|
||||||
|
'product_layout' => ProductLayout::Row,
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
'group_name' => 'All products',
|
||||||
|
'group_order' => 0,
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/featured-groups')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(2, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $second->id)
|
||||||
|
->assertJsonPath('data.0.category_name', 'Tickets')
|
||||||
|
->assertJsonPath('data.1.id', $first->id)
|
||||||
|
->assertJsonMissing(['category_name' => 'Other tenant'])
|
||||||
|
->assertJsonMissing(['group_name' => 'All products']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_creates_a_category_and_a_featured_horizontal_group(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/adminapp/tenant/featured-groups', [
|
||||||
|
'category_name' => 'Food',
|
||||||
|
'is_featured' => true,
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.category_name', 'Food')
|
||||||
|
->assertJsonPath('data.group_name', 'Food')
|
||||||
|
->assertJsonPath('data.is_featured', true)
|
||||||
|
->assertJsonPath('data.type', 'category')
|
||||||
|
->assertJsonPath('data.product_layout', 'row')
|
||||||
|
->assertJsonPath('data.group_layout', 'paginated');
|
||||||
|
|
||||||
|
$categoryId = $response->json('data.category_id');
|
||||||
|
$this->assertDatabaseHas('categorias', [
|
||||||
|
'id' => $categoryId,
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'nombre' => 'Food',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('featured_groups', [
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => 'category',
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
'product_layout' => 'row',
|
||||||
|
'group_layout' => 'paginated',
|
||||||
|
'group_name' => 'Food',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_uses_column_with_cart_when_the_category_is_not_featured(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/featured-groups', [
|
||||||
|
'category_name' => 'Parking',
|
||||||
|
'is_featured' => false,
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.is_featured', false)
|
||||||
|
->assertJsonPath('data.product_layout', 'column_with_cart')
|
||||||
|
->assertJsonPath('data.group_layout', 'paginated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_changes_the_category_and_group_together(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$group = $this->createCategoryGroup($tenant, 'Old name', ProductLayout::ColumnWithCart);
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||||
|
|
||||||
|
$this->putJson("/api/v1/adminapp/tenant/featured-groups/{$group->id}", [
|
||||||
|
'category_name' => 'New name',
|
||||||
|
'is_featured' => true,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.category_name', 'New name')
|
||||||
|
->assertJsonPath('data.group_name', 'New name')
|
||||||
|
->assertJsonPath('data.is_featured', true)
|
||||||
|
->assertJsonPath('data.product_layout', 'row')
|
||||||
|
->assertJsonPath('data.group_layout', 'paginated');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('categorias', [
|
||||||
|
'id' => $group->category_id,
|
||||||
|
'nombre' => 'New name',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('featured_groups', [
|
||||||
|
'id' => $group->id,
|
||||||
|
'group_name' => 'New name',
|
||||||
|
'product_layout' => 'row',
|
||||||
|
'group_layout' => 'paginated',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_rejects_groups_from_another_tenant_or_source(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$otherTenant = $this->createTenant('other');
|
||||||
|
$otherGroup = $this->createCategoryGroup($otherTenant, 'Other');
|
||||||
|
$allGroup = FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::All,
|
||||||
|
'product_layout' => ProductLayout::Row,
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
'group_name' => 'All products',
|
||||||
|
'group_order' => 0,
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||||
|
|
||||||
|
$this->putJson(
|
||||||
|
"/api/v1/adminapp/tenant/featured-groups/{$otherGroup->id}",
|
||||||
|
$this->payload(),
|
||||||
|
)->assertNotFound();
|
||||||
|
$this->putJson(
|
||||||
|
"/api/v1/adminapp/tenant/featured-groups/{$allGroup->id}",
|
||||||
|
$this->payload(),
|
||||||
|
)->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_name_and_featured_flag_are_required(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/featured-groups', [
|
||||||
|
'category_name' => '',
|
||||||
|
'is_featured' => 'yes',
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['category_name', 'is_featured']);
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('categorias', 0);
|
||||||
|
$this->assertDatabaseCount('featured_groups', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_controller_is_not_available_for_non_onticket_tenants(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('store', 'shopit');
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/featured-groups')->assertNotFound();
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/featured-groups', $this->payload())
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_customer_cannot_manage_onticket_featured_groups(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
'tenant_codigo' => null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/featured-groups')->assertForbidden();
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/featured-groups', $this->payload())
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{category_name: string, is_featured: bool} */
|
||||||
|
private function payload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category_name' => 'Food',
|
||||||
|
'is_featured' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTenant(string $code, string $websiteType = 'onticket'): Tenant
|
||||||
|
{
|
||||||
|
return Tenant::query()->create([
|
||||||
|
'codigo' => $code,
|
||||||
|
'nombre' => ucfirst($code),
|
||||||
|
'dominio' => "{$code}.test",
|
||||||
|
'website_type_code' => $websiteType,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAdminAppUser(Tenant $tenant): User
|
||||||
|
{
|
||||||
|
return User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createCategoryGroup(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $name,
|
||||||
|
ProductLayout $productLayout = ProductLayout::Row,
|
||||||
|
int $order = 0,
|
||||||
|
): FeaturedGroup {
|
||||||
|
$category = Category::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'nombre' => $name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::Category,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'product_layout' => $productLayout,
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
'group_name' => $name,
|
||||||
|
'group_order' => $order,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
tests/Feature/Forms/AdminAppSaleFormControllerTest.php
Normal file
67
tests/Feature/Forms/AdminAppSaleFormControllerTest.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Forms;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAppSaleFormControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(AuthorizationSeeder::class);
|
||||||
|
WebsiteType::query()->create([
|
||||||
|
'codigo' => 'onticket',
|
||||||
|
'nombre' => 'OnTicket',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authentication_is_required(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/sale')->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_adminapp_user_can_get_the_sale_form(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.test',
|
||||||
|
'website_type_code' => 'onticket',
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/sale')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(count(Purchase::statuses()), 'data.statuses')
|
||||||
|
->assertJsonPath('data.statuses.0.code', Purchase::STATUS_CREATED)
|
||||||
|
->assertJsonPath('data.statuses.0.name', 'Creada')
|
||||||
|
->assertJsonPath('data.statuses.1.code', Purchase::STATUS_PENDING_PAYMENT)
|
||||||
|
->assertJsonPath('data.statuses.2.code', Purchase::STATUS_PAID)
|
||||||
|
->assertJsonPath('data.statuses.5.code', Purchase::STATUS_EXPIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_customer_cannot_get_the_sale_form(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
'tenant_codigo' => null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/sale')->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
84
tests/Feature/Forms/AdminAppStaffFormControllerTest.php
Normal file
84
tests/Feature/Forms/AdminAppStaffFormControllerTest.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Forms;
|
||||||
|
|
||||||
|
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 App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAppStaffFormControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(AuthorizationSeeder::class);
|
||||||
|
WebsiteType::query()->create([
|
||||||
|
'codigo' => 'onticket',
|
||||||
|
'nombre' => 'OnTicket',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authentication_is_required(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/staff')->assertUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_adminapp_user_gets_only_its_tenant_categories(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$otherTenant = $this->createTenant('other');
|
||||||
|
$category = Category::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'nombre' => 'Bebidas',
|
||||||
|
]);
|
||||||
|
Category::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'categoria_id' => $category->id,
|
||||||
|
'nombre' => 'Gaseosas',
|
||||||
|
]);
|
||||||
|
Category::query()->create([
|
||||||
|
'tenant_code' => $otherTenant->codigo,
|
||||||
|
'nombre' => 'Privada',
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/staff')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data.categories')
|
||||||
|
->assertJsonPath('data.categories.0.id', $category->id)
|
||||||
|
->assertJsonPath('data.categories.0.nombre', 'Bebidas')
|
||||||
|
->assertJsonMissingPath('data.categories.0.categoria_id')
|
||||||
|
->assertJsonMissingPath('data.roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_customer_cannot_get_staff_form(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/forms/staff')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTenant(string $code): Tenant
|
||||||
|
{
|
||||||
|
return Tenant::query()->create([
|
||||||
|
'codigo' => $code,
|
||||||
|
'nombre' => ucfirst($code),
|
||||||
|
'dominio' => "{$code}.test",
|
||||||
|
'website_type_code' => 'onticket',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,7 +100,7 @@ class TelepagosWebhookTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_transfer_webhook_matches_purchase_in_review_by_dni_and_total_amount(): void
|
public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$this->configureTelepagosIntegration($tenant);
|
$this->configureTelepagosIntegration($tenant);
|
||||||
@@ -122,8 +122,6 @@ class TelepagosWebhookTest extends TestCase
|
|||||||
'12345678'
|
'12345678'
|
||||||
);
|
);
|
||||||
|
|
||||||
$matchingPurchase->update(['status' => Purchase::STATUS_IN_REVIEW]);
|
|
||||||
|
|
||||||
$newerPurchase = $this->createPendingTransferPurchase(
|
$newerPurchase = $this->createPendingTransferPurchase(
|
||||||
$tenant,
|
$tenant,
|
||||||
$newerUser->id,
|
$newerUser->id,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Logging;
|
|||||||
use App\Domains\Logging\Enums\ValueChangeActorType;
|
use App\Domains\Logging\Enums\ValueChangeActorType;
|
||||||
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
|
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
|
||||||
use App\Domains\Logging\Models\ValueChange;
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
@@ -29,6 +30,13 @@ class LogsValueChangesTest extends TestCase
|
|||||||
$table->id();
|
$table->id();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Schema::create('tenants', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('codigo')->unique();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::getConnection()->table('tenants')->insert(['codigo' => 'test']);
|
||||||
|
|
||||||
Schema::create('logging_test_products', function (Blueprint $table): void {
|
Schema::create('logging_test_products', function (Blueprint $table): void {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('name');
|
$table->string('name');
|
||||||
@@ -37,8 +45,17 @@ class LogsValueChangesTest extends TestCase
|
|||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Schema::create('compras', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_codigo');
|
||||||
|
$table->string('status')->default(Purchase::STATUS_CREATED);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
$migration = require database_path('migrations/2026_08_03_000200_create_value_changes_table.php');
|
$migration = require database_path('migrations/2026_08_03_000200_create_value_changes_table.php');
|
||||||
$migration->up();
|
$migration->up();
|
||||||
|
$tenantMigration = require database_path('migrations/2026_08_04_000000_add_tenant_code_to_value_changes_table.php');
|
||||||
|
$tenantMigration->up();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_creates_one_system_record_per_configured_change(): void
|
public function test_it_creates_one_system_record_per_configured_change(): void
|
||||||
@@ -57,6 +74,7 @@ class LogsValueChangesTest extends TestCase
|
|||||||
|
|
||||||
$this->assertDatabaseCount('value_changes', 2);
|
$this->assertDatabaseCount('value_changes', 2);
|
||||||
$this->assertDatabaseHas('value_changes', [
|
$this->assertDatabaseHas('value_changes', [
|
||||||
|
'tenant_code' => 'test',
|
||||||
'attribute' => 'name',
|
'attribute' => 'name',
|
||||||
'old_value' => 'Original',
|
'old_value' => 'Original',
|
||||||
'new_value' => 'Updated',
|
'new_value' => 'Updated',
|
||||||
@@ -103,6 +121,29 @@ class LogsValueChangesTest extends TestCase
|
|||||||
|
|
||||||
$this->assertDatabaseCount('value_changes', 0);
|
$this->assertDatabaseCount('value_changes', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_purchase_logs_its_status_changes(): void
|
||||||
|
{
|
||||||
|
$purchase = Purchase::query()->create([
|
||||||
|
'tenant_codigo' => 'test',
|
||||||
|
'status' => Purchase::STATUS_CREATED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$purchase->update([
|
||||||
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('value_changes', [
|
||||||
|
'tenant_code' => 'test',
|
||||||
|
'trackable_type' => $purchase->getMorphClass(),
|
||||||
|
'trackable_id' => $purchase->id,
|
||||||
|
'attribute' => 'status',
|
||||||
|
'old_value' => Purchase::STATUS_CREATED,
|
||||||
|
'new_value' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'actor_type' => ValueChangeActorType::System->value,
|
||||||
|
'user_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Fillable(['name', 'price', 'description'])]
|
#[Fillable(['name', 'price', 'description'])]
|
||||||
@@ -117,4 +158,9 @@ class LoggingTestProduct extends Model
|
|||||||
'name',
|
'name',
|
||||||
'price',
|
'price',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected function valueChangeTenantCode(): string
|
||||||
|
{
|
||||||
|
return 'test';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Domains\Catalog\Models\CatalogItem;
|
|||||||
use App\Domains\Catalog\Models\Category;
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Event\Models\Event;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -33,6 +34,12 @@ class StorePurchaseTest extends TestCase
|
|||||||
public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void
|
public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
$event = Event::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'name' => 'Sonder Fest',
|
||||||
|
'address' => 'Test address',
|
||||||
|
]);
|
||||||
|
$tenant->update(['active_event_id' => $event->id]);
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
'email' => 'buyer@example.com',
|
'email' => 'buyer@example.com',
|
||||||
]);
|
]);
|
||||||
@@ -85,6 +92,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
$response->assertJsonPath('data.nombre_apellido', null);
|
$response->assertJsonPath('data.nombre_apellido', null);
|
||||||
$response->assertJsonPath('data.email', null);
|
$response->assertJsonPath('data.email', null);
|
||||||
$response->assertJsonPath('data.tenant_codigo', 'sonder');
|
$response->assertJsonPath('data.tenant_codigo', 'sonder');
|
||||||
|
$response->assertJsonPath('data.event_id', $event->id);
|
||||||
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
|
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
|
||||||
$response->assertJsonPath('data.items_source', 'purchase');
|
$response->assertJsonPath('data.items_source', 'purchase');
|
||||||
$response->assertJsonCount(1, 'data.items');
|
$response->assertJsonCount(1, 'data.items');
|
||||||
@@ -97,6 +105,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
'id' => $purchaseId,
|
'id' => $purchaseId,
|
||||||
'cart_id' => $cartId,
|
'cart_id' => $cartId,
|
||||||
'tenant_codigo' => 'sonder',
|
'tenant_codigo' => 'sonder',
|
||||||
|
'event_id' => $event->id,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'dni' => null,
|
'dni' => null,
|
||||||
'telefono' => null,
|
'telefono' => null,
|
||||||
@@ -472,9 +481,18 @@ class StorePurchaseTest extends TestCase
|
|||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'payment_method' => 'transfer',
|
'payment_method' => 'transfer',
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('value_changes', [
|
||||||
|
'trackable_type' => (new Purchase)->getMorphClass(),
|
||||||
|
'trackable_id' => $purchaseId,
|
||||||
|
'attribute' => 'status',
|
||||||
|
'old_value' => Purchase::STATUS_CREATED,
|
||||||
|
'new_value' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'actor_type' => 'user',
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_submits_a_pending_purchase_for_review_idempotently(): void
|
public function test_it_keeps_a_submitted_purchase_pending_payment_idempotently(): void
|
||||||
{
|
{
|
||||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -492,24 +510,24 @@ class StorePurchaseTest extends TestCase
|
|||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->postJson($url)
|
->postJson($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW)
|
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
|
||||||
->assertJsonPath('data.expires_at', null);
|
->assertJsonPath('data.expires_at', null);
|
||||||
|
|
||||||
$this->assertDatabaseHas('compras', [
|
$this->assertDatabaseHas('compras', [
|
||||||
'id' => $purchase->id,
|
'id' => $purchase->id,
|
||||||
'status' => Purchase::STATUS_IN_REVIEW,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'expires_at' => null,
|
'expires_at' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->postJson($url)
|
->postJson($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW);
|
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete")
|
->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete")
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW);
|
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void
|
public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Domains\Attachable\Enums\AttachmentType;
|
|||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
use App\Domains\Catalog\Enums\EventProductType;
|
use App\Domains\Catalog\Enums\EventProductType;
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
@@ -174,7 +175,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
|||||||
$this->assertSame(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count());
|
$this->assertSame(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count());
|
||||||
$this->assertSame(10, Inventory::query()->count());
|
$this->assertSame(10, Inventory::query()->count());
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
['Entradas', 'Estacionamiento', 'Gastronomía'],
|
['Bebidas', 'Comidas', 'Entradas', 'Estacionamiento', 'Gastronomía'],
|
||||||
Category::query()
|
Category::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->orderBy('nombre')
|
->orderBy('nombre')
|
||||||
@@ -194,7 +195,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
|||||||
|
|
||||||
$featuredGroups = FeaturedGroup::query()
|
$featuredGroups = FeaturedGroup::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->with('featuredItems.catalogItem')
|
->with('category.catalogItems')
|
||||||
->orderBy('group_order')
|
->orderBy('group_order')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
@@ -207,21 +208,23 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
|||||||
],
|
],
|
||||||
$featuredGroups
|
$featuredGroups
|
||||||
->mapWithKeys(fn (FeaturedGroup $group): array => [
|
->mapWithKeys(fn (FeaturedGroup $group): array => [
|
||||||
$group->group_name => $group->featuredItems->pluck('catalogItem.slug')->all(),
|
$group->group_name => $group->category->catalogItems->pluck('slug')->all(),
|
||||||
])
|
])
|
||||||
->all()
|
->all()
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
[
|
[
|
||||||
['Entradas', ProductLayout::Row, GroupLayout::SimpleVertical, 0],
|
['Entradas', FeaturedGroupSource::Category, 'Entradas', ProductLayout::Row, GroupLayout::SimpleVertical, 0],
|
||||||
['Estacionamiento', ProductLayout::ColumnWithCart, GroupLayout::Simple, 1],
|
['Estacionamiento', FeaturedGroupSource::Category, 'Estacionamiento', ProductLayout::ColumnWithCart, GroupLayout::Simple, 1],
|
||||||
['Comidas', ProductLayout::ColumnWithCart, GroupLayout::Simple, 2],
|
['Comidas', FeaturedGroupSource::Category, 'Comidas', ProductLayout::ColumnWithCart, GroupLayout::Simple, 2],
|
||||||
['Bebidas', ProductLayout::ColumnWithCart, GroupLayout::Simple, 3],
|
['Bebidas', FeaturedGroupSource::Category, 'Bebidas', ProductLayout::ColumnWithCart, GroupLayout::Simple, 3],
|
||||||
],
|
],
|
||||||
$featuredGroups
|
$featuredGroups
|
||||||
->map(fn (FeaturedGroup $group): array => [
|
->map(fn (FeaturedGroup $group): array => [
|
||||||
$group->group_name,
|
$group->group_name,
|
||||||
|
$group->source_type,
|
||||||
|
$group->category->nombre,
|
||||||
$group->product_layout,
|
$group->product_layout,
|
||||||
$group->group_layout,
|
$group->group_layout,
|
||||||
$group->group_order,
|
$group->group_order,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Seeders;
|
|||||||
|
|
||||||
use App\Domains\Attachable\Enums\AttachmentType;
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
@@ -70,16 +71,14 @@ class ProductCatalogFromImagesSeederTest extends TestCase
|
|||||||
$this->assertNotNull($paginatedGroup);
|
$this->assertNotNull($paginatedGroup);
|
||||||
$this->assertSame(ProductLayout::ColumnWithImage, $paginatedGroup->product_layout);
|
$this->assertSame(ProductLayout::ColumnWithImage, $paginatedGroup->product_layout);
|
||||||
$this->assertSame(GroupLayout::Paginated, $paginatedGroup->group_layout);
|
$this->assertSame(GroupLayout::Paginated, $paginatedGroup->group_layout);
|
||||||
$this->assertSame(0, $paginatedGroup->group_order);
|
$this->assertSame(FeaturedGroupSource::All, $paginatedGroup->source_type);
|
||||||
$this->assertSame(
|
$this->assertSame(2, $paginatedGroup->group_order);
|
||||||
CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('id')->pluck('slug')->all(),
|
$this->assertCount(0, $paginatedGroup->featuredItems);
|
||||||
$paginatedGroup->featuredItems->pluck('catalogItem.slug')->all(),
|
|
||||||
);
|
|
||||||
$this->assertCount(10, $paginatedGroup->featuredItems);
|
|
||||||
|
|
||||||
$this->assertNotNull($carouselGroup);
|
$this->assertNotNull($carouselGroup);
|
||||||
$this->assertSame(ProductLayout::ColumnWithImage, $carouselGroup->product_layout);
|
$this->assertSame(ProductLayout::ColumnWithImage, $carouselGroup->product_layout);
|
||||||
$this->assertSame(GroupLayout::Carousel, $carouselGroup->group_layout);
|
$this->assertSame(GroupLayout::Carousel, $carouselGroup->group_layout);
|
||||||
|
$this->assertSame(FeaturedGroupSource::Manual, $carouselGroup->source_type);
|
||||||
$this->assertSame(1, $carouselGroup->group_order);
|
$this->assertSame(1, $carouselGroup->group_order);
|
||||||
$this->assertCount(5, $carouselGroup->featuredItems);
|
$this->assertCount(5, $carouselGroup->featuredItems);
|
||||||
$this->assertCount(
|
$this->assertCount(
|
||||||
|
|||||||
138
tests/Feature/Staff/StaffControllerTest.php
Normal file
138
tests/Feature/Staff/StaffControllerTest.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Staff;
|
||||||
|
|
||||||
|
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 App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class StaffControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Tenant $tenant;
|
||||||
|
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(AuthorizationSeeder::class);
|
||||||
|
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
|
||||||
|
$this->tenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.test',
|
||||||
|
'website_type_code' => 'onticket',
|
||||||
|
]);
|
||||||
|
$this->admin = User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_adminapp_can_create_update_list_and_delete_staff_with_categories(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
$firstCategory = $this->createCategory('Bebidas');
|
||||||
|
$secondCategory = $this->createCategory('Comidas');
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/adminapp/tenant/staff', [
|
||||||
|
'nombre_apellido' => 'Ada Lovelace',
|
||||||
|
'dni' => '12345678',
|
||||||
|
'email' => 'ADA@example.test',
|
||||||
|
'category_ids' => [$firstCategory->id],
|
||||||
|
])->assertSuccessful()
|
||||||
|
->assertJsonPath('data.email', 'ada@example.test')
|
||||||
|
->assertJsonPath('data.role.codigo', RoleCode::Scanner->value)
|
||||||
|
->assertJsonPath('data.categories.0.id', $firstCategory->id);
|
||||||
|
|
||||||
|
$staffId = $response->json('data.id');
|
||||||
|
$this->assertDatabaseHas('category_scanners', [
|
||||||
|
'user_id' => $staffId,
|
||||||
|
'categoria_id' => $firstCategory->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/staff?search=ada')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data');
|
||||||
|
|
||||||
|
$this->putJson("/api/v1/adminapp/tenant/staff/{$staffId}", [
|
||||||
|
'nombre_apellido' => 'Ada Byron',
|
||||||
|
'dni' => '12345678',
|
||||||
|
'email' => 'ada@example.test',
|
||||||
|
'category_ids' => [$secondCategory->id],
|
||||||
|
])->assertOk()
|
||||||
|
->assertJsonPath('data.nombre_apellido', 'Ada Byron')
|
||||||
|
->assertJsonPath('data.categories.0.id', $secondCategory->id);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('category_scanners', [
|
||||||
|
'user_id' => $staffId,
|
||||||
|
'categoria_id' => $firstCategory->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/adminapp/tenant/staff/{$staffId}")->assertNoContent();
|
||||||
|
$this->assertDatabaseMissing('users', ['id' => $staffId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_cannot_assign_another_tenants_category(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs($this->admin);
|
||||||
|
$otherTenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'other',
|
||||||
|
'nombre' => 'Other',
|
||||||
|
'dominio' => 'other.test',
|
||||||
|
'website_type_code' => 'onticket',
|
||||||
|
]);
|
||||||
|
$foreignCategory = Category::query()->create([
|
||||||
|
'tenant_code' => $otherTenant->codigo,
|
||||||
|
'nombre' => 'Privada',
|
||||||
|
]);
|
||||||
|
$payload = [
|
||||||
|
'nombre_apellido' => 'Grace Hopper',
|
||||||
|
'dni' => '87654321',
|
||||||
|
'email' => 'grace@example.test',
|
||||||
|
'category_ids' => [$foreignCategory->id],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/staff', $payload)
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('category_ids');
|
||||||
|
|
||||||
|
$parent = $this->createCategory('Local');
|
||||||
|
$child = Category::query()->create([
|
||||||
|
'tenant_code' => $this->tenant->codigo,
|
||||||
|
'categoria_id' => $parent->id,
|
||||||
|
'nombre' => 'Subcategoría',
|
||||||
|
]);
|
||||||
|
$payload['category_ids'] = [$child->id];
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/staff', $payload)
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('category_ids');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_customer_cannot_manage_staff(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs(User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/staff')->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createCategory(string $name): Category
|
||||||
|
{
|
||||||
|
return Category::query()->create([
|
||||||
|
'tenant_code' => $this->tenant->codigo,
|
||||||
|
'nombre' => $name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace Tests\Unit\Catalog;
|
|||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
use App\Domains\Catalog\Enums\EventProductType;
|
use App\Domains\Catalog\Enums\EventProductType;
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
@@ -104,6 +105,8 @@ class CatalogModelsTest extends TestCase
|
|||||||
{
|
{
|
||||||
$group = new FeaturedGroup;
|
$group = new FeaturedGroup;
|
||||||
$group->setRawAttributes([
|
$group->setRawAttributes([
|
||||||
|
'source_type' => FeaturedGroupSource::Category->value,
|
||||||
|
'category_id' => '4',
|
||||||
'product_layout' => ProductLayout::ColumnWithImage->value,
|
'product_layout' => ProductLayout::ColumnWithImage->value,
|
||||||
'group_layout' => GroupLayout::SimpleVertical->value,
|
'group_layout' => GroupLayout::SimpleVertical->value,
|
||||||
'group_order' => '2',
|
'group_order' => '2',
|
||||||
@@ -118,9 +121,12 @@ class CatalogModelsTest extends TestCase
|
|||||||
$this->assertSame('featured_groups', $group->getTable());
|
$this->assertSame('featured_groups', $group->getTable());
|
||||||
$this->assertFalse($group->usesTimestamps());
|
$this->assertFalse($group->usesTimestamps());
|
||||||
$this->assertSame(ProductLayout::ColumnWithImage, $group->product_layout);
|
$this->assertSame(ProductLayout::ColumnWithImage, $group->product_layout);
|
||||||
|
$this->assertSame(FeaturedGroupSource::Category, $group->source_type);
|
||||||
|
$this->assertSame(4, $group->category_id);
|
||||||
$this->assertSame(GroupLayout::SimpleVertical, $group->group_layout);
|
$this->assertSame(GroupLayout::SimpleVertical, $group->group_layout);
|
||||||
$this->assertSame(2, $group->group_order);
|
$this->assertSame(2, $group->group_order);
|
||||||
$this->assertInstanceOf(Tenant::class, $group->tenant()->getRelated());
|
$this->assertInstanceOf(Tenant::class, $group->tenant()->getRelated());
|
||||||
|
$this->assertInstanceOf(Category::class, $group->category()->getRelated());
|
||||||
$this->assertInstanceOf(FeaturedItem::class, $group->featuredItems()->getRelated());
|
$this->assertInstanceOf(FeaturedItem::class, $group->featuredItems()->getRelated());
|
||||||
|
|
||||||
$this->assertSame('featured_items', $featuredItem->getTable());
|
$this->assertSame('featured_items', $featuredItem->getTable());
|
||||||
@@ -189,7 +195,7 @@ class CatalogModelsTest extends TestCase
|
|||||||
$variant->setRelation('eventDate', $eventDate);
|
$variant->setRelation('eventDate', $eventDate);
|
||||||
$variant->setRelation('definitions', new EloquentCollection);
|
$variant->setRelation('definitions', new EloquentCollection);
|
||||||
|
|
||||||
$this->assertSame('Entrada General (Fecha: 2026-10-09)', $variant->getName());
|
$this->assertSame('Entrada General', $variant->getName());
|
||||||
$this->assertSame('2026-10-09 09:00:00', $variant->getMinimumUseDate()->format('Y-m-d H:i:s'));
|
$this->assertSame('2026-10-09 09:00:00', $variant->getMinimumUseDate()->format('Y-m-d H:i:s'));
|
||||||
$this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s'));
|
$this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s'));
|
||||||
}
|
}
|
||||||
|
|||||||
25
tests/Unit/Forms/SaleFormServiceTest.php
Normal file
25
tests/Unit/Forms/SaleFormServiceTest.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Forms;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Services\SaleFormService;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class SaleFormServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_returns_every_purchase_status_as_a_form_option(): void
|
||||||
|
{
|
||||||
|
$form = (new SaleFormService)->get();
|
||||||
|
|
||||||
|
$this->assertSame(Purchase::statuses(), array_column($form['statuses'], 'code'));
|
||||||
|
$this->assertSame([
|
||||||
|
'Creada',
|
||||||
|
'Esperando pago',
|
||||||
|
'Confirmada',
|
||||||
|
'Cancelada',
|
||||||
|
'Rechazada',
|
||||||
|
'Vencida',
|
||||||
|
], array_column($form['statuses'], 'name'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace Tests\Unit\Logging;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Logging\Enums\ValueChangeActorType;
|
use App\Domains\Logging\Enums\ValueChangeActorType;
|
||||||
use App\Domains\Logging\Models\ValueChange;
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ class ValueChangeTest extends TestCase
|
|||||||
{
|
{
|
||||||
$valueChange = new ValueChange;
|
$valueChange = new ValueChange;
|
||||||
$valueChange->setRawAttributes([
|
$valueChange->setRawAttributes([
|
||||||
|
'tenant_code' => 'test',
|
||||||
'trackable_id' => '10',
|
'trackable_id' => '10',
|
||||||
'attribute' => 'status',
|
'attribute' => 'status',
|
||||||
'old_value' => 'pending',
|
'old_value' => 'pending',
|
||||||
@@ -25,6 +27,7 @@ class ValueChangeTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame('value_changes', $valueChange->getTable());
|
$this->assertSame('value_changes', $valueChange->getTable());
|
||||||
$this->assertFalse($valueChange->usesTimestamps());
|
$this->assertFalse($valueChange->usesTimestamps());
|
||||||
|
$this->assertSame('test', $valueChange->tenant_code);
|
||||||
$this->assertSame(10, $valueChange->trackable_id);
|
$this->assertSame(10, $valueChange->trackable_id);
|
||||||
$this->assertSame('status', $valueChange->attribute);
|
$this->assertSame('status', $valueChange->attribute);
|
||||||
$this->assertSame('pending', $valueChange->old_value);
|
$this->assertSame('pending', $valueChange->old_value);
|
||||||
@@ -34,5 +37,6 @@ class ValueChangeTest extends TestCase
|
|||||||
$this->assertSame(20, $valueChange->user_id);
|
$this->assertSame(20, $valueChange->user_id);
|
||||||
$this->assertInstanceOf(MorphTo::class, $valueChange->trackable());
|
$this->assertInstanceOf(MorphTo::class, $valueChange->trackable());
|
||||||
$this->assertInstanceOf(User::class, $valueChange->user()->getRelated());
|
$this->assertInstanceOf(User::class, $valueChange->user()->getRelated());
|
||||||
|
$this->assertInstanceOf(Tenant::class, $valueChange->tenant()->getRelated());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
tests/Unit/Sale/AdminAppSalePdfServiceTest.php
Normal file
90
tests/Unit/Sale/AdminAppSalePdfServiceTest.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Sale;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Logging\Models\ValueChange;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Sale\Services\AdminAppSalePdfService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Barryvdh\DomPDF\ServiceProvider;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAppSalePdfServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->app->register(ServiceProvider::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_downloads_the_sales_report_as_a_pdf(): void
|
||||||
|
{
|
||||||
|
$response = app(AdminAppSalePdfService::class)->downloadSales(
|
||||||
|
$this->tenant(),
|
||||||
|
collect([$this->sale()]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('application/pdf', $response->headers->get('content-type'));
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'attachment; filename=ventas_acme_',
|
||||||
|
(string) $response->headers->get('content-disposition'),
|
||||||
|
);
|
||||||
|
$this->assertStringStartsWith('%PDF', $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_downloads_the_modification_history_as_a_pdf(): void
|
||||||
|
{
|
||||||
|
$sale = $this->sale();
|
||||||
|
$admin = (new User)->forceFill([
|
||||||
|
'id' => 10,
|
||||||
|
'nombre_apellido' => 'Admin Test',
|
||||||
|
'email' => 'admin@example.test',
|
||||||
|
]);
|
||||||
|
$modification = (new ValueChange)->forceFill([
|
||||||
|
'id' => 1,
|
||||||
|
'trackable_id' => $sale->id,
|
||||||
|
'attribute' => 'status',
|
||||||
|
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'new_value' => Purchase::STATUS_PAID,
|
||||||
|
'changed_at' => now(),
|
||||||
|
'actor_type' => 'user',
|
||||||
|
]);
|
||||||
|
$modification->setRelation('trackable', $sale);
|
||||||
|
$modification->setRelation('user', $admin);
|
||||||
|
|
||||||
|
$response = app(AdminAppSalePdfService::class)->downloadModifications(
|
||||||
|
$this->tenant(),
|
||||||
|
collect([$modification]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('application/pdf', $response->headers->get('content-type'));
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'attachment; filename=historial_modificaciones_acme_',
|
||||||
|
(string) $response->headers->get('content-disposition'),
|
||||||
|
);
|
||||||
|
$this->assertStringStartsWith('%PDF', $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenant(): Tenant
|
||||||
|
{
|
||||||
|
return (new Tenant)->forceFill([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme Eventos',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sale(): Purchase
|
||||||
|
{
|
||||||
|
return (new Purchase)->forceFill([
|
||||||
|
'id' => 15,
|
||||||
|
'created_at' => now(),
|
||||||
|
'nombre_apellido' => 'Cliente Test',
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => Purchase::STATUS_PAID,
|
||||||
|
'total' => '25000.00',
|
||||||
|
'tickets_count' => 2,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ class TicketTest extends TestCase
|
|||||||
'starts_at' => '2026-07-21 10:00:00',
|
'starts_at' => '2026-07-21 10:00:00',
|
||||||
'expires_at' => '2026-07-22 10:00:00',
|
'expires_at' => '2026-07-22 10:00:00',
|
||||||
'used_at' => null,
|
'used_at' => null,
|
||||||
|
'scanner_user_id' => '15',
|
||||||
'user_id' => '10',
|
'user_id' => '10',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -39,9 +40,11 @@ class TicketTest extends TestCase
|
|||||||
$this->assertInstanceOf(Carbon::class, $ticket->starts_at);
|
$this->assertInstanceOf(Carbon::class, $ticket->starts_at);
|
||||||
$this->assertInstanceOf(Carbon::class, $ticket->expires_at);
|
$this->assertInstanceOf(Carbon::class, $ticket->expires_at);
|
||||||
$this->assertNull($ticket->used_at);
|
$this->assertNull($ticket->used_at);
|
||||||
|
$this->assertSame(15, $ticket->scanner_user_id);
|
||||||
$this->assertSame(10, $ticket->user_id);
|
$this->assertSame(10, $ticket->user_id);
|
||||||
$this->assertInstanceOf(Tenant::class, $ticket->tenant()->getRelated());
|
$this->assertInstanceOf(Tenant::class, $ticket->tenant()->getRelated());
|
||||||
$this->assertInstanceOf(User::class, $ticket->user()->getRelated());
|
$this->assertInstanceOf(User::class, $ticket->user()->getRelated());
|
||||||
|
$this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated());
|
||||||
$this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated());
|
$this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated());
|
||||||
$this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated());
|
$this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user