42 Commits

Author SHA1 Message Date
5197862a62 feat(ticket): implement ticket generation policies and validity time management
- Added `ticket_generation_policy` column to `catalog_items` table to manage ticket generation strategies.
- Created `ticket_validity_times` table to allow multiple validity times per ticket.
- Introduced `validity_time_id` column in `event_dates` table to associate event dates with validity times.
- Established `ticket_validity_groups` and `ticket_validity_group_times` tables to group validity times for tickets.
- Updated seeder to set default ticket generation policy for specific tenant.
- Enhanced tests to cover new functionality, including ticket generation based on event dates and validity times.
- Refactored ticket validity checks to accommodate multiple validity times in a group.
2026-08-11 12:29:32 -03:00
2115d2384d feat(ticket): add tickets endpoint and resource for sales; implement ticket retrieval logic and tests 2026-08-11 11:20:55 -03:00
a458c564ca feat(sale): implement confirm and cancel functionality for sales in AdminApp; update routes and tests 2026-08-11 11:00:09 -03:00
68dd373d61 feat(tenant): add display options for categories and search bar; update requests, migrations, seeder, and tests 2026-08-11 10:04:18 -03:00
8d8e49fcfb feat(event): implement automatic tenant date text synchronization on save and delete; update related tests 2026-08-11 09:49:59 -03:00
19b506a465 feat(catalog): add scope to filter available variants and update related queries; enhance seeder and tests for stock validation 2026-08-11 09:10:26 -03:00
6f63a87af5 feat(test): add unit test for SaleDetailResource serialization 2026-08-10 17:11:45 -03:00
a59b96dd22 feat(sale): add sale detail retrieval functionality; implement controller method and resource, and create corresponding tests 2026-08-10 17:10:55 -03:00
6354e26620 feat(ticket): append variant properties to ticket name in generation process; add corresponding test case 2026-08-10 17:10:46 -03:00
69320c54d4 feat: remove EventProductType references and related logic from catalog and fiesta futbol infantil components 2026-08-10 16:37:28 -03:00
5bfc8b9f70 feat(seeder): update product layout and group layout for Fiesta Futbol Infantil seeder; adjust related test assertions 2026-08-10 15:05:42 -03:00
17f4cb6502 feat: enable ticketing for Fiesta Futbol Infantil catalog items; update services, migrations, seeders, and tests to reflect changes 2026-08-10 14:13:46 -03:00
6844ab7af9 feat(cart): update CartItemResource to use visibleVariants method with included variant ID; enhance variant filtering logic 2026-08-10 13:59:45 -03:00
8542c03466 feat: enhance item attributes management by adding sort order; update related models, resources, and tests for visibility and ordering of variants 2026-08-10 13:58:35 -03:00
e7c8807a67 feat(variant): refactor selection values to expose options with labels; update related resources and tests for consistency 2026-08-10 12:27:00 -03:00
c6351d706f feat(cart): enhance item update functionality to support variant changes and stock management; update related request validation and resource formatting; add tests for variant handling in cart 2026-08-10 12:08:09 -03:00
2693df2cbf feat: implement delete functionality for accommodations, entries, foods, and merchandise; enhance related services and tests 2026-08-10 11:33:05 -03:00
4aaa66dc38 feat(event): add EventDateTextFormatter for formatting event dates; update Tenant model and resource to include event_date_text; create migration for event_date_text column; enhance tests for event date formatting and tenant updates 2026-08-10 10:42:12 -03:00
1cd60f7021 feat(food): implement FoodFormController and FoodFormService; add FoodFormResource for managing food options and routes 2026-08-10 10:05:35 -03:00
ca605127d0 feat: implement index methods for Accommodation, Entry, Food, and Merchandise controllers; add service methods for fetching current items 2026-08-10 10:04:57 -03:00
00489c14d3 feat(forms): implement MerchandiseFormController, MerchandiseFormService, and MerchandiseFormResource for managing merchandise options 2026-08-10 09:28:52 -03:00
63f98b5da2 feat(merchandise): implement MerchandiseController, MerchandiseService, and UpsertMerchandiseRequest for managing merchandise items and variants 2026-08-07 15:05:50 -03:00
b05314b1dd refactor: remove tenant code check and implement middleware for menu validation in controllers 2026-08-07 14:44:52 -03:00
feed3205eb feat(accommodation): implement AccommodationController, AccommodationService, and UpsertAccommodationVariantsRequest for managing accommodations 2026-08-07 14:43:06 -03:00
11776f0734 refactor: integrate category handling for entries and food items in services and tests 2026-08-07 14:34:59 -03:00
e1ad27ecf0 feat(food): implement FoodController, FoodService, and related resources for managing food items and variants
refactor(catalog): add description and price fields to variants and update related resources

feat(migration): add commercial overrides to variants with description and price fields

test(food): add tests for FoodController and ensure correct seeding of food items and variants
2026-08-07 14:17:51 -03:00
6a6d3690cc feat(entry): implement EntryController, EntryService, and EntryResource for managing entries 2026-08-07 12:34:38 -03:00
ed4502425a refactor(catalog): replace hardcoded validation messages with translatable strings 2026-08-07 12:07:40 -03:00
6318a99328 refactor(variant): enhance selection values handling for multi-select attributes 2026-08-07 12:07:07 -03:00
21b70777f2 feat: add support for multiple event dates in variants
- Updated the FeaturedGroupService to include 'variants.eventDates' in the items query.
- Introduced a BelongsToMany relationship in EventDate for selected variants.
- Modified PurchaseItemResource and PurchaseItemSnapshotFactory to handle multiple event dates for variants.
- Enhanced StartCheckoutService to load event dates for variants.
- Updated TicketGeneratorService to accommodate event dates in ticket generation.
- Created migrations to support multi-value event dates and allow multiple variant values per attribute.
- Adjusted seeders to reflect new event date handling and added new attributes.
- Added tests to ensure correct functionality for multi-date variants and their integration with ticket generation.
2026-08-07 11:57:38 -03:00
91af233941 refactor(ticket): implement fixed validity time generation for event date and time windows in TicketGeneratorService 2026-08-07 10:43:05 -03:00
717ee5d194 refactor(catalog): add event date attribute with dynamic options and update related logic 2026-08-07 10:03:31 -03:00
14d53d1e0b refactor(catalog): handle null validity time in CatalogItemDetailResource and update related tests 2026-08-07 09:20:52 -03:00
7b7efeb4cc refactor(event): move event configuration onto tenant 2026-08-07 09:08:14 -03:00
60b8415c59 refactor(checkout): split checkout workflows into focused services 2026-08-07 09:04:36 -03:00
f578c6e24a feat(cart): implement user purchase limit validation for cart items and add related tests 2026-08-06 16:43:04 -03:00
5ad61eb217 feat(catalog): add max_units_per_user field to catalog items and implement purchase limit validation 2026-08-06 16:37:48 -03:00
20dd246cd8 feat(validity-time): add ValidityTimeResource and integrate into catalog and ticket resources 2026-08-06 16:13:29 -03:00
7561ba756c refactor(validity-time): remove 'active' field from ValidityTime model and related migrations/tests 2026-08-06 16:09:11 -03:00
b224dd8650 refactor(ticket): remove service_date field and update validity time logic 2026-08-06 16:05:08 -03:00
448ffb4102 feat(ticket): implement validity time management for tickets and catalog items
- Added ValidityTime model and migration to manage ticket validity periods.
- Updated TicketGeneratorService to resolve and assign validity times to tickets.
- Refactored ticket generation logic to remove legacy date fields and use validity time.
- Introduced timezone support for tenants to handle service dates correctly.
- Updated migrations to remove deprecated columns and add foreign keys for validity times.
- Modified seeders and tests to accommodate new validity time structure.
- Enhanced tests to validate ticket generation and validity time behavior.
2026-08-06 15:52:19 -03:00
7f01adab73 feat(seeders): update FiestaFutbolInfantil seeders and tests to include new categories and menu items 2026-08-06 14:12:58 -03:00
166 changed files with 10203 additions and 1849 deletions

View File

@@ -0,0 +1,29 @@
# Dominio Attachable
## Propósito
Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única.
## Componentes principales
- `Models/Attachment.php`: representa un adjunto y genera URL temporales de acceso.
- `Services/AttachmentService.php`: almacena, copia y elimina archivos, compensando en S3 si falla la escritura en base de datos.
- `Enums/AttachmentType.php`: clasifica imágenes, videos, PDF, audio, documentos y otros archivos.
- `Exceptions/AttachmentStorageException.php`: expresa fallos propios del almacenamiento.
## Flujo principal
1. El consumidor entrega un `UploadedFile` o una cadena Base64 y un directorio.
2. El servicio valida el contenido, detecta MIME/extensión y genera una clave UUID.
3. El archivo se guarda en el disco `s3`.
4. Se crea el registro `Attachment`; ante error se elimina el objeto que había sido subido.
## API y dependencias
No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog` y `Tenant`. Depende de Laravel Storage, Symfony Mime y del modelo `Attachment`.
## Consideraciones
- El directorio no puede quedar vacío después de normalizarlo.
- La eliminación se considera fallida si S3 no confirma el borrado.
- Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos.

View File

@@ -0,0 +1,36 @@
# Dominio Auth
## Propósito
Gestiona identidad y acceso de usuarios de la tienda y del panel administrativo: registro, inicio y cierre de sesión, perfil, autenticación con Google y recuperación de contraseña.
## Modelo y servicios
- `User`: usuario autenticable, asociado a tenant, rol, intentos de acceso y categorías habilitadas para escaneo.
- `LoginAttempt` y `ResetPasswordAttempt`: trazabilidad de accesos y recuperación de contraseña.
- `PasswordLoginService`: autentica tienda y AdminApp, incluyendo bloqueo por intentos.
- `RegisterUserService` y `ProfileService`: alta y edición del usuario.
- `ResetPasswordAttemptService`: crea, valida y consume códigos de recuperación.
- `GoogleAuthService`: redirección, callback e intercambio de código para Google OAuth.
- `AdminAppContextService`: carga el contexto requerido por un usuario administrativo.
## Endpoints públicos
- `POST /register`, `POST /login` y `POST /logout`.
- `GET /me` y `PUT /me`, protegidos por `auth:sanctum`.
- Creación, validación y aplicación de intentos de recuperación bajo `/password`.
- `POST /auth/google/exchange` para canjear el código de autenticación.
- `POST /v1/adminapp/login` y consulta del usuario administrativo dentro del grupo autenticado de AdminApp.
## Validación y respuestas
Los `FormRequest` validan cada operación. `UserResource` y `AdminAppMeResource` definen las representaciones de salida. Los endpoints sensibles aplican `auth:sanctum` y límites de frecuencia.
## Dependencias y eventos
Se relaciona con `Tenant` y `Authorization`; el registro y la recuperación disparan flujos atendidos por `Notification`. El carrito invitado puede integrarse al usuario autenticado mediante el dominio `Cart`.
## Consideraciones
- La resolución del tenant forma parte de la autenticación y no debe omitirse.
- Los cambios en reglas de login deben conservar los límites de intentos y el manejo de `AccountLockedException`.

View File

@@ -0,0 +1,26 @@
# Dominio Authorization
## Propósito
Define el esquema de roles y permisos usado para autorizar funcionalidades de la aplicación.
## Componentes principales
- `Enums/RoleCode.php`: códigos de roles conocidos por el sistema.
- `Models/Role.php`: rol con relaciones hacia permisos, usuarios y menús.
- `Models/Permission.php`: permiso asignable a uno o más roles.
- `Models/RolePermission.php`: entidad de asociación entre rol y permiso.
## API
No expone controladores ni rutas propias. Su información se consume desde autenticación, menús, políticas y middleware de autorización.
## Relaciones relevantes
- `Role` tiene muchos usuarios del dominio `Auth`.
- Roles y permisos mantienen una relación muchos-a-muchos.
- Los roles determinan los menús disponibles mediante el dominio `Menu`.
## Consideraciones
Los códigos definidos en `RoleCode` funcionan como contrato entre datos persistidos y lógica de aplicación. Al agregar un rol o permiso se deben revisar seeds, asociaciones y consumidores.

View File

@@ -0,0 +1,28 @@
# Dominio Bootstrap
## Propósito
Entrega la configuración inicial que necesitan la tienda y el panel administrativo antes de renderizar su interfaz.
## Flujos
- `TenantBootstrapService` resuelve un tenant desde el dominio solicitado y carga su información pública.
- `AdminAppBootstrapService` prepara el contexto inicial del panel administrativo para el tenant autenticado.
- Los controladores invocables transforman el resultado mediante `TenantResource` o `AdminAppBootstrapResource`.
## Endpoints
- `GET /tenants/bootstrap/{dominio}`: bootstrap público de la tienda.
- Endpoint de bootstrap bajo `/v1/adminapp`, protegido por `auth:sanctum` y `adminapp.tenant`.
## Validación
`TenantBootstrapRequest` valida el dominio recibido. `AdminAppBootstrapRequest` reutiliza ese contrato para el panel.
## Dependencias
Depende principalmente de `Tenant` para resolver y cargar la tienda, y de los dominios que aportan datos al contexto administrativo.
## Consideraciones
Este dominio es un agregador de lectura. Debe mantenerse liviano y delegar la obtención de cada dato al dominio propietario.

View File

@@ -54,16 +54,26 @@ class CartController extends Controller
Tenant $tenant,
CartItem $cartItem,
): CartResource {
$updatesVariant = $request->exists('variant_id');
return CartResource::make(
$this->cartService->updateItemQuantity(
$this->cartService->updateItem(
$tenant,
$request,
$cartItem->getKey(),
(int) $request->validated('cantidad'),
$updatesVariant
? ($request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null)
: $cartItem->variant_id,
$updatesVariant,
)
)->additional([
'code' => 'cart.quantity_updated',
'message' => __('api.cart.quantity_updated'),
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
'message' => $updatesVariant
? __('api.cart.item_updated')
: __('api.cart.quantity_updated'),
]);
}

View File

@@ -7,6 +7,7 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -86,6 +87,10 @@ class Cart extends Model
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$cartQuantity = (int) $this->items()
->where('catalog_item_id', $catalogItemId)
->sum('cantidad');
$this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity);
$inventoryService = app(CatalogInventoryService::class);
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
@@ -119,29 +124,99 @@ class Cart extends Model
});
}
public function updateItem(int $cartItemId, int $quantity): CartItem
{
public function updateItem(
int $cartItemId,
int $quantity,
?int $variantId = null,
bool $updateVariant = false,
): CartItem {
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.positive_quantity'),
]);
}
return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
return DB::transaction(function () use (
$cartItemId,
$quantity,
$variantId,
$updateVariant,
): CartItem {
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$selectedItem = $this->resolveScopedItem(
$currentSelection = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
$inventoryService = app(CatalogInventoryService::class);
if ($updateVariant && $variantId !== $item->variant_id) {
$nextSelection = $this->resolveScopedItem(
$item->catalog_item_id,
$variantId,
true,
);
$otherVariantsQuantity = (int) $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$this->assertUserPurchaseLimit(
$nextSelection,
$otherVariantsQuantity + $quantity,
);
$inventoryService->release($currentSelection, $item->cantidad);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
$targetItem = $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $variantId)
->whereKeyNot($item->getKey())
->lockForUpdate()
->first();
$inventoryService->reserve($nextSelection, $quantity);
if ($targetItem !== null) {
$targetItem->cantidad += $quantity;
$targetItem->save();
$item->delete();
return $targetItem->fresh();
}
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
return $item->fresh();
}
$delta = $quantity - $item->cantidad;
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
if ($delta > 0) {
$otherVariantsQuantity = (int) $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$this->assertUserPurchaseLimit(
$currentSelection,
$otherVariantsQuantity + $quantity,
);
}
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
@@ -154,11 +229,11 @@ class Cart extends Model
$item->save();
if ($delta > 0) {
$inventoryService->reserve($selectedItem, $delta);
$inventoryService->reserve($currentSelection, $delta);
}
if ($delta < 0) {
$inventoryService->release($selectedItem, abs($delta));
$inventoryService->release($currentSelection, abs($delta));
}
return $item->fresh();
@@ -256,6 +331,26 @@ class Cart extends Model
return $variant;
}
private function assertUserPurchaseLimit(
CatalogItem|Variant $selectedItem,
int $cartQuantity,
): void {
if ($this->user_id === null) {
return;
}
$catalogItem = $selectedItem instanceof Variant
? $selectedItem->catalogItem
: $selectedItem;
app(UserPurchaseLimitService::class)->assertCanPurchase(
$catalogItem,
$this->user_id,
$cartQuantity,
field: 'cantidad',
);
}
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
{
$query = Inventory::query()->whereKey($inventoryId);

View File

@@ -19,7 +19,7 @@ class UpdateCartItemQuantityRequest extends FormRequest
return [
'cantidad' => ['required', 'integer', 'min:1'],
'catalog_item_id' => ['prohibited'],
'variant_id' => ['prohibited'],
'variant_id' => ['sometimes', 'nullable', 'integer'],
];
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -36,6 +38,16 @@ class CartItemResource extends JsonResource
'product' => $selectedItem === null ? null : [
'nombre' => $selectedItem->getName(),
'imagen' => $imageUrl,
'variants' => $this->catalogItem->visibleVariants($this->variant_id)
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'precio' => $this->formatMoney($variant->getPrice()),
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->selectionOptions($this->catalogItem->itemAttributes),
])
->values(),
],
];
}

View File

@@ -51,11 +51,17 @@ class CartService
];
}
public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
{
public function updateItem(
Tenant $tenant,
Request $request,
int $cartItemId,
int $quantity,
?int $variantId,
bool $updateVariant,
): Cart {
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($cartItemId, $quantity);
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
return $this->loadCart($cart);
}
@@ -101,9 +107,16 @@ class CartService
return $cart->fresh()->load([
'items.catalogItem.attachments',
'items.catalogItem.inventory',
'items.catalogItem.itemAttributes.attribute',
'items.catalogItem.variants.inventory',
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
'items.catalogItem.variants.eventDates',
'items.catalogItem.variants.eventDate',
'items.variant.attachments',
'items.variant.inventory',
'items.variant.definitions.itemAttribute.attribute',
'items.variant.definitions.itemAttribute.attribute.options',
'items.variant.eventDates',
'items.variant.eventDate',
]);
}

View File

@@ -0,0 +1,32 @@
# Dominio Cart
## Propósito
Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios autenticados.
## Modelo
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; expone la selección efectiva.
## Servicios
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
## Endpoints
Bajo `/tenants/{tenant:codigo}`:
- `GET /cart`.
- `POST /cart/items`.
- `PATCH /cart/items/{cartItem}`.
- `DELETE /cart/items/{cartItem}`.
## Contratos
`AddCartItemRequest` y `UpdateCartItemQuantityRequest` validan selección y cantidad. `CartResource` y `CartItemResource` estabilizan la respuesta pública.
## Dependencias y reglas
Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual.

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Event\Models\EventDate;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -51,4 +52,14 @@ class Attribute extends Model
{
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
}
/**
* @return HasMany<EventDate, $this>
*/
public function eventDates(): HasMany
{
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
->orderBy('date')
->orderBy('time_start');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'attribute_id',
'validity_time_id',
'value',
'label',
'sort_order',
@@ -26,6 +28,7 @@ class AttributeOption extends Model
protected function casts(): array
{
return [
'validity_time_id' => 'integer',
'sort_order' => 'integer',
'metadata' => 'array',
];
@@ -38,4 +41,10 @@ class AttributeOption extends Model
{
return $this->belongsTo(Attribute::class, 'attribute_id');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
}

View File

@@ -4,24 +4,23 @@ namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Models\Ticket;
use Carbon\CarbonInterface;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
#[Fillable([
'tenant_code',
'event_id',
'event_product_type',
'category_id',
'brand_id',
'inventory_id',
@@ -31,9 +30,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'descripcion',
'precio',
'inventory_policy',
'max_units_per_user',
'has_tickets',
'maximum_use_date',
'minimum_use_date',
'ticket_generation_policy',
'validity_time_id',
])]
class CatalogItem extends Model
{
@@ -47,6 +47,7 @@ class CatalogItem extends Model
'type' => CatalogItemType::Standard->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false,
'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value,
];
protected function casts(): array
@@ -55,14 +56,13 @@ class CatalogItem extends Model
'category_id' => 'integer',
'brand_id' => 'integer',
'inventory_id' => 'integer',
'event_id' => 'integer',
'event_product_type' => EventProductType::class,
'type' => CatalogItemType::class,
'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class,
'max_units_per_user' => 'integer',
'has_tickets' => 'boolean',
'maximum_use_date' => 'datetime',
'minimum_use_date' => 'datetime',
'ticket_generation_policy' => TicketGenerationPolicy::class,
'validity_time_id' => 'integer',
];
}
@@ -72,12 +72,6 @@ class CatalogItem extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
@@ -120,6 +114,12 @@ class CatalogItem extends Model
return $this->hasMany(Ticket::class, 'source_catalog_item_id');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return BelongsToMany<Attribute, $this> */
public function attributes(): BelongsToMany
{
@@ -173,6 +173,31 @@ class CatalogItem extends Model
return ($this->availableStock() ?? 0) > 0;
}
/** @param Builder<CatalogItem> $query */
public function scopeWhereVariantsAvailable(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query
->whereDoesntHave('variants')
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhereHas(
'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
);
});
}
/** @return Collection<int, Variant> */
public function visibleVariants(?int $includedVariantId = null): Collection
{
return $this->variants
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|| $this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0)
->values();
}
public function getPrice(): float
{
return (float) $this->precio;
@@ -183,14 +208,9 @@ class CatalogItem extends Model
return $this->nombre;
}
public function getMinimumUseDate(): ?CarbonInterface
public function getDescription(): ?string
{
return $this->minimum_use_date;
}
public function getMaximumUseDate(): ?CarbonInterface
{
return $this->maximum_use_date;
return $this->descripcion;
}
public function isBundle(): bool

View File

@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'catalog_item_id',
'attribute_id',
'allow_multi_select',
'sort_order',
])]
class ItemAttribute extends Model
{
@@ -18,6 +20,16 @@ class ItemAttribute extends Model
protected $table = 'item_attributes';
protected function casts(): array
{
return [
'catalog_item_id' => 'integer',
'attribute_id' => 'integer',
'allow_multi_select' => 'boolean',
'sort_order' => 'integer',
];
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{

View File

@@ -5,20 +5,20 @@ namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
#[Fillable([
'catalog_item_id',
'event_date_id',
'inventory_id',
'minimum_use_date',
'maximum_use_date',
'descripcion',
'precio',
])]
class Variant extends Model
{
@@ -34,8 +34,7 @@ class Variant extends Model
'catalog_item_id' => 'integer',
'event_date_id' => 'integer',
'inventory_id' => 'integer',
'minimum_use_date' => 'datetime',
'maximum_use_date' => 'datetime',
'precio' => 'decimal:2',
];
}
@@ -51,6 +50,17 @@ class Variant extends Model
return $this->belongsTo(EventDate::class);
}
/** @return BelongsToMany<EventDate, $this> */
public function eventDates(): BelongsToMany
{
return $this->belongsToMany(
EventDate::class,
'variant_event_dates',
'variant_id',
'event_date_id',
)->orderBy('date')->orderBy('time_start');
}
/** @return HasMany<Ticket, $this> */
public function sourceTickets(): HasMany
{
@@ -90,7 +100,12 @@ class Variant extends Model
public function getPrice(): float
{
return $this->catalogItem->getPrice();
return (float) ($this->precio ?? $this->catalogItem->precio);
}
public function getDescription(): ?string
{
return $this->descripcion ?? $this->catalogItem->descripcion;
}
public function getName(): string
@@ -98,17 +113,132 @@ class Variant extends Model
return $this->catalogItem->nombre;
}
public function getMinimumUseDate(): ?CarbonInterface
/** @return Collection<string, string|array<int, string>> */
public function selectionValues(): Collection
{
return $this->eventDate?->startsAt()
?? $this->minimum_use_date
?? $this->catalogItem->getMinimumUseDate();
$values = $this->definitions
->groupBy('item_attribute_id')
->mapWithKeys(function (Collection $definitions): array {
$itemAttribute = $definitions->first()?->itemAttribute;
$attributeCode = $itemAttribute?->attribute?->codigo;
if ($attributeCode === null) {
return [];
}
$definitionValues = $definitions->pluck('value')->values();
return [
$attributeCode => $itemAttribute->allow_multi_select
? $definitionValues->all()
: $definitionValues->first(),
];
});
$eventDateIds = $this->selectedEventDates()
->pluck('id')
->map(fn ($id): string => (string) $id)
->values();
if ($eventDateIds->count() === 1) {
$values->put('event_date', $eventDateIds->first());
} elseif ($eventDateIds->isNotEmpty()) {
$values->put('event_date', $eventDateIds->all());
}
return $values;
}
public function getMaximumUseDate(): ?CarbonInterface
/**
* @return Collection<string, array{value: string, label: string}|array<int, array{value: string, label: string}>>
*/
public function selectionOptions(?Collection $itemAttributes = null): Collection
{
return $this->eventDate?->endsAt()
?? $this->maximum_use_date
?? $this->catalogItem->getMaximumUseDate();
$options = $this->definitions
->groupBy('item_attribute_id')
->mapWithKeys(function (Collection $definitions): array {
$itemAttribute = $definitions->first()?->itemAttribute;
$attribute = $itemAttribute?->attribute;
$attributeCode = $attribute?->codigo;
if ($attributeCode === null) {
return [];
}
$values = $definitions
->pluck('value')
->values()
->map(function (string $value) use ($attribute): array {
$attributeOption = $attribute->options->firstWhere('value', $value);
return [
'value' => $value,
'label' => $attributeOption?->label ?? $value,
];
});
return [
$attributeCode => $itemAttribute->allow_multi_select
? $values->all()
: $values->first(),
];
});
$eventDateOptions = $this->selectedEventDates()
->map(fn (EventDate $eventDate): array => [
'value' => (string) $eventDate->id,
'label' => $eventDate->date->format('d/m/Y'),
])
->values();
if ($eventDateOptions->count() === 1) {
$options->put('event_date', $eventDateOptions->first());
} elseif ($eventDateOptions->isNotEmpty()) {
$options->put('event_date', $eventDateOptions->all());
}
if ($itemAttributes === null) {
$itemAttributes = $this->definitions
->map(fn (VariantDefinition $definition) => $definition->itemAttribute)
->filter()
->unique('id')
->values();
if ($this->catalogItem !== null) {
$itemAttributes = $itemAttributes
->merge($this->catalogItem->itemAttributes)
->unique('id')
->values();
}
}
$ordering = $itemAttributes->mapWithKeys(function (ItemAttribute $itemAttribute): array {
$attribute = $itemAttribute->attribute;
return $attribute === null
? []
: [$attribute->codigo => [$itemAttribute->sort_order, mb_strtolower($attribute->nombre)]];
});
return $options->sortKeysUsing(function (string $left, string $right) use ($ordering): int {
[$leftOrder, $leftLabel] = $ordering->get($left, [0, mb_strtolower($left)]);
[$rightOrder, $rightLabel] = $ordering->get($right, [0, mb_strtolower($right)]);
return $leftOrder <=> $rightOrder
?: $leftLabel <=> $rightLabel
?: $left <=> $right;
});
}
/** @return Collection<int, EventDate> */
public function selectedEventDates(): Collection
{
$eventDates = $this->eventDates;
if ($eventDates->isEmpty() && $this->event_date_id !== null && $this->eventDate !== null) {
return collect([$this->eventDate]);
}
return $eventDates;
}
}

View File

@@ -3,9 +3,9 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -26,20 +26,6 @@ class StoreCatalogItemRequest extends FormRequest
return [
'tenant_code' => ['prohibited'],
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
'event_id' => [
'sometimes',
'nullable',
'required_with:event_product_type',
Rule::exists('events', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'event_product_type' => [
'sometimes',
'nullable',
'required_with:event_id',
Rule::enum(EventProductType::class),
],
'category_id' => [
'sometimes',
'nullable',
@@ -68,9 +54,10 @@ class StoreCatalogItemRequest extends FormRequest
'descripcion' => ['sometimes', 'nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'minimum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date'],
'maximum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date', 'after_or_equal:minimum_use_date'],
'ticket_generation_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(TicketGenerationPolicy::class)],
'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')],
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
'inventory_id' => ['prohibited'],
'reserved_stock' => ['prohibited'],
@@ -84,30 +71,44 @@ class StoreCatalogItemRequest extends FormRequest
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'multi_select_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'multi_select_attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'images' => ['sometimes', 'array'],
'images.*' => ['required', new ImageOrBase64Rule],
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.descripcion' => ['sometimes', 'nullable', 'string'],
'variants.*.precio' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:99999999.99'],
'variants.*.event_date_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('event_id', $this->input('event_id'))
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.event_date_ids' => ['sometimes', 'array', 'min:1'],
'variants.*.event_date_ids.*' => [
'required',
'integer',
'distinct',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['prohibited'],
'variants.*.minimum_use_date' => ['sometimes', 'nullable', 'date'],
'variants.*.maximum_use_date' => [
'sometimes',
'nullable',
'date',
'after_or_equal:variants.*.minimum_use_date',
],
'variants.*.values' => ['sometimes', 'array'],
'variants.*.values.*' => ['nullable', 'string'],
'variants.*.values.*' => ['nullable'],
'variants.*.values.*.*' => ['required', 'string'],
'variants.*.images' => ['sometimes', 'array'],
'variants.*.images.*' => ['required', new ImageOrBase64Rule],
'components' => [

View File

@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -30,20 +31,23 @@ class CatalogFeaturedItemResource extends JsonResource
'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value,
'validity_time_id' => $catalogItem->validity_time_id,
'validity_time' => ValidityTimeResource::make($catalogItem->validityTime),
'stock_tecnico' => $catalogItem->availableStock(),
'variants' => $catalogItem->variants
'variants' => $catalogItem->visibleVariants()
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null),
'values' => $variant->selectionOptions($catalogItem->itemAttributes),
])
->values(),
];
@@ -62,6 +66,9 @@ class CatalogFeaturedItemResource extends JsonResource
'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio,
'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value,
'validity_time_id' => $catalogItem->validity_time_id,
'validity_time' => ValidityTimeResource::make($catalogItem->validityTime),
'image' => $attachment?->getTemporaryUrl(1440),
];
}

View File

@@ -6,6 +6,8 @@ use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
@@ -22,8 +24,6 @@ class CatalogItemDetailResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'event_id' => $this->event_id,
'event_product_type' => $this->event_product_type?->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
@@ -33,12 +33,14 @@ class CatalogItemDetailResource extends JsonResource
'category' => $this->category?->nombre,
'brand' => $this->brand?->nombre,
'inventory_policy' => $this->inventory_policy?->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date,
'maximum_use_date' => $this->maximum_use_date,
'attributes' => $this->itemAttributes
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
->values(),
'ticket_generation_policy' => $this->ticket_generation_policy->value,
'validity_time_id' => $this->validity_time_id,
'validity_time' => $this->validityTime === null
? null
: ValidityTimeResource::make($this->validityTime),
'attributes' => $this->attributesData(),
'stock_tecnico' => $this->when(
$selectedVariant === null,
fn () => $this->availableStock(),
@@ -47,7 +49,7 @@ class CatalogItemDetailResource extends JsonResource
$selectedVariant === null,
fn () => $this->imageUrls($this->attachments),
),
'variants' => $this->variants
'variants' => $this->visibleVariants()
->map(fn (Variant $variant): array => $this->variantData($variant))
->values(),
'selected_variant' => $this->when(
@@ -80,6 +82,44 @@ class CatalogItemDetailResource extends JsonResource
private function attributeData(ItemAttribute $itemAttribute): array
{
$attribute = $itemAttribute->attribute;
return [
'id' => $attribute->id,
'codigo' => $attribute->codigo,
'nombre' => $attribute->nombre,
'sort_order' => $itemAttribute->sort_order,
'is_required' => $attribute->is_required,
'allow_multi_select' => $itemAttribute->allow_multi_select,
'metadata_schema' => $attribute->metadata_schema,
'type' => $attribute->type->value,
'options' => $attribute->type === FieldType::EventDate
? $this->eventDateOptions($itemAttribute)
: $this->catalogAttributeOptions($itemAttribute),
];
}
/** @return Collection<int, array<string, mixed>> */
private function eventDateOptions(ItemAttribute $itemAttribute): Collection
{
return $itemAttribute->attribute->eventDates
->map(fn ($eventDate, int $index): array => [
'id' => $eventDate->id,
'value' => (string) $eventDate->id,
'label' => $eventDate->date->format('d/m/Y'),
'sort_order' => $index,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'metadata' => [
'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start,
'time_end' => $eventDate->time_end,
],
]);
}
/** @return Collection<int, array<string, mixed>> */
private function catalogAttributeOptions(ItemAttribute $itemAttribute): Collection
{
$availableValues = $this->variants
->flatMap->definitions
->where('item_attribute_id', $itemAttribute->id)
@@ -87,47 +127,50 @@ class CatalogItemDetailResource extends JsonResource
->filter()
->unique();
return [
'id' => $attribute->id,
'codigo' => $attribute->codigo,
'nombre' => $attribute->nombre,
'is_required' => $attribute->is_required,
'metadata_schema' => $attribute->metadata_schema,
'type' => $attribute->type->value,
'options' => $attribute->options
->whereIn('value', $availableValues)
->map(fn ($option): array => [
'id' => $option->id,
'value' => $option->value,
'label' => $option->label,
'sort_order' => $option->sort_order,
'metadata' => $option->metadata,
])
->values(),
];
return $itemAttribute->attribute->options
->whereIn('value', $availableValues)
->map(fn ($option): array => [
'id' => $option->id,
'value' => $option->value,
'label' => $option->label,
'sort_order' => $option->sort_order,
'validity_time_id' => $option->validity_time_id,
'validity_time' => $option->validityTime === null
? null
: ValidityTimeResource::make($option->validityTime),
'metadata' => $option->metadata,
])
->values();
}
/** @return Collection<int, array<string, mixed>> */
private function attributesData(): Collection
{
return $this->itemAttributes
->sort(function (ItemAttribute $left, ItemAttribute $right): int {
return $left->sort_order <=> $right->sort_order
?: mb_strtolower($left->attribute->nombre) <=> mb_strtolower($right->attribute->nombre);
})
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
->values();
}
/** @return array<string, mixed> */
private function variantData(Variant $variant): array
{
$values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates();
return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $eventDates->pluck('id')->values(),
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $this->variantStock($variant),
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->eventDate?->startsAt()
?? $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->eventDate?->endsAt()
?? $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null),
'values' => $values,
];
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -15,8 +16,6 @@ class CatalogItemResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'event_id' => $this->event_id,
'event_product_type' => $this->event_product_type?->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
@@ -24,9 +23,14 @@ class CatalogItemResource extends JsonResource
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'inventory_policy' => $this->inventory_policy?->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date,
'maximum_use_date' => $this->maximum_use_date,
'ticket_generation_policy' => $this->ticket_generation_policy->value,
'validity_time_id' => $this->validity_time_id,
'validity_time' => $this->whenLoaded(
'validityTime',
fn () => ValidityTimeResource::make($this->validityTime),
),
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
@@ -36,20 +40,12 @@ class CatalogItemResource extends JsonResource
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'real_stock' => $variant->inventory?->real_stock,
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->eventDate?->startsAt()
?? $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->eventDate?->endsAt()
?? $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key) => $key !== null),
'values' => $variant->selectionOptions($this->itemAttributes),
'images' => $variant->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values(),

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -25,21 +26,24 @@ class CatalogSearchItemResource extends JsonResource
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'ticket_generation_policy' => $this->ticket_generation_policy->value,
'validity_time_id' => $this->validity_time_id,
'validity_time' => ValidityTimeResource::make($this->validityTime),
'image' => $attachment?->getTemporaryUrl(1440),
'stock_tecnico' => $this->availableStock(),
'variants' => $this->variants
'variants' => $this->visibleVariants()
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null),
'values' => $variant->selectionOptions($this->itemAttributes),
])
->values(),
];

View File

@@ -5,14 +5,12 @@ namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
@@ -38,16 +36,35 @@ class CatalogService
$variants = $data['variants'] ?? [];
$images = $data['images'] ?? [];
$attributeCodes = $data['attribute_codes'] ?? [];
$multiSelectAttributeCodes = $data['multi_select_attribute_codes'] ?? [];
$components = $data['components'] ?? [];
$hasDirectStock = array_key_exists('real_stock', $data);
$realStock = (int) ($data['real_stock'] ?? 0);
$hasEventDateVariants = $variants !== [] && collect($variants)->every(
fn (array $variant): bool => ! empty($variant['event_date_id'])
|| ! empty($variant['event_date_ids'])
);
if ($hasEventDateVariants && ! in_array('event_date', $attributeCodes, true)) {
throw ValidationException::withMessages([
'attribute_codes' => [
__('api.catalog.event_date_attribute_required'),
],
]);
}
$hasVariants = $attributeCodes !== [] || $hasEventDateVariants;
$this->validateEventData($data);
if (array_diff($multiSelectAttributeCodes, $attributeCodes) !== []) {
throw ValidationException::withMessages([
'multi_select_attribute_codes' => [
__('api.catalog.multi_select_attribute_not_on_item'),
],
]);
}
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
if ($type === CatalogItemType::Bundle) {
$this->validateBundleData($data, $components);
@@ -70,6 +87,7 @@ class CatalogService
$data['variants'],
$data['images'],
$data['attribute_codes'],
$data['multi_select_attribute_codes'],
$data['components'],
$data['real_stock'],
$data['reserved_stock'],
@@ -83,8 +101,6 @@ class CatalogService
$data['inventory_id'] = null;
$data['inventory_policy'] = null;
$data['has_tickets'] = false;
$data['minimum_use_date'] = null;
$data['maximum_use_date'] = null;
} elseif ($hasVariants) {
$data['inventory_id'] = null;
} else {
@@ -93,7 +109,7 @@ class CatalogService
$catalogItem = CatalogItem::query()->create($data);
$itemAttributes = $type === CatalogItemType::Standard
? $this->createItemAttributes($catalogItem, $attributeCodes)
? $this->createItemAttributes($catalogItem, $attributeCodes, $multiSelectAttributeCodes)
: [];
if ($type === CatalogItemType::Bundle) {
@@ -129,12 +145,13 @@ class CatalogService
'inventory',
'category',
'brand',
'event',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
@@ -148,22 +165,25 @@ class CatalogService
'inventory',
'category',
'brand',
'event',
'itemAttributes.attribute.options',
'validityTime',
'itemAttributes.attribute.options.validityTime',
'itemAttributes.attribute.eventDates.validityTime',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.eventDates',
'variants.definitions' => fn ($query) => $query->orderBy('id'),
'variants.definitions.itemAttribute.attribute',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem.inventory',
'bundleComponents.variant.inventory',
'bundleComponents.variant.definitions.itemAttribute.attribute',
]);
$visibleVariants = $catalogItem->visibleVariants();
$selectedVariant = $variantId === null
? $catalogItem->variants->first()
: $catalogItem->variants->firstWhere('id', $variantId);
? $visibleVariants->first()
: $visibleVariants->firstWhere('id', $variantId);
if ($variantId !== null && $selectedVariant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
@@ -187,6 +207,7 @@ class CatalogService
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereVariantsAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
@@ -205,10 +226,13 @@ class CatalogService
->with([
'attachments',
'inventory',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
@@ -234,13 +258,17 @@ class CatalogService
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->whereVariantsAvailable()
->with([
'attachments',
'inventory',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
@@ -280,6 +308,39 @@ class CatalogService
});
}
public function deleteVariant(Variant $variant): void
{
DB::transaction(function () use ($variant): void {
$variant = Variant::query()
->with('attachments')
->lockForUpdate()
->findOrFail($variant->getKey());
$catalogItem = CatalogItem::query()
->lockForUpdate()
->findOrFail($variant->catalog_item_id);
$attachments = $variant->attachments;
$inventoryId = $variant->inventory_id;
$variant->attachments()->detach();
$variant->delete();
Inventory::query()->whereKey($inventoryId)->delete();
$minimumPrice = $catalogItem->variants()->min('precio');
if ($minimumPrice === null) {
$this->delete($catalogItem);
} else {
$catalogItem->update(['precio' => $minimumPrice]);
}
foreach ($attachments as $attachment) {
if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
$this->attachmentService->delete($attachment);
}
}
});
}
private function createInventory(int $realStock): Inventory
{
return Inventory::query()->create([
@@ -381,8 +442,8 @@ class CatalogService
'attribute_codes',
'variants',
'has_tickets',
'minimum_use_date',
'maximum_use_date',
'ticket_generation_policy',
'validity_time_id',
] as $field) {
if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([
@@ -426,11 +487,13 @@ class CatalogService
/**
* @param array<int, string> $attributeCodes
* @param array<int, string> $multiSelectAttributeCodes
* @return array<string, ItemAttribute>
*/
private function createItemAttributes(
CatalogItem $catalogItem,
array $attributeCodes,
array $multiSelectAttributeCodes = [],
): array {
$itemAttributes = [];
$attributeCodes = array_values(array_unique($attributeCodes));
@@ -453,6 +516,7 @@ class CatalogService
$itemAttribute = $catalogItem->itemAttributes()->create([
'attribute_id' => $attribute->id,
'allow_multi_select' => in_array($attributeCode, $multiSelectAttributeCodes, true),
]);
$itemAttributes[$attributeCode] = $itemAttribute;
@@ -486,35 +550,53 @@ class CatalogService
}
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
$eventDateId = $data['event_date_id'] ?? null;
if (
$eventDateId !== null
&& (
$catalogItem->event_id === null
|| ! EventDate::query()
->whereKey($eventDateId)
->where('event_id', $catalogItem->event_id)
->exists()
$eventDateIds = collect($data['event_date_ids'] ?? [])
->when(
isset($data['event_date_id']),
fn ($ids) => $ids->push($data['event_date_id']),
)
) {
->filter(fn ($id): bool => $id !== null)
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
$eventDateItemAttribute = $itemAttributes['event_date'] ?? null;
if ($eventDateItemAttribute !== null && (
$eventDateIds->isEmpty()
|| (! $eventDateItemAttribute->allow_multi_select && $eventDateIds->count() !== 1)
)) {
throw ValidationException::withMessages([
"variants.{$index}.event_date_id" => [
'The event date must belong to the catalog item event.',
"variants.{$index}.event_date_ids" => [
$eventDateItemAttribute->allow_multi_select
? __('api.catalog.event_date_selection_required')
: __('api.catalog.single_event_date_required'),
],
]);
}
$validEventDateCount = EventDate::query()
->whereKey($eventDateIds)
->where('tenant_code', $catalogItem->tenant_code)
->count();
if ($validEventDateCount !== $eventDateIds->count()) {
throw ValidationException::withMessages([
"variants.{$index}.event_date_ids" => [
__('api.catalog.event_date_wrong_tenant'),
],
]);
}
$variant = $catalogItem->variants()->create([
'inventory_id' => $inventory->id,
'event_date_id' => $eventDateId,
'minimum_use_date' => $data['minimum_use_date'] ?? null,
'maximum_use_date' => $data['maximum_use_date'] ?? null,
'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null,
'descripcion' => $data['descripcion'] ?? null,
'precio' => $data['precio'] ?? null,
]);
$variant->eventDates()->sync($eventDateIds->all());
$variant->setRelation('catalogItem', $catalogItem);
$this->validateVariantUseDates($variant, $index);
foreach ($data['values'] ?? [] as $attributeCode => $value) {
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
@@ -526,63 +608,130 @@ class CatalogService
]);
}
$variant->definitions()->create([
'item_attribute_id' => $itemAttribute->id,
'value' => $value,
]);
foreach ($this->validatedVariantValues(
$itemAttribute,
$value,
"variants.{$index}.values.{$attributeCode}",
) as $validatedValue) {
$variant->definitions()->create([
'item_attribute_id' => $itemAttribute->id,
'value' => $validatedValue,
]);
}
}
return $variant;
}
/** @param array<string, mixed> $data */
private function validateEventData(array $data): void
/**
* @param array<int, array<string, mixed>> $variants
* @param array<int, string> $attributeCodes
*/
private function validateUniqueVariantCombinations(array $variants, array $attributeCodes): void
{
$eventId = $data['event_id'] ?? null;
$eventProductType = $data['event_product_type'] ?? null;
$seen = [];
$attributeCodes = array_values(array_unique($attributeCodes));
sort($attributeCodes);
if (($eventId === null) !== ($eventProductType === null)) {
throw ValidationException::withMessages([
'event_id' => ['Event and event product type must be provided together.'],
]);
}
foreach (array_values($variants) as $index => $variant) {
$eventDateIds = collect($variant['event_date_ids'] ?? [])
->when(
isset($variant['event_date_id']),
fn ($ids) => $ids->push($variant['event_date_id']),
)
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values()
->implode(',');
$combination = [$eventDateIds];
if ($eventId === null) {
return;
}
foreach ($attributeCodes as $attributeCode) {
$values = $variant['values'][$attributeCode] ?? '';
$normalizedValues = collect(is_array($values) ? $values : [$values])
->map(fn ($value): string => $this->normalizeVariantValue((string) $value))
->unique()
->sort()
->values()
->implode(',');
$combination[] = $normalizedValues;
}
if (! Event::query()
->whereKey($eventId)
->where('tenant_code', $data['tenant_code'] ?? null)
->exists()) {
throw ValidationException::withMessages([
'event_id' => ['The event must belong to the catalog item tenant.'],
]);
}
$key = implode('|', $combination);
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"variants.{$index}" => [__('api.catalog.duplicate_variant_combination')],
]);
}
if (! in_array($eventProductType, EventProductType::values(), true)) {
throw ValidationException::withMessages([
'event_product_type' => ['The event product type is invalid.'],
]);
$seen[$key] = true;
}
}
private function validateVariantUseDates(Variant $variant, int $index): void
{
$minimumUseDate = $variant->getMinimumUseDate();
$maximumUseDate = $variant->getMaximumUseDate();
/** @return array<int, string> */
private function validatedVariantValues(
ItemAttribute $itemAttribute,
mixed $value,
string $validationKey,
): array {
$values = is_array($value) ? array_values($value) : [$value];
if (
$minimumUseDate !== null
&& $maximumUseDate !== null
&& $maximumUseDate->lessThan($minimumUseDate)
) {
if ($values === [] || (! $itemAttribute->allow_multi_select && count($values) !== 1)) {
throw ValidationException::withMessages([
"variants.{$index}.maximum_use_date" => [
__('api.catalog.invalid_effective_date_range'),
$validationKey => [
$itemAttribute->allow_multi_select
? __('api.catalog.multi_value_required')
: __('api.catalog.single_value_required'),
],
]);
}
if (collect($values)->contains(fn ($item): bool => ! is_string($item) || trim($item) === '')) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.selected_values_non_empty')],
]);
}
$normalizedValues = collect($values)
->map(fn (string $item): string => $this->normalizeVariantValue($item));
if ($normalizedValues->unique()->count() !== count($values)) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.selected_values_distinct')],
]);
}
$attribute = $itemAttribute->attribute;
if ($attribute->type->supportsOptions() && ! $attribute->type->usesDynamicOptions()) {
$optionsByNormalizedValue = $attribute->options
->keyBy(fn ($option): string => $this->normalizeVariantValue($option->value));
$resolvedOptions = $normalizedValues->map(fn (string $normalizedValue) => $optionsByNormalizedValue->get($normalizedValue));
if ($resolvedOptions->contains(null)) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.invalid_attribute_options')],
]);
}
$validityTimeIds = $resolvedOptions
->pluck('validity_time_id')
->filter()
->unique();
if ($validityTimeIds->count() > 1) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.incompatible_validity_windows')],
]);
}
return $resolvedOptions->pluck('value')->all();
}
return collect($values)->map(fn (string $item): string => trim($item))->all();
}
private function normalizeVariantValue(string $value): string
{
return Str::ascii(mb_strtolower(trim($value)));
}
/**

View File

@@ -37,13 +37,17 @@ class FeaturedGroupService
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->whereVariantsAvailable()
->with([
'inventory',
'attachments',
'validityTime',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);

View File

@@ -0,0 +1,33 @@
# Dominio Catalog
## Propósito
Modela y publica la oferta comercial del tenant: productos, variantes, categorías, marcas, atributos, inventario, bundles y grupos destacados.
## Modelo
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
- `Inventory` administra stock disponible, reservado y comprado.
- `Category` soporta jerarquía y categorías globales o propias del tenant.
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
- `BundleComponent` representa los componentes de un paquete.
## Servicios
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
## Endpoints de tienda
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
## Endpoints administrativos
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
## Dependencias y reglas
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.

View File

@@ -15,14 +15,14 @@ class EventController extends Controller
public function show(Request $request): EventResource
{
return EventResource::make(
$this->eventService->activeForTenant($request->user()->tenant()->firstOrFail())
$this->eventService->forTenant($request->user()->tenant()->firstOrFail())
);
}
public function update(UpdateEventRequest $request): EventResource
{
return EventResource::make(
$this->eventService->updateActiveForTenant(
$this->eventService->updateForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated()
)

View File

@@ -1,48 +0,0 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'name',
'address',
])]
class Event extends Model
{
use HasFactory;
public $timestamps = false;
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return HasMany<EventDate, $this> */
public function dates(): HasMany
{
return $this->hasMany(EventDate::class)->orderBy('date')->orderBy('time_start');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
/** @return HasMany<Purchase, $this> */
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class);
}
}

View File

@@ -3,16 +3,21 @@
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Services\EventDateTextFormatter;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
#[Fillable([
'event_id',
'tenant_code',
'date',
'time_start',
'time_end',
@@ -23,18 +28,44 @@ class EventDate extends Model
public $timestamps = false;
protected static function booted(): void
{
static::creating(fn (self $eventDate) => $eventDate->syncValidityTime());
static::created(fn (self $eventDate) => $eventDate->syncTenantDateText());
static::updated(function (self $eventDate): void {
if ($eventDate->wasChanged(['date', 'time_start', 'time_end'])) {
$eventDate->syncValidityTime();
}
$eventDate->syncTenantDateText();
});
static::deleted(function (self $eventDate): void {
$eventDate->syncTenantDateText();
ValidityTime::query()
->whereKey($eventDate->validity_time_id)
->whereDoesntHave('ticketValidityGroups')
->delete();
});
}
protected function casts(): array
{
return [
'event_id' => 'integer',
'date' => 'date:Y-m-d',
'validity_time_id' => 'integer',
];
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Event::class);
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return HasMany<Variant, $this> */
@@ -43,6 +74,17 @@ class EventDate extends Model
return $this->hasMany(Variant::class);
}
/** @return BelongsToMany<Variant, $this> */
public function selectedByVariants(): BelongsToMany
{
return $this->belongsToMany(
Variant::class,
'variant_event_dates',
'event_date_id',
'variant_id',
);
}
public function startsAt(): CarbonInterface
{
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start);
@@ -52,4 +94,48 @@ class EventDate extends Model
{
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
}
private function syncTenantDateText(): void
{
$tenant = $this->tenant()->first();
if (! $tenant) {
return;
}
$tenant->update([
'event_date_text' => app(EventDateTextFormatter::class)->format(
$tenant->eventDates()->pluck('date')
),
]);
}
private function syncValidityTime(): void
{
$startsAt = $this->startsAt();
$expiresAt = $this->endsAt();
if ($expiresAt->lessThanOrEqualTo($startsAt)) {
$expiresAt = $expiresAt->addDay();
}
$attributes = [
'type' => ValidityTimeType::FixedWindow,
'start_time' => null,
'end_time' => null,
'fixed_starts_at' => $startsAt,
'fixed_expires_at' => $expiresAt,
];
if ($this->validity_time_id === null) {
$validityTime = ValidityTime::query()->create($attributes);
$this->validity_time_id = $validityTime->getKey();
$this->setRelation('validityTime', $validityTime);
return;
}
$this->validityTime()->update($attributes);
$this->unsetRelation('validityTime');
}
}

View File

@@ -2,29 +2,32 @@
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Event */
/** @mixin Tenant */
class EventResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$socialMedia = $this->tenant->socialMedia->keyBy('code');
$socialMedia = $this->socialMedia->keyBy('code');
return [
'id' => $this->id,
'title' => $this->name,
'location' => $this->address,
'dates' => $this->dates->map(fn ($eventDate): array => [
'title' => $this->event_title,
'location' => $this->event_location,
'dates' => $this->eventDates->map(fn ($eventDate): array => [
'id' => $eventDate->id,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'date' => $eventDate->date->format('Y-m-d'),
'start_time' => substr($eventDate->time_start, 0, 5),
'end_time' => substr($eventDate->time_end, 0, 5),
])->values(),
'social_media' => $this->tenant->socialMedia->map(fn ($item): array => [
'social_media' => $this->socialMedia->map(fn ($item): array => [
'code' => $item->code,
'url' => $item->pivot->url,
'orden' => $item->pivot->orden,

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Domains\Event\Services;
use DateTimeImmutable;
class EventDateTextFormatter
{
/** @var array<int, string> */
private const MONTHS = [
1 => 'Enero',
2 => 'Febrero',
3 => 'Marzo',
4 => 'Abril',
5 => 'Mayo',
6 => 'Junio',
7 => 'Julio',
8 => 'Agosto',
9 => 'Septiembre',
10 => 'Octubre',
11 => 'Noviembre',
12 => 'Diciembre',
];
/** @param iterable<string> $dates */
public function format(iterable $dates): ?string
{
$normalizedDates = collect($dates)
->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date))
->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
->sortBy(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
->values();
if ($normalizedDates->isEmpty()) {
return null;
}
$years = $normalizedDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y'))
->map(function ($yearDates, string $year): string {
$months = $yearDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('n'))
->map(function ($monthDates, string $month): string {
$days = $monthDates
->map(fn (DateTimeImmutable $date): string => (string) ((int) $date->format('j')))
->values()
->all();
return $this->join($days).' de '.self::MONTHS[(int) $month];
})
->values()
->all();
return $this->join($months).' '.$year;
})
->values()
->all();
return $this->join($years);
}
/** @param array<int, string> $parts */
private function join(array $parts): string
{
if (count($parts) <= 1) {
return $parts[0] ?? '';
}
$last = array_pop($parts);
return implode(', ', $parts).' y '.$last;
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
@@ -14,53 +13,36 @@ class EventService
'facebook_url' => 'facebook',
];
public function activeForTenant(Tenant $tenant): Event
public function forTenant(Tenant $tenant): Tenant
{
return $tenant->events()
->whereKey($tenant->active_event_id)
->with(['dates', 'tenant.socialMedia'])
->firstOrFail();
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
}
/** @param array<string, mixed> $data */
public function updateActiveForTenant(Tenant $tenant, array $data): Event
public function updateForTenant(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Event {
return DB::transaction(function () use ($tenant, $data): Tenant {
$tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
$event = $tenant->active_event_id === null
? $tenant->events()->create([
'name' => $data['title'],
'address' => $data['location'],
])
: $tenant->events()
->whereKey($tenant->active_event_id)
->lockForUpdate()
->firstOrFail();
$event->update([
'name' => $data['title'],
'address' => $data['location'],
$tenant->update([
'event_title' => $data['title'],
'event_location' => $data['location'],
]);
if ($tenant->active_event_id === null) {
$tenant->update(['active_event_id' => $event->id]);
}
$this->syncDates($event, $data['dates']);
$this->syncDates($tenant, $data['dates']);
if (array_key_exists('social_media', $data)) {
$this->syncSocialMedia($tenant, $data['social_media']);
} else {
$this->syncLegacyContact($tenant, $data['contact']);
}
return $event->load(['dates', 'tenant.socialMedia']);
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
});
}
/** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */
private function syncDates(Event $event, array $dates): void
private function syncDates(Tenant $tenant, array $dates): void
{
$existingDates = $event->dates()->get()->values();
$existingDates = $tenant->eventDates()->get()->values();
foreach (array_values($dates) as $index => $date) {
$attributes = [
@@ -74,12 +56,12 @@ class EventService
if ($existingDate) {
$existingDate->update($attributes);
} else {
$event->dates()->create($attributes);
$tenant->eventDates()->create($attributes);
}
}
$existingDates->slice(count($dates))->each->delete();
$event->unsetRelation('dates');
$tenant->unsetRelation('eventDates');
}
/** @param array<string, string|null> $contact */

View File

@@ -0,0 +1,28 @@
# Dominio Event
## Propósito
Administra la configuración temporal de un tenant orientado a eventos y sus fechas disponibles.
## Componentes
- `Models/EventDate.php`: fecha del evento con inicio, fin, tenant y variantes asociadas.
- `Services/EventService.php`: obtiene y actualiza la configuración de evento del tenant.
- `Controllers/AdminApp/EventController.php`: consulta y modificación desde AdminApp.
- `UpdateEventRequest`: valida datos y reglas cruzadas de fechas.
- `EventResource`: serializa la configuración de salida.
## Endpoints
Bajo `/v1/adminapp/tenant/event`, protegidos por `auth:sanctum` y `adminapp.tenant`:
- `GET`: obtiene la configuración.
- `PUT`: actualiza la configuración.
## Dependencias
Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets.
## Consideraciones
El archivo `routes/api.php` no publica operaciones adicionales. Al modificar fechas debe mantenerse la validación de orden y coherencia temporal de `UpdateEventRequest`.

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertAccommodationVariantsRequest;
use App\Domains\FiestaFutbolInfantil\Resources\AccommodationResource;
use App\Domains\FiestaFutbolInfantil\Services\AccommodationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class AccommodationController extends Controller
{
public function __construct(private readonly AccommodationService $accommodationService) {}
public function index(Request $request): AccommodationResource
{
return AccommodationResource::make(
$this->accommodationService->current($request->user()->tenant()->firstOrFail())
);
}
public function store(UpsertAccommodationVariantsRequest $request): AccommodationResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return AccommodationResource::make(
$this->accommodationService->upsertMany(
$tenant,
$request->validated('variants'),
)
);
}
public function destroy(Request $request, int $accommodation): Response
{
$this->accommodationService->delete(
$request->user()->tenant()->firstOrFail(),
$accommodation,
);
return response()->noContent();
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertEntriesRequest;
use App\Domains\FiestaFutbolInfantil\Resources\EntryResource;
use App\Domains\FiestaFutbolInfantil\Services\EntryService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class EntryController extends Controller
{
public function __construct(private readonly EntryService $entryService) {}
public function index(Request $request): AnonymousResourceCollection
{
return EntryResource::collection(
$this->entryService->all($request->user()->tenant()->firstOrFail())
);
}
public function store(UpsertEntriesRequest $request): JsonResponse
{
$tenant = $request->user()->tenant()->firstOrFail();
$entries = $this->entryService->upsertMany(
$tenant,
$request->validated('entries'),
);
return EntryResource::collection($entries)
->response()
->setStatusCode(200);
}
public function destroy(Request $request, int $entry): Response
{
$this->entryService->delete(
$request->user()->tenant()->firstOrFail(),
$entry,
);
return response()->noContent();
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest;
use App\Domains\FiestaFutbolInfantil\Resources\FoodResource;
use App\Domains\FiestaFutbolInfantil\Services\FoodService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class FoodController extends Controller
{
public function __construct(private readonly FoodService $foodService) {}
public function index(Request $request): FoodResource
{
return FoodResource::make(
$this->foodService->current($request->user()->tenant()->firstOrFail())
);
}
public function store(UpsertFoodVariantsRequest $request): FoodResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return FoodResource::make(
$this->foodService->upsertMany(
$tenant,
$request->validated('variants'),
)
);
}
public function destroy(Request $request, int $food): Response
{
$this->foodService->delete(
$request->user()->tenant()->firstOrFail(),
$food,
);
return response()->noContent();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Requests\UpsertMerchandiseRequest;
use App\Domains\FiestaFutbolInfantil\Resources\MerchandiseResource;
use App\Domains\FiestaFutbolInfantil\Services\MerchandiseService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class MerchandiseController extends Controller
{
public function __construct(private readonly MerchandiseService $merchandiseService) {}
public function index(Request $request): AnonymousResourceCollection
{
return MerchandiseResource::collection(
$this->merchandiseService->all($request->user()->tenant()->firstOrFail())
);
}
public function store(UpsertMerchandiseRequest $request): JsonResponse
{
$tenant = $request->user()->tenant()->firstOrFail();
$items = $this->merchandiseService->upsertMany(
$tenant,
$request->validated('items'),
);
return MerchandiseResource::collection($items)
->response()
->setStatusCode(200);
}
public function destroy(Request $request, int $merchandise): Response
{
$this->merchandiseService->delete(
$request->user()->tenant()->firstOrFail(),
$merchandise,
);
return response()->noContent();
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpsertAccommodationVariantsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'variants' => ['required', 'array', 'min:1', 'max:500'],
'variants.*' => ['required', 'array:id,title,description,stock,price'],
'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
'variants.*.title' => ['required', 'string', 'max:255'],
'variants.*.description' => ['sometimes', 'nullable', 'string'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpsertEntriesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->user()?->tenant_codigo;
return [
'entries' => ['required', 'array', 'min:1', 'max:100'],
'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,price'],
'entries.*.id' => [
'sometimes',
'nullable',
'integer',
'distinct',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query
->where('tenant_code', $tenantCode)
->whereIn('category_id', fn ($categoryQuery) => $categoryQuery
->select('id')
->from('categorias')
->where('tenant_code', $tenantCode)
->where('nombre', 'Entradas'))
),
],
'entries.*.title' => ['required', 'string', 'max:255'],
'entries.*.description' => ['sometimes', 'nullable', 'string'],
'entries.*.event_date_ids' => ['required', 'array', 'min:1'],
'entries.*.event_date_ids.*' => [
'required',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'entries.*.stock' => ['required', 'integer', 'min:0'],
'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
}
/** @return array<int, callable> */
public function after(): array
{
return [
function (Validator $validator): void {
foreach ($this->input('entries', []) as $index => $entry) {
if (! is_array($entry)) {
continue;
}
$dateIds = $entry['event_date_ids'] ?? [];
if (! is_array($dateIds)) {
continue;
}
if (count($dateIds) !== count(array_unique($dateIds))) {
$validator->errors()->add(
"entries.{$index}.event_date_ids",
'Las fechas de una entrada no pueden repetirse.',
);
}
}
},
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpsertFoodVariantsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->user()?->tenant_codigo;
return [
'variants' => ['required', 'array', 'min:1', 'max:500'],
'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,price'],
'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
'variants.*.event_date_id' => [
'required',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.schedule' => ['required', 'string', 'max:255'],
'variants.*.service' => ['required', 'string', 'max:255'],
'variants.*.description' => ['sometimes', 'nullable', 'string'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
}
/** @return array<int, callable> */
public function after(): array
{
return [
function (Validator $validator): void {
$seen = [];
foreach ($this->input('variants', []) as $index => $variant) {
if (! is_array($variant)) {
continue;
}
$key = implode('|', [
$variant['event_date_id'] ?? '',
mb_strtolower(trim((string) ($variant['schedule'] ?? ''))),
mb_strtolower(trim((string) ($variant['service'] ?? ''))),
]);
if (isset($seen[$key])) {
$validator->errors()->add(
"variants.{$index}",
'La combinación de fecha, horario y servicio no puede repetirse.',
);
}
$seen[$key] = true;
}
},
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpsertMerchandiseRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->user()?->tenant_codigo;
return [
'items' => ['required', 'array', 'min:1', 'max:100'],
'items.*' => ['required', 'array:id,title,description,max_units_per_user,variants'],
'items.*.id' => [
'sometimes',
'nullable',
'integer',
'distinct',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query
->where('tenant_code', $tenantCode)
->whereIn('category_id', fn ($categoryQuery) => $categoryQuery
->select('id')
->from('categorias')
->where('tenant_code', $tenantCode)
->where('nombre', 'Merchandising'))
),
],
'items.*.title' => ['required', 'string', 'max:255'],
'items.*.description' => ['sometimes', 'nullable', 'string'],
'items.*.max_units_per_user' => ['required', 'integer', 'min:1'],
'items.*.variants' => ['required', 'array', 'min:1', 'max:500'],
'items.*.variants.*' => ['required', 'array:id,color,size,stock,price'],
'items.*.variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
'items.*.variants.*.color' => ['required', 'string', 'max:255'],
'items.*.variants.*.size' => ['required', 'string', 'max:255'],
'items.*.variants.*.stock' => ['required', 'integer', 'min:0'],
'items.*.variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class AccommodationResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
if ($this->resource === null) {
return [
'id' => null,
'name' => 'Alojamiento',
'variants' => [],
];
}
$typeAttribute = $this->itemAttributes
->first(fn ($itemAttribute) => $itemAttribute->attribute?->codigo === 'tipo_alojamiento');
$options = $typeAttribute?->attribute?->options?->keyBy('value') ?? collect();
return [
'id' => $this->id,
'name' => $this->nombre,
'variants' => $this->variants->map(function ($variant) use ($typeAttribute, $options): array {
$value = $variant->definitions
->firstWhere('item_attribute_id', $typeAttribute?->id)
?->value;
return [
'id' => $variant->id,
'title' => $options->get($value)?->label ?? $value,
'value' => $value,
'description' => $variant->descripcion,
'stock' => $variant->inventory->real_stock,
'price' => number_format($variant->getPrice(), 2, '.', ''),
];
})->values(),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class EntryResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$variant = $this->variants->sole();
return [
'id' => $this->id,
'title' => $this->nombre,
'description' => $this->descripcion,
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'stock' => $variant->inventory->real_stock,
'price' => $this->precio,
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class FoodResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
if ($this->resource === null) {
return [
'id' => null,
'name' => 'Comida',
'variants' => [],
];
}
return [
'id' => $this->id,
'name' => $this->nombre,
'variants' => $this->variants->map(function ($variant): array {
$values = $variant->selectionValues();
$eventDate = $variant->selectedEventDates()->first();
return [
'id' => $variant->id,
'event_date_id' => $eventDate?->id,
'event_date' => $eventDate?->date?->format('Y-m-d'),
'schedule' => $values->get('horario'),
'service' => $values->get('servicio'),
'description' => $variant->descripcion,
'stock' => $variant->inventory->real_stock,
'price' => number_format($variant->getPrice(), 2, '.', ''),
];
})->values(),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class MerchandiseResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$itemAttributes = $this->itemAttributes->keyBy(
fn ($itemAttribute) => $itemAttribute->attribute?->codigo
);
$colorAttribute = $itemAttributes->get('color');
$sizeAttribute = $itemAttributes->get('talle');
$colorOptions = $colorAttribute?->attribute?->options?->keyBy('value') ?? collect();
$sizeOptions = $sizeAttribute?->attribute?->options?->keyBy('value') ?? collect();
return [
'id' => $this->id,
'title' => $this->nombre,
'description' => $this->descripcion,
'max_units_per_user' => $this->max_units_per_user,
'variants' => $this->variants->map(function ($variant) use (
$colorAttribute,
$sizeAttribute,
$colorOptions,
$sizeOptions,
): array {
$colorValue = $variant->definitions
->firstWhere('item_attribute_id', $colorAttribute?->id)
?->value;
$sizeValue = $variant->definitions
->firstWhere('item_attribute_id', $sizeAttribute?->id)
?->value;
return [
'id' => $variant->id,
'color' => $colorOptions->get($colorValue)?->label ?? $colorValue,
'color_value' => $colorValue,
'size' => $sizeOptions->get($sizeValue)?->label ?? $sizeValue,
'size_value' => $sizeValue,
'stock' => $variant->inventory->real_stock,
'price' => number_format($variant->getPrice(), 2, '.', ''),
];
})->values(),
];
}
}

View File

@@ -0,0 +1,298 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class AccommodationService
{
private const ATTRIBUTE_CODE = 'tipo_alojamiento';
public function __construct(private readonly CatalogService $catalogService) {}
public function current(Tenant $tenant): ?CatalogItem
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'alojamiento')
->with([
'itemAttributes.attribute.options',
'variants.catalogItem',
'variants.inventory',
'variants.definitions',
])
->first();
}
/**
* @param array<int, array<string, mixed>> $variants
*/
public function upsertMany(Tenant $tenant, array $variants): CatalogItem
{
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
$attribute = $this->attribute($tenant);
$accommodation = $this->accommodation($tenant, $variants);
$itemAttribute = $accommodation->itemAttributes()->firstOrCreate(
['attribute_id' => $attribute->id],
['allow_multi_select' => false],
);
$existingVariants = $accommodation->variants()
->with(['inventory', 'definitions'])
->lockForUpdate()
->get();
$resolvedVariants = $this->resolveVariants($variants);
$this->validateValues($resolvedVariants, $existingVariants, $itemAttribute);
foreach ($resolvedVariants as $index => $data) {
$variant = isset($data['id'])
? $existingVariants->firstWhere('id', (int) $data['id'])
: null;
if (isset($data['id']) && $variant === null) {
throw ValidationException::withMessages([
"variants.{$index}.id" => ['La variante no pertenece al producto Alojamiento.'],
]);
}
if ($variant === null) {
$this->createVariant($attribute, $accommodation, $itemAttribute, $data);
} else {
$this->updateVariant($attribute, $variant, $itemAttribute, $data, $index);
}
}
$minimumPrice = $accommodation->variants()->min('precio');
if ($minimumPrice !== null) {
$accommodation->update(['precio' => $minimumPrice]);
}
return $accommodation->fresh()->load([
'itemAttributes.attribute.options',
'variants.catalogItem',
'variants.inventory',
'variants.definitions',
]);
});
}
public function delete(Tenant $tenant, int $accommodationId): void
{
$variant = Variant::query()
->whereKey($accommodationId)
->whereHas('catalogItem', fn ($query) => $query
->where('tenant_code', $tenant->codigo)
->where('slug', 'alojamiento'))
->firstOrFail();
$this->catalogService->deleteVariant($variant);
}
private function attribute(Tenant $tenant): Attribute
{
$attribute = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->where('codigo', self::ATTRIBUTE_CODE)
->with('options')
->lockForUpdate()
->first();
if ($attribute === null) {
throw ValidationException::withMessages([
'variants' => ['Falta el atributo requerido tipo_alojamiento.'],
]);
}
return $attribute;
}
/** @param array<int, array<string, mixed>> $variants */
private function accommodation(Tenant $tenant, array $variants): CatalogItem
{
$category = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => 'Alojamientos',
]);
$accommodation = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'alojamiento')
->lockForUpdate()
->first();
if ($accommodation !== null) {
$accommodation->update([
'category_id' => $category->id,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
return $accommodation;
}
return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'alojamiento',
'nombre' => 'Alojamiento',
'descripcion' => 'Alojamiento',
'category_id' => $category->id,
'precio' => collect($variants)->min('price') ?? 0,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null,
]);
}
/**
* @param array<int, array<string, mixed>> $variants
* @return array<int, array<string, mixed>>
*/
private function resolveVariants(array $variants): array
{
return collect($variants)->map(fn (array $variant): array => [
...$variant,
'title' => trim($variant['title']),
'value' => $this->valueCode($variant['title']),
'description' => $variant['description'] ?? null,
'stock' => (int) $variant['stock'],
])->all();
}
/**
* @param array<int, array<string, mixed>> $incoming
* @param Collection<int, Variant> $existing
*/
private function validateValues(array $incoming, Collection $existing, ItemAttribute $itemAttribute): void
{
$incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id);
$seen = [];
foreach ($existing->whereNotIn('id', $incomingIds) as $variant) {
$value = $variant->definitions->firstWhere('item_attribute_id', $itemAttribute->id)?->value;
if ($value !== null) {
$seen[mb_strtolower(trim($value))] = true;
}
}
foreach ($incoming as $index => $variant) {
$value = $variant['value'];
if (isset($seen[$value])) {
throw ValidationException::withMessages([
"variants.{$index}.title" => ['Ya existe un tipo de alojamiento con ese título.'],
]);
}
$seen[$value] = true;
}
}
/** @param array<string, mixed> $data */
private function createVariant(
Attribute $attribute,
CatalogItem $accommodation,
ItemAttribute $itemAttribute,
array $data,
): void {
$this->createOption($attribute, $data['value'], $data['title']);
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
$variant = $accommodation->variants()->create([
'inventory_id' => $inventory->id,
'descripcion' => $data['description'],
'precio' => $data['price'],
]);
$variant->definitions()->create([
'item_attribute_id' => $itemAttribute->id,
'value' => $data['value'],
]);
}
/** @param array<string, mixed> $data */
private function updateVariant(
Attribute $attribute,
Variant $variant,
ItemAttribute $itemAttribute,
array $data,
int $index,
): void {
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
if ($data['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"variants.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$definition = $variant->definitions
->firstWhere('item_attribute_id', $itemAttribute->id);
$option = $definition === null
? null
: $attribute->options->firstWhere('value', $definition->value);
if ($option === null) {
$this->createOption($attribute, $data['value'], $data['title']);
} else {
$option->update([
'value' => $data['value'],
'label' => $data['title'],
]);
}
$variant->update([
'descripcion' => $data['description'],
'precio' => $data['price'],
]);
$inventory->update(['real_stock' => $data['stock']]);
$variant->definitions()->updateOrCreate(
['item_attribute_id' => $itemAttribute->id],
['value' => $data['value']],
);
}
private function createOption(Attribute $attribute, string $value, string $label): AttributeOption
{
$existing = $attribute->options->first(
fn (AttributeOption $option): bool => mb_strtolower($option->value) === $value
);
if ($existing !== null) {
$existing->update(['label' => $label]);
return $existing;
}
$option = $attribute->options()->create([
'value' => $value,
'label' => $label,
'sort_order' => ((int) $attribute->options->max('sort_order')) + 1,
]);
$attribute->options->push($option);
return $option;
}
private function valueCode(string $title): string
{
return mb_strtolower((string) preg_replace('/\s+/u', '_', trim($title)));
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class EntryService
{
public function __construct(private readonly CatalogService $catalogService) {}
/** @return Collection<int, CatalogItem> */
public function all(Tenant $tenant): Collection
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->with([
'variants.inventory',
'variants.eventDate',
'variants.eventDates',
])
->orderBy('id')
->get();
}
/**
* @param array<int, array<string, mixed>> $entries
* @return Collection<int, CatalogItem>
*/
public function upsertMany(Tenant $tenant, array $entries): Collection
{
return DB::transaction(function () use ($tenant, $entries): Collection {
$reservedSlugs = [];
$category = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => 'Entradas',
]);
return collect($entries)->map(function (array $entry, int $index) use ($tenant, $category, &$reservedSlugs): CatalogItem {
if (isset($entry['id'])) {
return $this->update($tenant, $category, $entry, $index);
}
$slug = $this->uniqueSlug($tenant, $entry['title'], $reservedSlugs);
$reservedSlugs[] = $slug;
return $this->catalogService->create([
'tenant_code' => $tenant->codigo,
'slug' => $slug,
'nombre' => $entry['title'],
'descripcion' => $entry['description'] ?? null,
'category_id' => $category->id,
'precio' => $entry['price'],
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'attribute_codes' => ['event_date'],
'multi_select_attribute_codes' => ['event_date'],
'variants' => [[
'real_stock' => $entry['stock'],
'event_date_ids' => array_values($entry['event_date_ids']),
]],
]);
})->values();
});
}
public function delete(Tenant $tenant, int $entryId): void
{
$entry = CatalogItem::query()
->whereKey($entryId)
->where('tenant_code', $tenant->codigo)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->firstOrFail();
$this->catalogService->delete($entry);
}
/** @param array<string, mixed> $entry */
private function update(Tenant $tenant, Category $category, array $entry, int $index): CatalogItem
{
$catalogItem = CatalogItem::query()
->whereKey($entry['id'])
->where('tenant_code', $tenant->codigo)
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
->lockForUpdate()
->firstOrFail();
$variants = Variant::query()
->where('catalog_item_id', $catalogItem->id)
->lockForUpdate()
->get();
if ($variants->count() !== 1) {
throw ValidationException::withMessages([
"entries.{$index}.id" => [
'La entrada no posee una única variante editable.',
],
]);
}
$variant = $variants->first();
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
if ((int) $entry['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"entries.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$eventDateIds = collect($entry['event_date_ids'])
->map(fn ($id): int => (int) $id)
->unique()
->values();
$catalogItem->update([
'nombre' => $entry['title'],
'descripcion' => $entry['description'] ?? null,
'category_id' => $category->id,
'precio' => $entry['price'],
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
]);
$variant->update([
'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null,
]);
$variant->eventDates()->sync($eventDateIds->all());
$catalogItem->itemAttributes()
->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date'))
->update(['allow_multi_select' => true]);
$inventory->update(['real_stock' => $entry['stock']]);
return $catalogItem->load([
'variants.inventory',
'variants.eventDate',
'variants.eventDates',
]);
}
/** @param array<int, string> $reservedSlugs */
private function uniqueSlug(Tenant $tenant, string $title, array $reservedSlugs): string
{
$baseSlug = Str::slug($title) ?: 'entrada';
$slug = $baseSlug;
$suffix = 2;
while (
in_array($slug, $reservedSlugs, true)
|| CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', $slug)
->exists()
) {
$slug = "{$baseSlug}-{$suffix}";
$suffix++;
}
return $slug;
}
}

View File

@@ -0,0 +1,331 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class FoodService
{
private const ATTRIBUTE_CODES = ['event_date', 'horario', 'servicio'];
private const ATTRIBUTE_SORT_ORDERS = [
'event_date' => 1,
'horario' => 2,
'servicio' => 3,
];
public function __construct(private readonly CatalogService $catalogService) {}
public function current(Tenant $tenant): ?CatalogItem
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida')
->with([
'variants.catalogItem',
'variants.inventory',
'variants.eventDate',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute',
])
->first();
}
/**
* @param array<int, array<string, mixed>> $variants
*/
public function upsertMany(Tenant $tenant, array $variants): CatalogItem
{
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
$attributes = $this->attributes($tenant);
$food = $this->food($tenant, $variants);
$itemAttributes = $this->itemAttributes($food, $attributes);
$food->variants()->whereNull('precio')->update(['precio' => $food->precio]);
$existingVariants = $food->variants()
->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute'])
->lockForUpdate()
->get();
$resolvedVariants = $this->resolveVariants($variants, $attributes);
$this->validateCombinations($resolvedVariants, $existingVariants);
foreach ($resolvedVariants as $index => $data) {
$variant = isset($data['id'])
? $existingVariants->firstWhere('id', (int) $data['id'])
: null;
if (isset($data['id']) && $variant === null) {
throw ValidationException::withMessages([
"variants.{$index}.id" => ['La variante no pertenece al producto Comida.'],
]);
}
if ($variant === null) {
$this->createVariant($food, $itemAttributes, $data);
} else {
$this->updateVariant($variant, $itemAttributes, $data, $index);
}
}
$minimumPrice = $food->variants()->min('precio');
if ($minimumPrice !== null) {
$food->update(['precio' => $minimumPrice]);
}
return $food->fresh()->load([
'variants.catalogItem',
'variants.inventory',
'variants.eventDate',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute',
]);
});
}
public function delete(Tenant $tenant, int $foodId): void
{
$variant = Variant::query()
->whereKey($foodId)
->whereHas('catalogItem', fn ($query) => $query
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida'))
->firstOrFail();
$this->catalogService->deleteVariant($variant);
}
/** @return Collection<string, Attribute> */
private function attributes(Tenant $tenant): Collection
{
$attributes = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', self::ATTRIBUTE_CODES)
->with('options')
->get()
->keyBy('codigo');
$missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys());
if ($missingCodes->isNotEmpty()) {
throw ValidationException::withMessages([
'variants' => [
'Faltan atributos requeridos para Comida: '.$missingCodes->implode(', ').'.',
],
]);
}
return $attributes;
}
/** @param array<int, array<string, mixed>> $variants */
private function food(Tenant $tenant, array $variants): CatalogItem
{
$category = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => 'Comidas',
]);
$food = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'comida')
->lockForUpdate()
->first();
if ($food !== null) {
$food->update([
'category_id' => $category->id,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
return $food;
}
return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'comida',
'nombre' => 'Comida',
'descripcion' => 'Comida',
'category_id' => $category->id,
'precio' => collect($variants)->min('price') ?? 0,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null,
]);
}
/**
* @param Collection<string, Attribute> $attributes
* @return Collection<string, ItemAttribute>
*/
private function itemAttributes(CatalogItem $food, Collection $attributes): Collection
{
return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($food): array {
$itemAttribute = $food->itemAttributes()->updateOrCreate(
['attribute_id' => $attribute->id],
[
'allow_multi_select' => false,
'sort_order' => self::ATTRIBUTE_SORT_ORDERS[$code],
],
);
return [$code => $itemAttribute];
});
}
/**
* @param array<int, array<string, mixed>> $variants
* @param Collection<string, Attribute> $attributes
* @return array<int, array<string, mixed>>
*/
private function resolveVariants(array $variants, Collection $attributes): array
{
return collect($variants)->map(function (array $variant, int $index) use ($attributes): array {
$schedule = $this->option($attributes['horario'], $variant['schedule'], "variants.{$index}.schedule");
$service = $this->option($attributes['servicio'], $variant['service'], "variants.{$index}.service");
return [
...$variant,
'event_date_id' => (int) $variant['event_date_id'],
'schedule' => $schedule->value,
'service' => $service->value,
'description' => (string) ($variant['description'] ?? ''),
'stock' => (int) $variant['stock'],
];
})->all();
}
private function option(Attribute $attribute, string $value, string $validationKey): AttributeOption
{
$option = $attribute->options->first(
fn (AttributeOption $option): bool => mb_strtolower(trim($option->value)) === mb_strtolower(trim($value))
);
if ($option === null) {
throw ValidationException::withMessages([
$validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."],
]);
}
return $option;
}
/**
* @param array<int, array<string, mixed>> $incoming
* @param Collection<int, Variant> $existing
*/
private function validateCombinations(array $incoming, Collection $existing): void
{
$incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id);
$seen = [];
foreach ($existing->whereNotIn('id', $incomingIds) as $variant) {
$values = $variant->selectionValues();
$seen[$this->combinationKey(
(int) $variant->selectedEventDates()->first()?->id,
(string) $values->get('horario'),
(string) $values->get('servicio'),
)] = true;
}
foreach ($incoming as $index => $variant) {
$key = $this->combinationKey(
$variant['event_date_id'],
$variant['schedule'],
$variant['service'],
);
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"variants.{$index}" => ['La combinación de fecha, horario y servicio ya existe.'],
]);
}
$seen[$key] = true;
}
}
/** @param Collection<string, ItemAttribute> $itemAttributes */
private function createVariant(CatalogItem $food, Collection $itemAttributes, array $data): void
{
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
$variant = $food->variants()->create([
'event_date_id' => $data['event_date_id'],
'inventory_id' => $inventory->id,
'descripcion' => $data['description'],
'precio' => $data['price'],
]);
$variant->eventDates()->sync([$data['event_date_id']]);
$this->syncDefinitions($variant, $itemAttributes, $data);
}
/** @param Collection<string, ItemAttribute> $itemAttributes */
private function updateVariant(
Variant $variant,
Collection $itemAttributes,
array $data,
int $index,
): void {
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
if ($data['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"variants.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$variant->update([
'event_date_id' => $data['event_date_id'],
'descripcion' => $data['description'],
'precio' => $data['price'],
]);
$variant->eventDates()->sync([$data['event_date_id']]);
$inventory->update(['real_stock' => $data['stock']]);
$this->syncDefinitions($variant, $itemAttributes, $data);
}
/** @param Collection<string, ItemAttribute> $itemAttributes */
private function syncDefinitions(Variant $variant, Collection $itemAttributes, array $data): void
{
$definitionAttributes = $itemAttributes->toBase()->only(['horario', 'servicio']);
$variant->definitions()->whereIn('item_attribute_id', $definitionAttributes->pluck('id'))->delete();
$variant->definitions()->createMany([
[
'item_attribute_id' => $definitionAttributes['horario']->id,
'value' => $data['schedule'],
],
[
'item_attribute_id' => $definitionAttributes['servicio']->id,
'value' => $data['service'],
],
]);
}
private function combinationKey(int $eventDateId, string $schedule, string $service): string
{
return implode('|', [
$eventDateId,
mb_strtolower(trim($schedule)),
mb_strtolower(trim($service)),
]);
}
}

View File

@@ -0,0 +1,443 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class MerchandiseService
{
private const ATTRIBUTE_CODES = ['color', 'talle'];
public function __construct(private readonly CatalogService $catalogService) {}
/** @return Collection<int, CatalogItem> */
public function all(Tenant $tenant): Collection
{
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereHas('category', fn ($query) => $query->where('nombre', 'Merchandising'))
->with([
'itemAttributes.attribute.options',
'variants.catalogItem',
'variants.inventory',
'variants.definitions',
])
->orderBy('id')
->get();
}
/**
* @param array<int, array<string, mixed>> $items
* @return Collection<int, CatalogItem>
*/
public function upsertMany(Tenant $tenant, array $items): Collection
{
return DB::transaction(function () use ($tenant, $items): Collection {
$attributes = $this->attributes($tenant);
$category = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => 'Merchandising',
]);
$reservedSlugs = [];
return collect($items)->map(function (array $data, int $index) use (
$tenant,
$attributes,
$category,
&$reservedSlugs,
): CatalogItem {
$item = isset($data['id'])
? $this->existingItem($tenant, $category, (int) $data['id'], $index)
: $this->createItem($tenant, $category, $data, $reservedSlugs);
if (! isset($data['id'])) {
$reservedSlugs[] = $item->slug;
}
$item->update([
'nombre' => trim($data['title']),
'descripcion' => $data['description'] ?? null,
'category_id' => $category->id,
'max_units_per_user' => (int) $data['max_units_per_user'],
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
$itemAttributes = $this->itemAttributes($item, $attributes);
$existingVariants = $item->variants()
->with(['inventory', 'definitions'])
->lockForUpdate()
->get();
$variants = $this->resolveVariants($data['variants'], $attributes, $index);
$this->validateCombinations($variants, $existingVariants, $itemAttributes, $index);
foreach ($variants as $variantIndex => $variantData) {
$variant = isset($variantData['id'])
? $existingVariants->firstWhere('id', (int) $variantData['id'])
: null;
if (isset($variantData['id']) && $variant === null) {
throw ValidationException::withMessages([
"items.{$index}.variants.{$variantIndex}.id" => [
'La variante no pertenece al artículo de merchandising.',
],
]);
}
if ($variant === null) {
$this->createVariant($item, $itemAttributes, $variantData);
} else {
$this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex);
}
}
$minimumPrice = $item->variants()->min('precio');
if ($minimumPrice !== null) {
$item->update(['precio' => $minimumPrice]);
}
return $item->fresh()->load([
'itemAttributes.attribute.options',
'variants.catalogItem',
'variants.inventory',
'variants.definitions',
]);
})->values();
});
}
public function delete(Tenant $tenant, int $merchandiseId): void
{
$variant = Variant::query()
->whereKey($merchandiseId)
->whereHas('catalogItem', fn ($query) => $query
->where('tenant_code', $tenant->codigo)
->whereHas('category', fn ($categoryQuery) => $categoryQuery
->where('nombre', 'Merchandising')))
->firstOrFail();
$this->catalogService->deleteVariant($variant);
}
/** @return Collection<string, Attribute> */
private function attributes(Tenant $tenant): Collection
{
$attributes = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', self::ATTRIBUTE_CODES)
->with('options')
->lockForUpdate()
->get()
->keyBy('codigo');
$missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys());
if ($missingCodes->isNotEmpty()) {
throw ValidationException::withMessages([
'items' => [
'Faltan atributos requeridos para merchandising: '.$missingCodes->implode(', ').'.',
],
]);
}
return $attributes;
}
private function existingItem(
Tenant $tenant,
Category $category,
int $itemId,
int $index,
): CatalogItem {
$item = CatalogItem::query()
->whereKey($itemId)
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->lockForUpdate()
->first();
if ($item === null) {
throw ValidationException::withMessages([
"items.{$index}.id" => ['El artículo no pertenece al merchandising del tenant.'],
]);
}
return $item;
}
/**
* @param array<string, mixed> $data
* @param array<int, string> $reservedSlugs
*/
private function createItem(
Tenant $tenant,
Category $category,
array $data,
array $reservedSlugs,
): CatalogItem {
return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => $this->uniqueSlug($tenant, $data['title'], $reservedSlugs),
'nombre' => trim($data['title']),
'descripcion' => $data['description'] ?? null,
'category_id' => $category->id,
'precio' => collect($data['variants'])->min('price') ?? 0,
'max_units_per_user' => (int) $data['max_units_per_user'],
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null,
]);
}
/**
* @param Collection<string, Attribute> $attributes
* @return Collection<string, ItemAttribute>
*/
private function itemAttributes(CatalogItem $item, Collection $attributes): Collection
{
return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($item): array {
$itemAttribute = $item->itemAttributes()->firstOrCreate(
['attribute_id' => $attribute->id],
['allow_multi_select' => false],
);
if ($itemAttribute->allow_multi_select) {
$itemAttribute->update(['allow_multi_select' => false]);
}
return [$code => $itemAttribute];
});
}
/**
* @param array<int, array<string, mixed>> $variants
* @param Collection<string, Attribute> $attributes
* @return array<int, array<string, mixed>>
*/
private function resolveVariants(array $variants, Collection $attributes, int $itemIndex): array
{
return collect($variants)->map(function (array $variant, int $variantIndex) use (
$attributes,
$itemIndex,
): array {
$color = $this->resolveColor($attributes['color'], $variant['color']);
$size = $this->existingOption(
$attributes['talle'],
$variant['size'],
"items.{$itemIndex}.variants.{$variantIndex}.size",
);
return [
...$variant,
'color' => $color->value,
'size' => $size->value,
'stock' => (int) $variant['stock'],
];
})->all();
}
private function resolveColor(Attribute $attribute, string $color): AttributeOption
{
$option = $this->findOption($attribute, $color);
if ($option !== null) {
return $option;
}
$label = trim($color);
$option = $attribute->options()->create([
'value' => $this->valueCode($label),
'label' => $label,
'sort_order' => ((int) $attribute->options->max('sort_order')) + 1,
]);
$attribute->options->push($option);
return $option;
}
private function existingOption(
Attribute $attribute,
string $value,
string $validationKey,
): AttributeOption {
$option = $this->findOption($attribute, $value);
if ($option === null) {
throw ValidationException::withMessages([
$validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."],
]);
}
return $option;
}
private function findOption(Attribute $attribute, string $value): ?AttributeOption
{
$key = $this->optionKey($value);
return $attribute->options->first(
fn (AttributeOption $option): bool => $this->optionKey($option->value) === $key
|| $this->optionKey($option->label) === $key
);
}
/**
* @param array<int, array<string, mixed>> $incoming
* @param Collection<int, Variant> $existing
* @param Collection<string, ItemAttribute> $itemAttributes
*/
private function validateCombinations(
array $incoming,
Collection $existing,
Collection $itemAttributes,
int $itemIndex,
): void {
$incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id);
$seen = [];
foreach ($existing->whereNotIn('id', $incomingIds) as $variant) {
$values = $variant->definitions->keyBy('item_attribute_id');
$color = $values->get($itemAttributes['color']->id)?->value;
$size = $values->get($itemAttributes['talle']->id)?->value;
if ($color !== null && $size !== null) {
$seen[$this->combinationKey($color, $size)] = true;
}
}
foreach ($incoming as $variantIndex => $variant) {
$key = $this->combinationKey($variant['color'], $variant['size']);
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"items.{$itemIndex}.variants.{$variantIndex}" => [
'La combinación de color y talle ya existe para el artículo.',
],
]);
}
$seen[$key] = true;
}
}
/**
* @param Collection<string, ItemAttribute> $itemAttributes
* @param array<string, mixed> $data
*/
private function createVariant(
CatalogItem $item,
Collection $itemAttributes,
array $data,
): void {
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
$variant = $item->variants()->create([
'inventory_id' => $inventory->id,
'precio' => $data['price'],
]);
$this->syncDefinitions($variant, $itemAttributes, $data);
}
/**
* @param Collection<string, ItemAttribute> $itemAttributes
* @param array<string, mixed> $data
*/
private function updateVariant(
Variant $variant,
Collection $itemAttributes,
array $data,
int $itemIndex,
int $variantIndex,
): void {
$inventory = Inventory::query()
->whereKey($variant->inventory_id)
->lockForUpdate()
->firstOrFail();
if ($data['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"items.{$itemIndex}.variants.{$variantIndex}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$variant->update(['precio' => $data['price']]);
$inventory->update(['real_stock' => $data['stock']]);
$this->syncDefinitions($variant, $itemAttributes, $data);
}
/**
* @param Collection<string, ItemAttribute> $itemAttributes
* @param array<string, mixed> $data
*/
private function syncDefinitions(
Variant $variant,
Collection $itemAttributes,
array $data,
): void {
$variant->definitions()
->whereIn('item_attribute_id', $itemAttributes->pluck('id'))
->delete();
$variant->definitions()->createMany([
[
'item_attribute_id' => $itemAttributes['color']->id,
'value' => $data['color'],
],
[
'item_attribute_id' => $itemAttributes['talle']->id,
'value' => $data['size'],
],
]);
}
/** @param array<int, string> $reservedSlugs */
private function uniqueSlug(Tenant $tenant, string $title, array $reservedSlugs): string
{
$baseSlug = Str::slug($title) ?: 'merchandising';
$slug = $baseSlug;
$suffix = 2;
while (
in_array($slug, $reservedSlugs, true)
|| CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', $slug)
->exists()
) {
$slug = "{$baseSlug}-{$suffix}";
$suffix++;
}
return $slug;
}
private function combinationKey(string $color, string $size): string
{
return $this->optionKey($color).'|'.$this->optionKey($size);
}
private function optionKey(string $value): string
{
return Str::ascii(mb_strtolower((string) preg_replace('/[_\s]+/u', ' ', trim($value))));
}
private function valueCode(string $value): string
{
return mb_strtolower((string) preg_replace('/\s+/u', '_', trim($value)));
}
}

View File

@@ -0,0 +1,48 @@
<?php
use App\Domains\FiestaFutbolInfantil\Controllers\AccommodationController;
use App\Domains\FiestaFutbolInfantil\Controllers\EntryController;
use App\Domains\FiestaFutbolInfantil\Controllers\FoodController;
use App\Domains\FiestaFutbolInfantil\Controllers\MerchandiseController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('entries', [EntryController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.index');
Route::post('entries', [EntryController::class, 'store'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.store');
Route::delete('entries/{entry}', [EntryController::class, 'destroy'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.destroy');
Route::get('foods', [FoodController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.index');
Route::post('foods', [FoodController::class, 'store'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.store');
Route::delete('foods/{food}', [FoodController::class, 'destroy'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
Route::get('accommodations', [AccommodationController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
->name('adminapp.fiesta-futbol-infantil.accommodations.index');
Route::post('accommodations', [AccommodationController::class, 'store'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
->name('adminapp.fiesta-futbol-infantil.accommodations.store');
Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
->name('adminapp.fiesta-futbol-infantil.accommodations.destroy');
Route::get('merchandise', [MerchandiseController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
->name('adminapp.fiesta-futbol-infantil.merchandise.index');
Route::post('merchandise', [MerchandiseController::class, 'store'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
->name('adminapp.fiesta-futbol-infantil.merchandise.store');
Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
->name('adminapp.fiesta-futbol-infantil.merchandise.destroy');
});

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\FoodFormResource;
use App\Domains\Forms\Services\FoodFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class FoodFormController extends Controller
{
public function __construct(protected FoodFormService $foodFormService) {}
public function __invoke(Request $request): FoodFormResource
{
return FoodFormResource::make(
$this->foodFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\MerchandiseFormResource;
use App\Domains\Forms\Services\MerchandiseFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class MerchandiseFormController extends Controller
{
public function __construct(protected MerchandiseFormService $merchandiseFormService) {}
public function __invoke(Request $request): MerchandiseFormResource
{
return MerchandiseFormResource::make(
$this->merchandiseFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
class FoodFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'event_dates' => $this->resource['event_dates']->map(
fn (EventDate $eventDate): array => [
'id' => $eventDate->id,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'date' => $eventDate->date->format('Y-m-d'),
]
)->values(),
'schedules' => $this->options($this->resource['schedules']),
'services' => $this->options($this->resource['services']),
];
}
/**
* @param Collection<int, AttributeOption> $options
* @return Collection<int, array{value: string, label: string}>
*/
private function options(Collection $options): Collection
{
return $options->map(fn (AttributeOption $option): array => [
'value' => $option->value,
'label' => $option->label,
])->values();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Catalog\Models\AttributeOption;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
class MerchandiseFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'colors' => $this->options($this->resource['colors']),
'sizes' => $this->options($this->resource['sizes']),
];
}
/**
* @param Collection<int, AttributeOption> $options
* @return Collection<int, array{value: string, label: string}>
*/
private function options(Collection $options): Collection
{
return $options->map(fn (AttributeOption $option): array => [
'value' => $option->value,
'label' => $option->label,
])->values();
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
class FoodFormService
{
/**
* @return array{
* event_dates: Collection<int, EventDate>,
* schedules: Collection<int, AttributeOption>,
* services: Collection<int, AttributeOption>
* }
*/
public function get(Tenant $tenant): array
{
$attributes = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', ['horario', 'servicio'])
->with('options')
->get()
->keyBy('codigo');
return [
'event_dates' => $tenant->eventDates()->with('validityTime')->get(),
'schedules' => $attributes->get('horario')?->options ?? new Collection,
'services' => $attributes->get('servicio')?->options ?? new Collection,
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
class MerchandiseFormService
{
/**
* @return array{
* colors: Collection<int, AttributeOption>,
* sizes: Collection<int, AttributeOption>
* }
*/
public function get(Tenant $tenant): array
{
$attributes = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->whereIn('codigo', ['color', 'talle'])
->with('options')
->get()
->keyBy('codigo');
return [
'colors' => $attributes->get('color')?->options ?? new Collection,
'sizes' => $attributes->get('talle')?->options ?? new Collection,
];
}
}

View File

@@ -0,0 +1,26 @@
# Dominio Forms
## Propósito
Provee catálogos y opciones auxiliares para construir formularios del panel administrativo. Es un dominio de lectura que compone datos pertenecientes a otros dominios.
## Formularios disponibles
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
## Endpoints
Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`:
- `GET /event`.
- `GET /sale`.
- `GET /staff`.
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
## Dependencias
Compone datos de `Tenant`, `Purchase` y `Catalog`. No debe duplicar reglas de negocio: las listas y estados canónicos siguen perteneciendo a sus dominios de origen.

View File

@@ -1,6 +1,8 @@
<?php
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
use Illuminate\Support\Facades\Route;
@@ -11,4 +13,12 @@ Route::prefix('v1/adminapp/forms')
Route::get('event', EventFormController::class);
Route::get('sale', SaleFormController::class);
Route::get('staff', StaffFormController::class);
Route::get(
'fiesta-futbol-infantil/merchandise',
MerchandiseFormController::class
);
Route::get(
'fiesta-futbol-infantil/food',
FoodFormController::class
);
});

View File

@@ -127,8 +127,7 @@ class TelepagosWebhookService
DB::transaction(function () use ($compra, $paymentData) {
TelepagosPayment::create($paymentData);
$this->checkoutService->confirmPurchase($compra);
$compra->markAsPaid();
$this->checkoutService->confirmPaidPurchase($compra);
});
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");

View File

@@ -0,0 +1,29 @@
# Dominio Integration
## Propósito
Gestiona integraciones externas disponibles y su configuración por tenant. Incluye correo y pagos mediante Telepagos.
## Modelo y seguridad
- `Integration`: definición global de una integración.
- `TenantIntegration`: configuración y credenciales de una integración para un tenant.
- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos.
- `TenantIntegrationService`: consulta y configura integraciones del tenant.
## Servicios externos
- `BaseIntegrationService`: base para resolver configuración, URL y cliente del tenant.
- `MailService`: envío de correo usando la integración configurada.
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
## Endpoints
- CRUD global bajo `/integrations`.
- Consulta y configuración por tenant bajo `/{tenant_code}/integrations`.
- `POST /webhooks/telepagos/{tenant_codigo}` para notificaciones del proveedor.
## Dependencias y reglas
Se integra con `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. Las credenciales no deben exponerse en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra.

View File

@@ -0,0 +1,25 @@
# Dominio Logging
## Propósito
Registra cambios relevantes de valores en modelos de negocio, indicando tenant, atributo, valor anterior/nuevo, fecha y actor.
## Componentes
- `Models/ValueChange.php`: entrada persistida del historial, relacionada polimórficamente con el objeto modificado.
- `Models/Concerns/LogsValueChanges.php`: trait reutilizable que escucha actualizaciones del modelo.
- `Enums/ValueChangeActorType.php`: distingue cambios realizados por usuario o por el sistema.
## Uso
Un modelo consumidor debe:
1. Usar el trait `LogsValueChanges`.
2. Declarar la propiedad `loggedAttributes` con los atributos auditables.
3. Implementar `valueChangeTenantCode()`.
El trait solo registra atributos configurados que efectivamente cambiaron. Si existe un usuario autenticado lo asocia al cambio; en caso contrario marca al sistema como actor.
## API y dependencias
No expone rutas HTTP. `Purchase` lo utiliza para auditar cambios de estado y `Sale` consulta esas modificaciones para reportes.

View File

@@ -0,0 +1,24 @@
# Dominio MailTest
## Propósito
Ofrece una operación técnica para verificar la configuración de correo de un tenant sin ejecutar un flujo funcional real.
## Componentes
- `MailTestController`: endpoint invocable de envío.
- `SendTestMailRequest`: valida destinatario y contenido requerido.
- `MailTestService`: coordina el envío de prueba.
- `TestMail`: mailable utilizado para construir el mensaje.
## Endpoint
- `POST /{tenant_code}/mail-test/send`.
## Dependencias
Usa la configuración de correo del dominio `Integration` y resuelve el tenant indicado.
## Consideraciones
Es una herramienta de diagnóstico. Debe restringirse o deshabilitarse en entornos donde no corresponda exponer envíos de prueba, y nunca debe registrar credenciales.

View File

@@ -0,0 +1,24 @@
# Dominio Menu
## Propósito
Define menús disponibles y permite configurar su contenido para cada tenant y rol.
## Modelo
- `Menu`: definición global de una entrada de menú y su tipo de contenido.
- `TenantMenu`: configuración específica por tenant, incluyendo contenido estático cuando corresponde.
- `MenuRole`: asociación entre menú y rol autorizado.
## Servicios
`TenantMenuService::configure()` crea o actualiza atómicamente la configuración de un menú para un tenant. Solo conserva `static_content` cuando el menú fue definido como contenido estático.
## Endpoints
- Recurso REST `/menues` mediante `MenuController`.
- `POST /{tenant_code}/menues/{menu_code}` para configurar un menú del tenant.
## Dependencias y reglas
Depende de `Tenant` y `Authorization`. Los códigos de menú y tenant forman la identidad lógica de la configuración; el contenido enviado debe respetar el tipo definido por `Menu`.

View File

@@ -0,0 +1,26 @@
# Dominio Notification
## Propósito
Orquesta notificaciones de negocio por correo a partir de eventos de otros dominios.
## Eventos atendidos
- `UserRegistered`: dispara el correo de bienvenida.
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
- `PurchasePaid`: envía la confirmación de pago.
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
## Componentes
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail`, `SendPurchasePaidEmail` y `SendTicketsAvailableEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
## API y dependencias
No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`, y delega la entrega al dominio `Integration`.
## Consideraciones
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
- La recuperación no se envía si el intento dejó de estar pendiente.
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.

View File

@@ -4,7 +4,6 @@ namespace App\Domains\Purchase\Models;
use App\Domains\Auth\Models\User;
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\Tenant\Models\Tenant;
@@ -20,7 +19,6 @@ use Illuminate\Support\Facades\DB;
#[Fillable([
'cart_id',
'tenant_codigo',
'event_id',
'user_id',
'status',
'payment_method',
@@ -72,7 +70,6 @@ class Purchase extends Model
{
return [
'cart_id' => 'integer',
'event_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
@@ -87,12 +84,6 @@ class Purchase extends Model
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>
*/

View File

@@ -64,7 +64,7 @@ class PurchaseItemResource extends JsonResource
],
'item_details' => $selectedItem === null ? null : [
'nombre' => $selectedItem->getName(),
'descripcion' => $catalogItem?->descripcion,
'descripcion' => $selectedItem->getDescription(),
'imagen' => $imageUrl,
'attributes' => $variant === null ? [] : $this->resolveAttributes($variant),
],
@@ -98,14 +98,36 @@ class PurchaseItemResource extends JsonResource
return [];
}
return $variant->definitions
->map(fn ($definition): array => [
'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''),
'value' => $definition->value,
])
$attributes = $variant->definitions
->groupBy('item_attribute_id')
->map(function ($definitions): array {
$itemAttribute = $definitions->first()?->itemAttribute;
$values = $definitions->pluck('value')->values();
return [
'name' => (string) ($itemAttribute?->attribute?->nombre ?? ''),
'value' => $itemAttribute?->allow_multi_select
? $values->all()
: $values->first(),
];
})
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
->values()
->all();
->values();
$eventDates = $variant->relationLoaded('eventDates')
? $variant->selectedEventDates()
: collect();
if ($eventDates->isNotEmpty()) {
$attributes->prepend([
'name' => 'Fecha',
'value' => $eventDates
->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))
->values()
->all(),
]);
}
return $attributes->all();
}
private function formatMoney(float|int|string|null $amount): string

View File

@@ -42,7 +42,6 @@ class PurchaseResource extends JsonResource
'id' => $this->id,
'cart_id' => $this->cart_id,
'tenant_codigo' => $this->tenant_codigo,
'event_id' => $this->event_id,
'user_id' => $this->user_id,
'created_at' => $this->created_at,
'status' => $this->status,

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CatalogSelectionResolver
{
public function resolve(
Tenant $tenant,
int $catalogItemId,
?int $variantId,
): CatalogItem|Variant {
/** @var CatalogItem|null $catalogItem */
$catalogItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->first();
if ($catalogItem === null) {
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
return $catalogItem;
}
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => __('api.cart.variant_required'),
]);
}
$catalogItem->setRelation(
'inventory',
Inventory::query()->whereKey($catalogItem->inventory_id)->lockForUpdate()->firstOrFail(),
);
return $catalogItem;
}
/** @var Variant|null $variant */
$variant = Variant::query()
->whereKey($variantId)
->where('catalog_item_id', $catalogItem->id)
->lockForUpdate()
->first();
if ($variant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation(
'inventory',
Inventory::query()->whereKey($variant->inventory_id)->lockForUpdate()->firstOrFail(),
);
return $variant;
}
public function resolvePurchaseItem(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant
{
return $this->resolve(
$tenant,
(int) $item->source_catalog_item_id,
$item->source_variant_id === null ? null : (int) $item->source_variant_id,
);
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CompleteCheckoutService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly CatalogSelectionResolver $selections,
private readonly SourceCartService $sourceCart,
) {}
public function complete(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->payment_method === null) {
throw ValidationException::withMessages([
'payment_method' => __('api.purchase.payment_method_required'),
]);
}
if ($this->isTerminal($purchase)) {
return $this->loadPurchase($purchase);
}
$purchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $this->loadPurchase($purchase);
});
}
public function submitForReview(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status === Purchase::STATUS_PAID) {
return $this->loadPurchase($purchase);
}
if (
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_review'),
]);
}
$purchase->update(['expires_at' => null]);
return $this->loadPurchase($purchase);
});
}
public function confirm(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status === Purchase::STATUS_PAID) {
return;
}
if (in_array($purchase->status, [
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
], true)) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.cannot_confirm'),
]);
}
$items = $purchase->items()
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
->lockForUpdate()
->get();
foreach ($items as $item) {
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
try {
$this->inventory->commit($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
]);
}
$this->sourceCart->finalize($purchase);
});
}
private function isTerminal(Purchase $purchase): bool
{
return in_array($purchase->status, [
Purchase::STATUS_PAID,
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
], true);
}
private function lockPurchase(Purchase $purchase): Purchase
{
/** @var Purchase */
return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
}
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EditCheckoutService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly CatalogSelectionResolver $selections,
private readonly SourceCartService $sourceCart,
) {}
/** @param array<string, string> $customerData */
public function updateCustomer(Purchase $purchase, array $customerData): Purchase
{
return DB::transaction(function () use ($purchase, $customerData): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->assertEditable($purchase);
$purchase->update($customerData);
return $this->loadPurchase($purchase);
});
}
public function updateItemQuantity(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
): Purchase {
return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
$purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem);
$difference = $quantity - (int) $purchaseItem->cantidad;
if ($difference !== 0) {
$this->adjustReservation($purchase, $purchaseItem, $quantity, $difference);
$purchaseItem->update([
'cantidad' => $quantity,
'total' => (float) $purchaseItem->precio_unitario * $quantity,
]);
$this->sourceCart->syncItemQuantity($purchase, $purchaseItem, $quantity);
}
$purchase->update([
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $this->loadPurchase($purchase);
});
}
public function prepareItemEditing(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->assertEditable($purchase);
$purchase->telepagosQr()->delete();
$purchase->update([
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'transfer_payer_dni' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
]);
return $this->loadPurchase($purchase);
});
}
private function adjustReservation(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
int $difference,
): void {
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem);
try {
if ($difference > 0) {
$otherItemQuantity = (int) $purchase->items()
->where('source_catalog_item_id', $purchaseItem->source_catalog_item_id)
->whereKeyNot($purchaseItem->getKey())
->sum('cantidad');
$catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection;
$this->purchaseLimits->assertCanPurchase(
$catalogItem,
(int) $purchase->user_id,
$otherItemQuantity + $quantity,
$purchase->getKey(),
);
$this->inventory->reserve($selection, $difference);
} else {
$this->inventory->release($selection, abs($difference));
}
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'quantity' => __('api.purchase.insufficient_stock'),
]);
}
}
private function lockPurchaseItem(Purchase $purchase, PurchaseItem $item): PurchaseItem
{
/** @var PurchaseItem|null $lockedItem */
$lockedItem = $purchase->items()
->whereKey($item->getKey())
->lockForUpdate()
->first();
if ($lockedItem === null) {
throw new NotFoundHttpException('Purchase item not found.');
}
if ($lockedItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
throw ValidationException::withMessages([
'item' => __('api.purchase.item_not_editable'),
]);
}
return $lockedItem;
}
private function assertEditable(Purchase $purchase): void
{
if (
! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| $this->hasExpired($purchase)
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
}
private function hasExpired(Purchase $purchase): bool
{
return $purchase->expires_at !== null && $purchase->expires_at->isPast();
}
private function lockPurchase(Purchase $purchase): Purchase
{
/** @var Purchase */
return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
}
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Collection;
class PurchaseItemSnapshotFactory
{
/**
* @param Collection<int, CartItem> $cartItems
* @return array<int, array<string, mixed>>
*/
public function fromCartItems(Collection $cartItems): array
{
return $cartItems
->map(function (CartItem $item): array {
$selectedItem = $item->selectedItem();
$quantity = (int) $item->cantidad;
$unitPrice = $selectedItem?->getPrice() ?? 0;
return [
'source_catalog_item_id' => $item->catalog_item_id,
'source_variant_id' => $item->variant_id,
'image_attachment_id' => $this->firstImageAttachment($item)?->id,
'nombre' => $item->catalogItem->nombre,
'descripcion' => $selectedItem?->getDescription(),
'slug' => $item->catalogItem->slug,
'item_nombre' => $selectedItem->getName(),
'variant_attributes' => $item->variant === null
? []
: $this->snapshotAttributes($item->variant),
'cantidad' => $quantity,
'precio_unitario' => $unitPrice,
'discount_total' => null,
'tax_total' => null,
'total' => $unitPrice * $quantity,
'reservation_status' => PurchaseItem::RESERVATION_ACTIVE,
];
})
->all();
}
private function firstImageAttachment(CartItem $item): ?Attachment
{
return $item->variant?->attachments->first()
?? $item->catalogItem?->attachments->first();
}
/** @return array<int, array{name: string, value: mixed}> */
private function snapshotAttributes(Variant $variant): array
{
$attributes = $variant->definitions
->groupBy('item_attribute_id')
->map(function ($definitions): array {
$itemAttribute = $definitions->first()?->itemAttribute;
$values = $definitions->pluck('value')->values();
return [
'name' => (string) ($itemAttribute?->attribute?->nombre ?? ''),
'value' => $itemAttribute?->allow_multi_select
? $values->all()
: $values->first(),
];
})
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
->values();
$eventDates = $variant->selectedEventDates();
if ($eventDates->isNotEmpty()) {
$attributes->prepend([
'name' => 'Fecha',
'value' => $eventDates
->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))
->values()
->all(),
]);
}
return $attributes->all();
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ReleaseCheckoutService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly CatalogSelectionResolver $selections,
private readonly SourceCartService $sourceCart,
) {}
public function cancel(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: true);
}
public function cancelWithoutRestoringCart(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: false);
}
public function expire(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_EXPIRED, restoreCart: true);
}
public function expireOverdue(): int
{
$expiredCount = 0;
Purchase::query()
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->orderBy('id')
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
$purchase = $this->expire($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED) {
$expiredCount++;
}
});
return $expiredCount;
}
private function release(Purchase $purchase, string $targetStatus, bool $restoreCart): Purchase
{
return DB::transaction(function () use ($purchase, $targetStatus, $restoreCart): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status === Purchase::STATUS_PAID) {
if ($targetStatus === Purchase::STATUS_EXPIRED) {
return $this->loadPurchase($purchase);
}
throw ValidationException::withMessages([
'purchase' => __('api.purchase.paid_cannot_cancel'),
]);
}
if ($this->isAlreadyReleased($purchase)) {
return $this->loadPurchase($purchase);
}
if (
$targetStatus === Purchase::STATUS_EXPIRED
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
) {
return $this->loadPurchase($purchase);
}
$items = $purchase->items()
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
->lockForUpdate()
->get();
$reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
foreach ($items as $item) {
if (! $reservationReturnedToCart) {
$this->releaseInventory($purchase, $item);
}
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_RELEASED,
]);
}
$purchase->update(['status' => $targetStatus]);
return $this->loadPurchase($purchase);
});
}
private function releaseInventory(Purchase $purchase, PurchaseItem $item): void
{
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
try {
$this->inventory->release($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
private function isAlreadyReleased(Purchase $purchase): bool
{
return in_array($purchase->status, [
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
], true);
}
private function lockPurchase(Purchase $purchase): Purchase
{
/** @var Purchase */
return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
}
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
class SourceCartService
{
public function restore(Purchase $purchase): bool
{
$sourceCart = $this->findSourceCart($purchase);
if ($sourceCart === null) {
return false;
}
/** @var Cart|null $activeCart */
$activeCart = Cart::query()
->where('tenant_codigo', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->where('status', 'active')
->where('id', '!=', $sourceCart->getKey())
->lockForUpdate()
->first();
if ($activeCart !== null) {
$this->mergeIntoActiveCart($sourceCart, $activeCart);
$sourceCart->update([
'status' => 'converted',
'guest_token' => null,
]);
if (! $sourceCart->trashed()) {
$sourceCart->delete();
}
return true;
}
if ($sourceCart->trashed()) {
$sourceCart->restore();
}
$sourceCart->update([
'status' => 'active',
'user_id' => $purchase->user_id,
'guest_token' => null,
]);
return true;
}
public function syncItemQuantity(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
): void {
$sourceCart = $this->findSourceCart($purchase);
if ($sourceCart === null) {
return;
}
$sourceCart->items()
->where('catalog_item_id', $purchaseItem->source_catalog_item_id)
->where('variant_id', $purchaseItem->source_variant_id)
->update(['cantidad' => $quantity]);
}
public function finalize(Purchase $purchase): void
{
$sourceCart = $this->findSourceCart($purchase);
if ($sourceCart === null || $sourceCart->trashed()) {
return;
}
$sourceCart->update([
'status' => 'converted',
'guest_token' => null,
]);
$sourceCart->delete();
}
private function findSourceCart(Purchase $purchase): ?Cart
{
if ($purchase->cart_id === null) {
return null;
}
/** @var Cart|null */
return Cart::withTrashed()
->whereKey($purchase->cart_id)
->lockForUpdate()
->first();
}
private function mergeIntoActiveCart(Cart $sourceCart, Cart $activeCart): void
{
$sourceItems = $sourceCart->items()->lockForUpdate()->get();
foreach ($sourceItems as $sourceItem) {
/** @var CartItem|null $activeItem */
$activeItem = $activeCart->items()
->where('catalog_item_id', $sourceItem->catalog_item_id)
->where('variant_id', $sourceItem->variant_id)
->lockForUpdate()
->first();
if ($activeItem === null) {
$activeCart->items()->create([
'catalog_item_id' => $sourceItem->catalog_item_id,
'variant_id' => $sourceItem->variant_id,
'cantidad' => $sourceItem->cantidad,
]);
} else {
$activeItem->increment('cantidad', (int) $sourceItem->cantidad);
}
}
}
}

View File

@@ -0,0 +1,290 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class StartCheckoutService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly CatalogSelectionResolver $selections,
private readonly PurchaseItemSnapshotFactory $snapshots,
) {}
/** @param array<string, mixed> $purchaseData */
public function start(Tenant $tenant, int $userId, array $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;
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
unset($purchaseData['direct_item'], $purchaseData['cart_id']);
if (is_array($directItem)) {
return $this->startDirect($tenant, $userId, $purchaseData, $directItem);
}
if ($cartId === null) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.source_required'),
]);
}
return $this->startFromCart($tenant, $userId, $purchaseData, $cartId);
});
}
/**
* @param array<string, mixed> $purchaseData
* @param array<string, mixed> $directItem
*/
private function startDirect(
Tenant $tenant,
int $userId,
array $purchaseData,
array $directItem,
): Purchase {
$catalogItemId = (int) $directItem['catalog_item_id'];
$variantId = isset($directItem['variant_id']) ? (int) $directItem['variant_id'] : null;
$quantity = (int) $directItem['cantidad'];
$selection = $this->selections->resolve($tenant, $catalogItemId, $variantId);
$catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection;
$this->purchaseLimits->assertCanPurchase(
$catalogItem,
$userId,
$quantity,
field: 'direct_item.cantidad',
);
$availableQuantity = $this->inventory->availableQuantity($selection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
]);
}
try {
$this->inventory->reserve($selection, $quantity);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
]);
}
$purchase = $this->createPurchase(
$tenant,
$userId,
$purchaseData,
$selection->getPrice() * $quantity,
null,
);
$directCartItem = $this->makeDirectCartItem(
$selection,
$catalogItemId,
$variantId,
$quantity,
);
$purchase->items()->createMany(
$this->snapshots->fromCartItems(collect([$directCartItem])),
);
return $this->loadPurchase($purchase);
}
/** @param array<string, mixed> $purchaseData */
private function startFromCart(
Tenant $tenant,
int $userId,
array $purchaseData,
int $cartId,
): Purchase {
$cart = $this->resolveCart($tenant, $userId, $cartId);
$cartItems = $cart->items()->lockForUpdate()->get();
if ($cartItems->isEmpty()) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.empty_cart'),
]);
}
$this->loadCartItems($cartItems);
$this->verifyTenantItems($tenant, $cartItems);
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems);
$cart->setRelation('items', $cartItems);
$purchase = $this->createPurchase(
$tenant,
$userId,
$purchaseData,
$cart->getTotalAmount(),
$cart->getKey(),
);
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
// The purchase owns the reservation until checkout finishes. The cart is
// retained so it can be restored if the purchase is cancelled or expires.
$cart->update([
'status' => 'checkout',
'guest_token' => null,
]);
return $this->loadPurchase($purchase);
}
private function resolveCart(Tenant $tenant, int $userId, int $cartId): Cart
{
/** @var Cart|null $cart */
$cart = Cart::query()->lockForUpdate()->find($cartId);
if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) {
throw new NotFoundHttpException('Cart not found for tenant.');
}
if ($cart->status !== 'active') {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.inactive_cart'),
]);
}
return $cart;
}
/** @param Collection<int, CartItem> $cartItems */
private function verifyTenantItems(Tenant $tenant, Collection $cartItems): void
{
foreach ($cartItems as $item) {
if ($item->selectedItem() === null) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_missing'),
]);
}
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]);
}
}
}
/** @param Collection<int, CartItem> $cartItems */
private function assertCartPurchaseLimits(
Tenant $tenant,
int $userId,
Collection $cartItems,
): void {
$quantities = $cartItems
->groupBy('catalog_item_id')
->map(fn (Collection $items): int => (int) $items->sum('cantidad'))
->sortKeys();
$catalogItems = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereKey($quantities->keys())
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($quantities as $catalogItemId => $quantity) {
/** @var CatalogItem $catalogItem */
$catalogItem = $catalogItems->get($catalogItemId);
$this->purchaseLimits->assertCanPurchase(
$catalogItem,
$userId,
$quantity,
field: 'cart_id',
);
}
}
/** @param array<string, mixed> $purchaseData */
private function createPurchase(
Tenant $tenant,
int $userId,
array $purchaseData,
float $total,
?int $cartId,
): Purchase {
return Purchase::query()->create([
...$purchaseData,
'cart_id' => $cartId,
'tenant_codigo' => $tenant->codigo,
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
'total' => $total,
]);
}
private function makeDirectCartItem(
CatalogItem|Variant $selection,
int $catalogItemId,
?int $variantId,
int $quantity,
): CartItem {
$catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection;
$catalogItem->loadMissing(['inventory', 'attachments']);
if ($selection instanceof Variant) {
$selection->loadMissing([
'inventory',
'attachments',
'catalogItem',
'definitions.itemAttribute.attribute',
]);
}
$item = new CartItem([
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'cantidad' => $quantity,
]);
$item->setRelation('catalogItem', $catalogItem);
$item->setRelation('variant', $selection instanceof Variant ? $selection : null);
return $item;
}
/** @param Collection<int, CartItem> $cartItems */
private function loadCartItems(Collection $cartItems): void
{
$cartItems->load([
'catalogItem.inventory',
'catalogItem.attachments',
'variant.inventory',
'variant.attachments',
'variant.catalogItem',
'variant.definitions.itemAttribute.attribute',
'variant.eventDates',
'variant.eventDate',
]);
}
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
}
}

View File

@@ -2,153 +2,49 @@
namespace App\Domains\Purchase\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Stable checkout API used by controllers, commands and integrations.
*
* Workflow details live in focused services under Services/Checkout.
*/
class CheckoutService
{
public function __construct(
private readonly CatalogInventoryService $catalogInventoryService,
private readonly StartCheckoutService $starter,
private readonly EditCheckoutService $editor,
private readonly CompleteCheckoutService $completer,
private readonly ReleaseCheckoutService $releaser,
) {}
/** @param array<string, mixed> $purchaseData */
public function startCheckout(Tenant $tenant, int $userId, array $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;
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
unset($purchaseData['direct_item'], $purchaseData['cart_id']);
if (is_array($directItem)) {
return $this->startDirectCheckout(
$tenant,
$userId,
$purchaseData,
$directItem,
);
}
if ($cartId === null) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.source_required'),
]);
}
return $this->startCartCheckout(
$tenant,
$userId,
$purchaseData,
$cartId,
);
});
return $this->starter->start($tenant, $userId, $purchaseData);
}
public function completePurchase(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if ($purchase->payment_method === null) {
throw ValidationException::withMessages([
'payment_method' => __('api.purchase.payment_method_required'),
]);
}
if (in_array($purchase->status, [
Purchase::STATUS_PAID,
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
], true)) {
return $this->loadPurchase($purchase);
}
$purchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $this->loadPurchase($purchase);
});
return $this->completer->complete($purchase);
}
public function submitForReview(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if (in_array($purchase->status, [
Purchase::STATUS_PAID,
], true)) {
return $this->loadPurchase($purchase);
}
if (
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_review'),
]);
}
$purchase->update([
'expires_at' => null,
]);
return $this->loadPurchase($purchase);
});
return $this->completer->submitForReview($purchase);
}
/**
* @param array<string, string> $customerData
*/
/** @param array<string, string> $customerData */
public function updateCustomerData(Purchase $purchase, array $customerData): Purchase
{
return DB::transaction(function () use ($purchase, $customerData): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if (
! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
$purchase->update($customerData);
return $this->loadPurchase($purchase);
});
return $this->editor->updateCustomer($purchase, $customerData);
}
public function updateItemQuantity(
@@ -156,696 +52,46 @@ class CheckoutService
PurchaseItem $purchaseItem,
int $quantity,
): Purchase {
return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if (
$purchase->status !== Purchase::STATUS_CREATED
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
/** @var PurchaseItem|null $purchaseItem */
$purchaseItem = $purchase->items()
->whereKey($purchaseItem->getKey())
->lockForUpdate()
->first();
if ($purchaseItem === null) {
throw new NotFoundHttpException('Purchase item not found.');
}
if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
throw ValidationException::withMessages([
'item' => __('api.purchase.item_not_editable'),
]);
}
$currentQuantity = (int) $purchaseItem->cantidad;
$difference = $quantity - $currentQuantity;
if ($difference !== 0) {
$selection = $this->resolvePurchaseItemSelection($purchase->tenant, $purchaseItem);
try {
if ($difference > 0) {
$this->catalogInventoryService->reserve($selection, $difference);
} else {
$this->catalogInventoryService->release($selection, abs($difference));
}
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'quantity' => __('api.purchase.insufficient_stock'),
]);
}
$purchaseItem->update([
'cantidad' => $quantity,
'total' => (float) $purchaseItem->precio_unitario * $quantity,
]);
$this->syncSourceCartItemQuantity($purchase, $purchaseItem, $quantity);
}
$purchase->update([
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $this->loadPurchase($purchase);
});
return $this->editor->updateItemQuantity($purchase, $purchaseItem, $quantity);
}
public function prepareItemEditing(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if (
! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
$purchase->telepagosQr()->delete();
$purchase->update([
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'transfer_payer_dni' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
]);
return $this->loadPurchase($purchase);
});
return $this->editor->prepareItemEditing($purchase);
}
public function confirmPurchase(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
$this->completer->confirm($purchase);
}
if ($purchase->status === Purchase::STATUS_PAID) {
return;
}
public function confirmPaidPurchase(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$this->completer->confirm($purchase);
$purchase->markAsPaid();
if (in_array($purchase->status, [
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
], true)) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.cannot_confirm'),
]);
}
$items = $purchase->items()
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
->lockForUpdate()
->get();
foreach ($items as $item) {
$selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item);
try {
$this->catalogInventoryService->commit($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
]);
}
$this->finalizeSourceCart($purchase);
return $purchase->refresh()->load(['items.imageAttachment']);
});
}
public function cancelPurchase(Purchase $purchase): Purchase
{
return $this->releasePurchase($purchase, Purchase::STATUS_CANCELLED);
return $this->releaser->cancel($purchase);
}
public function cancelPurchaseWithoutRestoringCart(Purchase $purchase): Purchase
{
return $this->releaser->cancelWithoutRestoringCart($purchase);
}
public function expirePurchase(Purchase $purchase): Purchase
{
return $this->releasePurchase($purchase, Purchase::STATUS_EXPIRED);
return $this->releaser->expire($purchase);
}
public function expireOverduePurchases(): int
{
$expiredCount = 0;
Purchase::query()
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->orderBy('id')
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
$purchase = $this->expirePurchase($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED) {
$expiredCount++;
}
});
return $expiredCount;
}
private function releasePurchase(Purchase $purchase, string $targetStatus): Purchase
{
return DB::transaction(function () use ($purchase, $targetStatus): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if ($purchase->status === Purchase::STATUS_PAID) {
if ($targetStatus === Purchase::STATUS_EXPIRED) {
return $this->loadPurchase($purchase);
}
throw ValidationException::withMessages([
'purchase' => __('api.purchase.paid_cannot_cancel'),
]);
}
if (in_array($purchase->status, [
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
], true)) {
return $this->loadPurchase($purchase);
}
if (
$targetStatus === Purchase::STATUS_EXPIRED
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
) {
return $this->loadPurchase($purchase);
}
$items = $purchase->items()
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
->lockForUpdate()
->get();
$reservationReturnedToCart = $this->restoreSourceCart($purchase);
foreach ($items as $item) {
if (! $reservationReturnedToCart) {
$selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item);
try {
$this->catalogInventoryService->release($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_RELEASED,
]);
}
$purchase->update([
'status' => $targetStatus,
]);
return $this->loadPurchase($purchase);
});
}
/**
* @param array<string, mixed> $purchaseData
* @param array<string, mixed> $directItem
*/
private function startDirectCheckout(
Tenant $tenant,
int $userId,
array $purchaseData,
array $directItem,
): Purchase {
$catalogItemId = (int) $directItem['catalog_item_id'];
$variantId = isset($directItem['variant_id']) ? (int) $directItem['variant_id'] : null;
$quantity = (int) $directItem['cantidad'];
$selection = $this->resolveSelection($tenant, $catalogItemId, $variantId);
$availableQuantity = $this->catalogInventoryService->availableQuantity($selection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
]);
}
try {
$this->catalogInventoryService->reserve($selection, $quantity);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
]);
}
$purchase = $this->createPurchase(
$tenant,
$userId,
$purchaseData,
$selection->getPrice() * $quantity,
null,
);
$cartItem = $this->makeDirectCartItem($selection, $catalogItemId, $variantId, $quantity);
$purchase->items()->createMany(
$this->buildPurchaseItemsPayload(collect([$cartItem])),
);
return $this->loadPurchase($purchase);
}
/**
* @param array<string, mixed> $purchaseData
*/
private function startCartCheckout(
Tenant $tenant,
int $userId,
array $purchaseData,
int $cartId,
): Purchase {
$cart = $this->resolveCheckoutCart($tenant, $userId, $cartId);
$cartItems = $cart->items()->lockForUpdate()->get();
if ($cartItems->isEmpty()) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.empty_cart'),
]);
}
$this->loadCartItems($cartItems);
$this->verifyTenantItems($tenant, $cartItems);
$cart->setRelation('items', $cartItems);
$purchase = $this->createPurchase(
$tenant,
$userId,
$purchaseData,
$cart->getTotalAmount(),
$cart->getKey(),
);
$purchase->items()->createMany(
$this->buildPurchaseItemsPayload($cartItems),
);
// PurchaseItem owns the reservation during checkout. The source cart is
// kept with its owner so it can be restored if the purchase is cancelled
// or expires. Only active carts participate in the identity constraint.
$cart->update([
'status' => 'checkout',
'guest_token' => null,
]);
return $this->loadPurchase($purchase);
}
private function restoreSourceCart(Purchase $purchase): bool
{
if ($purchase->cart_id === null) {
return false;
}
/** @var Cart|null $sourceCart */
$sourceCart = Cart::withTrashed()
->whereKey($purchase->cart_id)
->lockForUpdate()
->first();
if ($sourceCart === null) {
return false;
}
/** @var Cart|null $activeCart */
$activeCart = Cart::query()
->where('tenant_codigo', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->where('status', 'active')
->where('id', '!=', $sourceCart->getKey())
->lockForUpdate()
->first();
if ($activeCart !== null) {
$sourceItems = $sourceCart->items()->lockForUpdate()->get();
foreach ($sourceItems as $sourceItem) {
/** @var CartItem|null $activeItem */
$activeItem = $activeCart->items()
->where('catalog_item_id', $sourceItem->catalog_item_id)
->where('variant_id', $sourceItem->variant_id)
->lockForUpdate()
->first();
if ($activeItem === null) {
$activeCart->items()->create([
'catalog_item_id' => $sourceItem->catalog_item_id,
'variant_id' => $sourceItem->variant_id,
'cantidad' => $sourceItem->cantidad,
]);
} else {
$activeItem->increment('cantidad', (int) $sourceItem->cantidad);
}
}
$sourceCart->update([
'status' => 'converted',
'guest_token' => null,
]);
if (! $sourceCart->trashed()) {
$sourceCart->delete();
}
return true;
}
if ($sourceCart->trashed()) {
$sourceCart->restore();
}
$sourceCart->update([
'status' => 'active',
'user_id' => $purchase->user_id,
'guest_token' => null,
]);
return true;
}
private function syncSourceCartItemQuantity(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
): void {
if ($purchase->cart_id === null) {
return;
}
$sourceCart = Cart::withTrashed()
->whereKey($purchase->cart_id)
->lockForUpdate()
->first();
if ($sourceCart === null) {
return;
}
$sourceCart->items()
->where('catalog_item_id', $purchaseItem->source_catalog_item_id)
->where('variant_id', $purchaseItem->source_variant_id)
->update([
'cantidad' => $quantity,
]);
}
private function finalizeSourceCart(Purchase $purchase): void
{
if ($purchase->cart_id === null) {
return;
}
/** @var Cart|null $sourceCart */
$sourceCart = Cart::withTrashed()
->whereKey($purchase->cart_id)
->lockForUpdate()
->first();
if ($sourceCart === null || $sourceCart->trashed()) {
return;
}
$sourceCart->update([
'status' => 'converted',
'guest_token' => null,
]);
$sourceCart->delete();
}
/**
* @param array<string, mixed> $purchaseData
*/
private function createPurchase(
Tenant $tenant,
int $userId,
array $purchaseData,
float $total,
?int $cartId,
): Purchase {
return Purchase::query()->create([
...$purchaseData,
'cart_id' => $cartId,
'tenant_codigo' => $tenant->codigo,
'event_id' => $tenant->active_event_id,
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
'total' => $total,
]);
}
protected function verifyTenantItems(Tenant $tenant, Collection $cartItems): void
{
foreach ($cartItems as $item) {
if ($item->selectedItem() === null) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_missing'),
]);
}
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]);
}
}
}
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
{
/** @var Cart|null $cart */
$cart = Cart::query()
->lockForUpdate()
->find($cartId);
if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) {
throw new NotFoundHttpException('Cart not found for tenant.');
}
if ($cart->status !== 'active') {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.inactive_cart'),
]);
}
return $cart;
}
/**
* @param Collection<int, CartItem> $cartItems
* @return array<int, array<string, mixed>>
*/
protected function buildPurchaseItemsPayload(Collection $cartItems): array
{
return $cartItems
->map(function (CartItem $item): array {
$selectedItem = $item->selectedItem();
$quantity = (int) $item->cantidad;
$unitPrice = $selectedItem?->getPrice() ?? 0;
$imageAttachment = $this->firstImageAttachment($item);
return [
'source_catalog_item_id' => $item->catalog_item_id,
'source_variant_id' => $item->variant_id,
'image_attachment_id' => $imageAttachment?->id,
'nombre' => $item->catalogItem->nombre,
'descripcion' => $item->catalogItem->descripcion,
'slug' => $item->catalogItem->slug,
'item_nombre' => $selectedItem->getName(),
'variant_attributes' => $item->variant === null
? []
: $this->snapshotAttributes($item->variant),
'cantidad' => $quantity,
'precio_unitario' => $unitPrice,
'discount_total' => null,
'tax_total' => null,
'total' => $unitPrice * $quantity,
'reservation_status' => PurchaseItem::RESERVATION_ACTIVE,
];
})
->all();
}
private function resolveSelection(
Tenant $tenant,
int $catalogItemId,
?int $variantId,
): CatalogItem|Variant {
/** @var CatalogItem|null $catalogItem */
$catalogItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->first();
if ($catalogItem === null) {
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
return $catalogItem;
}
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => __('api.cart.variant_required'),
]);
}
$catalogItem->setRelation(
'inventory',
Inventory::query()->whereKey($catalogItem->inventory_id)->lockForUpdate()->firstOrFail(),
);
return $catalogItem;
}
/** @var Variant|null $variant */
$variant = Variant::query()
->whereKey($variantId)
->where('catalog_item_id', $catalogItem->id)
->lockForUpdate()
->first();
if ($variant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation(
'inventory',
Inventory::query()->whereKey($variant->inventory_id)->lockForUpdate()->firstOrFail(),
);
return $variant;
}
private function resolvePurchaseItemSelection(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant
{
return $this->resolveSelection(
$tenant,
(int) $item->source_catalog_item_id,
$item->source_variant_id === null ? null : (int) $item->source_variant_id,
);
}
private function makeDirectCartItem(
CatalogItem|Variant $selection,
int $catalogItemId,
?int $variantId,
int $quantity,
): CartItem {
$catalogItem = $selection instanceof Variant
? $selection->catalogItem
: $selection;
$catalogItem->loadMissing(['inventory', 'attachments']);
if ($selection instanceof Variant) {
$selection->loadMissing([
'inventory',
'attachments',
'catalogItem',
'definitions.itemAttribute.attribute',
]);
}
$item = new CartItem([
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'cantidad' => $quantity,
]);
$item->setRelation('catalogItem', $catalogItem);
$item->setRelation('variant', $selection instanceof Variant ? $selection : null);
return $item;
}
/** @param Collection<int, CartItem> $cartItems */
private function loadCartItems(Collection $cartItems): void
{
$cartItems->load([
'catalogItem.inventory',
'catalogItem.attachments',
'variant.inventory',
'variant.attachments',
'variant.catalogItem',
'variant.definitions.itemAttribute.attribute',
]);
}
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load([
'items.imageAttachment',
]);
}
private function firstImageAttachment(CartItem $item): ?Attachment
{
return $item->variant?->attachments->first()
?? $item->catalogItem?->attachments->first();
}
/** @return array<int, array{name: string, value: mixed}> */
private function snapshotAttributes(Variant $variant): array
{
return $variant->definitions
->map(fn ($definition): array => [
'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''),
'value' => $definition->value,
])
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
->values()
->all();
return $this->releaser->expireOverdue();
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class UserPurchaseLimitService
{
public function assertCanPurchase(
CatalogItem $catalogItem,
int $userId,
int $requestedQuantity,
?int $excludedPurchaseId = null,
string $field = 'quantity',
): void {
DB::transaction(function () use (
$catalogItem,
$userId,
$requestedQuantity,
$excludedPurchaseId,
$field,
): void {
/** @var CatalogItem $catalogItem */
$catalogItem = CatalogItem::query()
->whereKey($catalogItem->getKey())
->lockForUpdate()
->firstOrFail();
$limit = $catalogItem->max_units_per_user;
if ($limit === null) {
return;
}
$purchasedQuantity = (int) PurchaseItem::query()
->where('source_catalog_item_id', $catalogItem->getKey())
->whereHas('purchase', function ($query) use ($userId, $excludedPurchaseId): void {
$query
->where('user_id', $userId)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_PAID,
])
->when(
$excludedPurchaseId !== null,
fn ($query) => $query->whereKeyNot($excludedPurchaseId),
);
})
->sum('cantidad');
if ($purchasedQuantity + $requestedQuantity > $limit) {
throw ValidationException::withMessages([
$field => __('api.purchase_limit.exceeded', ['max' => $limit]),
]);
}
});
}
}

View File

@@ -0,0 +1,33 @@
# Dominio Purchase
## Propósito
Implementa el ciclo de compra y checkout: crea una compra desde el carrito, toma una instantánea de sus ítems, reserva inventario, permite ediciones, inicia el pago y confirma, cancela o vence la operación.
## Modelo
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `paid`, `cancelled`, `rejected` y `expired`.
- `PurchaseItem`: snapshot del producto o variante, cantidad, precio y total al comprar.
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
## Servicios de checkout
`CheckoutService` es la fachada estable. Delega en:
- `StartCheckoutService`: inicia la compra desde el carrito.
- `EditCheckoutService`: modifica cliente o cantidades antes del cierre.
- `CompleteCheckoutService`: completa, envía a revisión o confirma el pago.
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
- `SourceCartService`: sincroniza, restaura o finaliza el carrito fuente.
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
## Endpoints
Bajo `/tenants/{tenant:codigo}/compras`, con `auth:sanctum`: listado, inicio, detalle, edición de ítems, datos del cliente, intención de pago, finalización, revisión y cancelación.
## Dependencias y reglas
Depende de `Cart`, `Catalog`, `Tenant`, `Auth` e `Integration`; emite eventos consumidos por `Ticket` y `Notification`. Los cambios de estado e inventario deben ser transaccionales y usar los servicios del checkout, no actualizaciones directas del modelo.

View File

@@ -3,8 +3,10 @@
namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller;
@@ -30,6 +32,36 @@ class SaleController extends Controller
]);
}
public function show(Request $request, int $sale): SaleDetailResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleDetailResource($this->saleService->detail($tenant, $sale));
}
public function tickets(Request $request, int $sale): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
return SaleTicketResource::collection(
$this->saleService->tickets($tenant, $sale)
);
}
public function confirm(Request $request, int $sale): SaleResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->confirm($tenant, $sale));
}
public function cancel(Request $request, int $sale): SaleResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->cancel($tenant, $sale));
}
public function modifications(Request $request): AnonymousResourceCollection
{
return SaleModificationResource::collection(

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Purchase */
class SaleDetailResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'items' => $this->items->map(fn (PurchaseItem $item): array => [
'id' => $item->id,
'product' => $item->item_nombre,
'event_dates' => $this->eventDates($item),
'quantity' => (int) $item->cantidad,
'unit_price' => $this->formatMoney($item->precio_unitario),
'total' => $this->formatMoney($item->total),
])->values(),
'total' => $this->formatMoney($this->total),
];
}
/** @return list<string> */
private function eventDates(PurchaseItem $item): array
{
return collect($item->variant_attributes ?? [])
->filter(fn (mixed $attribute): bool => is_array($attribute)
&& mb_strtolower(trim((string) ($attribute['name'] ?? ''))) === 'fecha')
->flatMap(function (array $attribute): array {
$value = $attribute['value'] ?? [];
return is_array($value) ? $value : [$value];
})
->filter(fn (mixed $date): bool => is_string($date) && $date !== '')
->values()
->all();
}
private function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Ticket */
class SaleTicketResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'product' => $this->name,
'id' => $this->id,
'expires_at' => $this->getEffectiveExpiresAt(),
'status' => $this->status,
];
}
}

View File

@@ -4,13 +4,19 @@ namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class AdminAppSaleService
{
public function __construct(
protected CheckoutService $checkoutService,
) {}
public function confirmedSalesTotal(Tenant $tenant): string
{
$total = Purchase::query()
@@ -39,6 +45,42 @@ class AdminAppSaleService
->withQueryString();
}
public function detail(Tenant $tenant, int $saleId): Purchase
{
return Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->with('items')
->findOrFail($saleId);
}
/** @return Collection<int, Ticket> */
public function tickets(Tenant $tenant, int $saleId): Collection
{
return $this->findForTenant($tenant, $saleId)
->tickets()
->with('validityGroups.validityTimes')
->orderBy('id')
->get();
}
public function confirm(Tenant $tenant, int $saleId): Purchase
{
$sale = $this->findForTenant($tenant, $saleId);
return $this->saleForResponse(
$this->checkoutService->confirmPaidPurchase($sale)
);
}
public function cancel(Tenant $tenant, int $saleId): Purchase
{
$sale = $this->findForTenant($tenant, $saleId);
return $this->saleForResponse(
$this->checkoutService->cancelPurchaseWithoutRestoringCart($sale)
);
}
/**
* @param array<string, mixed> $filters
* @return Collection<int, Purchase>
@@ -117,4 +159,18 @@ class AdminAppSaleService
->orderByDesc('changed_at')
->orderByDesc('id');
}
protected function findForTenant(Tenant $tenant, int $saleId): Purchase
{
return Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->findOrFail($saleId);
}
protected function saleForResponse(Purchase $sale): Purchase
{
return $sale->refresh()
->loadSum('items as quantity', 'cantidad')
->loadCount('tickets');
}
}

View File

@@ -0,0 +1,28 @@
# Dominio Sale
## Propósito
Provee consultas administrativas y exportaciones de ventas confirmadas, además del historial de modificaciones auditadas.
## Componentes
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación.
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
- `SaleController`: entrada HTTP del panel.
## Endpoints
Bajo `/v1/adminapp/tenant`, protegidos por `auth:sanctum` y `adminapp.tenant`:
- `GET /sales` y `GET /sales/pdf`.
- `GET /sales/modifications` y `GET /sales/modifications/pdf`.
## Dependencias
Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es dueño del estado de una compra; cualquier mutación debe ejecutarse en `Purchase`.
## Consideraciones
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla y PDF.

View File

@@ -10,4 +10,8 @@ Route::prefix('v1/adminapp/tenant')
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
Route::get('sales/modifications', [SaleController::class, 'modifications']);
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
Route::post('sales/{sale}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale');
Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale');
Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale');
Route::get('sales/{sale}', [SaleController::class, 'show'])->whereNumber('sale');
});

View File

@@ -11,10 +11,16 @@ enum FieldType: string
case Multiselect = 'multiselect';
case Color = 'color';
case Image = 'image';
case EventDate = 'event_date';
public function supportsOptions(): bool
{
return in_array($this, [self::Select, self::Multiselect], true);
return in_array($this, [self::Select, self::Multiselect, self::EventDate], true);
}
public function usesDynamicOptions(): bool
{
return $this === self::EventDate;
}
/**

View File

@@ -0,0 +1,18 @@
# Dominio Shared
## Propósito
Contiene contratos técnicos reutilizables que no pertenecen a un único dominio funcional.
## Componentes
- `Enums/FieldType.php`: tipos de campos dinámicos y helpers para determinar si admiten opciones estáticas o dinámicas.
- `Rules/ImageOrBase64Rule.php`: regla de validación para aceptar una imagen subida o codificada en Base64.
## API
No posee modelos persistentes, controladores ni rutas. Sus elementos se importan desde requests y servicios de otros dominios.
## Criterio de pertenencia
Solo deben incorporarse aquí conceptos verdaderamente transversales. Una regla o enum con significado de negocio específico debe permanecer en su dominio propietario.

View File

@@ -0,0 +1,20 @@
# Dominio Staff
## Propósito
Administra usuarios de personal de un tenant y las categorías que tienen habilitadas para operar o escanear.
## Componentes
- `AdminAppStaffController`: listado, alta, modificación y baja.
- `StaffService`: aplica el alcance por tenant, busca personal y sincroniza sus datos/asignaciones.
- `StoreStaffRequest` y `UpdateStaffRequest`: validan cada operación.
- `StaffResource`: representación de salida para AdminApp.
## Endpoints
Recurso REST `/v1/adminapp/tenant/staff`, excepto detalle individual, protegido por `auth:sanctum` y `adminapp.tenant`.
## Dependencias y reglas
Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado.

View File

@@ -0,0 +1,23 @@
# Dominio StorageTest
## Propósito
Expone operaciones técnicas para comprobar la escritura en S3 y la generación de URL temporales.
## Componentes
- `S3TestController`: recibe solicitudes de carga y URL temporal.
- `S3TestService`: almacena un archivo de prueba y genera el enlace firmado.
- `StoreS3TestFileRequest`: valida la carga.
- `GenerateS3TemporaryUrlRequest`: valida ruta y tiempo de expiración.
## Endpoints
Bajo `/storage-test/s3`:
- `POST /upload`.
- `GET /temporary-url`.
## Consideraciones
Es infraestructura de diagnóstico, no una API funcional de archivos. Debe restringirse por entorno o autorización. Para adjuntos de negocio se debe usar el dominio `Attachable`.

View File

@@ -7,7 +7,7 @@ use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -33,7 +33,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'search_product_layout',
'search_group_layout',
'search_items_per_page',
'active_event_id',
'display_categories',
'display_seach_bar',
'event_title',
'event_location',
'event_date_text',
])]
class Tenant extends Model
{
@@ -43,6 +47,8 @@ class Tenant extends Model
'search_product_layout' => ProductLayout::ColumnWithImage->value,
'search_group_layout' => GroupLayout::Paginated->value,
'search_items_per_page' => 12,
'display_categories' => true,
'display_seach_bar' => true,
];
public function getRouteKeyName(): string
@@ -61,7 +67,8 @@ class Tenant extends Model
'search_product_layout' => ProductLayout::class,
'search_group_layout' => GroupLayout::class,
'search_items_per_page' => 'integer',
'active_event_id' => 'integer',
'display_categories' => 'boolean',
'display_seach_bar' => 'boolean',
];
}
@@ -94,16 +101,12 @@ class Tenant extends Model
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
}
/** @return HasMany<Event, $this> */
public function events(): HasMany
/** @return HasMany<EventDate, $this> */
public function eventDates(): HasMany
{
return $this->hasMany(Event::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function activeEvent(): BelongsTo
{
return $this->belongsTo(Event::class, 'active_event_id');
return $this->hasMany(EventDate::class, 'tenant_code', 'codigo')
->orderBy('date')
->orderBy('time_start');
}
/**

View File

@@ -19,6 +19,10 @@ class WebsiteExtra extends Model
protected $table = 'websites_extras';
protected $attributes = [
'is_enabled' => true,
];
private mixed $resolvedConfig = null;
private bool $hasResolvedConfig = false;

View File

@@ -75,6 +75,8 @@ class StoreTenantRequest extends FormRequest
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
'website_type_code' => [
'required_with:extras',
'sometimes',

View File

@@ -85,6 +85,8 @@ class UpdateTenantRequest extends FormRequest
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -9,6 +9,7 @@ use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* @mixin Tenant
@@ -32,14 +33,13 @@ class TenantResource extends JsonResource
'header_bg_color' => $this->header_bg_color,
'footer_bg_color' => $this->footer_bg_color,
'website_type_code' => $this->website_type_code,
'active_event_id' => $this->active_event_id,
'active_event' => $this->whenLoaded('activeEvent', fn () => $this->activeEvent === null
'event_date_text' => $this->event_date_text,
'event' => $this->whenLoaded('eventDates', fn () => $this->event_title === null
? null
: [
'id' => $this->activeEvent->id,
'name' => $this->activeEvent->name,
'address' => $this->activeEvent->address,
'dates' => $this->activeEvent->dates->map(fn ($eventDate): array => [
'title' => $this->event_title,
'location' => $this->event_location,
'dates' => $this->eventDates->map(fn ($eventDate): array => [
'id' => $eventDate->id,
'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start,
@@ -62,6 +62,8 @@ class TenantResource extends JsonResource
'search_product_layout' => $this->search_product_layout->value,
'search_group_layout' => $this->search_group_layout->value,
'search_items_per_page' => $this->search_items_per_page,
'display_categories' => $this->display_categories,
'display_seach_bar' => $this->display_seach_bar,
'social_media' => $this->whenLoaded(
'socialMedia',
fn () => $this->socialMedia
@@ -158,6 +160,10 @@ class TenantResource extends JsonResource
$formatted['submenues'] = $childrenByParent
->get($menu->code, collect())
->sortBy(
fn (Menu $submenu) => Str::lower(Str::ascii($submenu->label)),
SORT_NATURAL
)
->map($formatMenu)
->values();
@@ -167,6 +173,10 @@ class TenantResource extends JsonResource
return $menus
->filter(fn (Menu $menu) => $menu->parent_menu_code === null
|| ! $menuCodes->has($menu->parent_menu_code))
->sortBy(
fn (Menu $menu) => Str::lower(Str::ascii($menu->label)),
SORT_NATURAL
)
->map($formatMenu)
->values();
}

View File

@@ -15,7 +15,7 @@ class TenantInformationService
'footerLogo',
'socialMedia',
'websiteExtras.websiteTypeExtra',
'activeEvent.dates',
'eventDates',
];
/**

View File

@@ -0,0 +1,30 @@
# Dominio Tenant
## Propósito
Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de web, redes sociales, logos y extras configurables por sitio.
## Modelo
- `Tenant`: entidad principal, resuelta en rutas por `codigo`; relaciona catálogo, fechas, redes, menús y configuración visual.
- `WebsiteType`: plantilla o tipo de sitio disponible.
- `WebsiteTypeExtra`: definición de un extra y su configuración admitida.
- `WebsiteExtra`: valor resuelto y estado del extra para un tenant.
- `SocialMedia`: catálogo de redes sociales asociables.
## Servicios
- `TenantService`: crea y actualiza tenants, incluyendo sus recursos asociados.
- `TenantInformationService`: carga un tenant y las relaciones requeridas por cada contexto.
- `WebsiteTypeService`: crea o actualiza tipos de sitio.
- `WebsiteExtraService`: construye reglas dinámicas, crea, actualiza y habilita/deshabilita extras.
- `TenantDomainNormalizer`: normaliza dominios antes de resolver el tenant.
## Endpoints
- Recurso REST público/administrativo `/tenants`.
- Bajo `/v1/adminapp/tenant/website-extras`, con autenticación y contexto de tenant: consulta general, detalle, actualización y activación/desactivación.
## Dependencias y reglas
Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral.

View File

@@ -22,7 +22,7 @@ class TicketController extends Controller
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->with('sourceVariant.eventDate', 'sourceVariant.catalogItem')
->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->orderByDesc('id')
->get();
@@ -36,7 +36,7 @@ class TicketController extends Controller
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->whereIn('id', $ticketIds)
->with('sourceVariant.eventDate', 'sourceVariant.catalogItem')
->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->orderByDesc('id')
->get();

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Ticket\Enums;
enum TicketGenerationPolicy: string
{
case PerEventDate = 'per_event_date';
case OnePerUnit = 'one_per_unit';
/** @return list<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Ticket\Enums;
enum EventProductType: string
enum ValidityTimeType: string
{
case Entry = 'entrada';
case Product = 'producto';
case TimeWindow = 'time_window';
case FixedWindow = 'fixed_window';
/** @return list<string> */
public static function values(): array

View File

@@ -24,9 +24,9 @@ class TicketGenerationException extends RuntimeException
return new self(__('api.ticket.disabled', ['product' => $catalogItem->id]));
}
public static function maximumUseDateReached(CatalogItem $catalogItem): self
public static function ambiguousValidityTime(CatalogItem $catalogItem): self
{
return new self(__('api.ticket.expired', ['product' => $catalogItem->id]));
return new self("Catalog item {$catalogItem->id} resolves more than one validity time.");
}
public static function variantNotFound(

View File

@@ -9,9 +9,12 @@ use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
#[Fillable([
'tenant_code',
@@ -21,8 +24,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'source_purchase_id',
'source_catalog_item_id',
'source_variant_id',
'starts_at',
'expires_at',
'used_at',
'scanner_user_id',
'user_id',
@@ -31,12 +32,19 @@ class Ticket extends Model
{
use HasFactory;
public const STATUS_ACTIVE = 'active';
public const STATUS_EXPIRED = 'expired';
public const STATUS_USED = 'used';
public $timestamps = false;
protected $appends = [
'is_valid',
'is_expired',
'is_used',
'status',
];
protected function casts(): array
@@ -45,8 +53,6 @@ class Ticket extends Model
'source_catalog_item_id' => 'integer',
'source_variant_id' => 'integer',
'source_purchase_id' => 'integer',
'starts_at' => 'datetime',
'expires_at' => 'datetime',
'used_at' => 'datetime',
'scanner_user_id' => 'integer',
'user_id' => 'integer',
@@ -89,15 +95,24 @@ class Ticket extends Model
return $this->belongsTo(Variant::class, 'source_variant_id');
}
/** @return HasMany<TicketValidityGroup, $this> */
public function validityGroups(): HasMany
{
return $this->hasMany(TicketValidityGroup::class);
}
public function isValid(): bool
{
$now = now();
$startsAt = $this->getEffectiveStartsAt();
$expiresAt = $this->getEffectiveExpiresAt();
if ($this->used_at !== null) {
return false;
}
return $this->used_at === null
&& ($startsAt === null || $startsAt->lessThanOrEqualTo($now))
&& ($expiresAt === null || $expiresAt->greaterThan($now));
$validityGroups = $this->resolvedValidityGroups();
return $validityGroups->isEmpty()
|| $validityGroups->contains(
fn (TicketValidityGroup $group): bool => $group->isValid()
);
}
public function getIsValidAttribute(): bool
@@ -107,11 +122,13 @@ class Ticket extends Model
public function getIsExpiredAttribute(): bool
{
$expiresAt = $this->getEffectiveExpiresAt();
$validityGroups = $this->resolvedValidityGroups();
return $this->used_at === null
&& $expiresAt !== null
&& $expiresAt->lessThanOrEqualTo(now());
&& $validityGroups->isNotEmpty()
&& $validityGroups->every(
fn (TicketValidityGroup $group): bool => $group->isExpired()
);
}
public function getIsUsedAttribute(): bool
@@ -119,15 +136,65 @@ class Ticket extends Model
return $this->used_at !== null;
}
public function getStatusAttribute(): string
{
if ($this->is_used) {
return self::STATUS_USED;
}
if ($this->is_expired) {
return self::STATUS_EXPIRED;
}
return self::STATUS_ACTIVE;
}
public function getEffectiveStartsAt(): ?CarbonInterface
{
return $this->sourceVariant?->getMinimumUseDate()
?? $this->starts_at;
return $this->resolvedValidityGroups()
->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveStartsAt())
->filter()
->sortBy(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
public function getEffectiveExpiresAt(): ?CarbonInterface
{
return $this->sourceVariant?->getMaximumUseDate()
?? $this->expires_at;
return $this->resolvedValidityGroups()
->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveExpiresAt())
->filter()
->sortByDesc(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp())
->first();
}
/** @return Collection<int, ValidityTime> */
public function allValidityTimes(): Collection
{
return $this->resolvedValidityGroups()
->flatMap(fn (TicketValidityGroup $group): EloquentCollection => $group->resolvedValidityTimes())
->unique(
fn (ValidityTime $validityTime): int => $validityTime->getKey()
?? spl_object_id($validityTime)
)
->values();
}
/** @return EloquentCollection<int, TicketValidityGroup> */
public function resolvedValidityGroups(): EloquentCollection
{
if ($this->relationLoaded('validityGroups')) {
return $this->getRelation('validityGroups');
}
if (! $this->exists) {
return new EloquentCollection;
}
$validityGroups = $this->validityGroups()
->with('validityTimes')
->get();
$this->setRelation('validityGroups', $validityGroups);
return $validityGroups;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Fillable(['ticket_id'])]
class TicketValidityGroup extends Model
{
public $timestamps = false;
/** @return BelongsTo<Ticket, $this> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
/** @return BelongsToMany<ValidityTime, $this> */
public function validityTimes(): BelongsToMany
{
return $this->belongsToMany(
ValidityTime::class,
'ticket_validity_group_times',
'ticket_validity_group_id',
'validity_time_id',
);
}
public function isValid(?CarbonInterface $at = null): bool
{
$at ??= now();
$validityTimes = $this->resolvedValidityTimes();
return $validityTimes->isNotEmpty()
&& $validityTimes->every(
fn (ValidityTime $validityTime): bool => $validityTime->isValid($at)
);
}
public function isExpired(?CarbonInterface $at = null): bool
{
$at ??= now();
$expiresAt = $this->effectiveExpiresAt($at);
return $expiresAt !== null && $expiresAt->lessThanOrEqualTo($at);
}
public function effectiveStartsAt(?CarbonInterface $at = null): ?CarbonInterface
{
$at ??= now();
$anchor = $this->dateAnchor() ?? $at;
return $this->resolvedValidityTimes()
->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->startsAt($anchor))
->filter()
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
public function effectiveExpiresAt(?CarbonInterface $at = null): ?CarbonInterface
{
$at ??= now();
$anchor = $this->dateAnchor() ?? $at;
return $this->resolvedValidityTimes()
->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface {
$startsAt = $validityTime->startsAt($anchor);
$expiresAt = $validityTime->expiresAt($anchor);
if (
$startsAt !== null
&& $expiresAt !== null
&& $expiresAt->lessThanOrEqualTo($startsAt)
) {
return $expiresAt->addDay();
}
return $expiresAt;
})
->filter()
->sortBy(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp())
->first();
}
/** @return EloquentCollection<int, ValidityTime> */
public function resolvedValidityTimes(): EloquentCollection
{
if ($this->relationLoaded('validityTimes')) {
return $this->getRelation('validityTimes');
}
if (! $this->exists) {
return new EloquentCollection;
}
$validityTimes = $this->validityTimes()->get();
$this->setRelation('validityTimes', $validityTimes);
return $validityTimes;
}
private function dateAnchor(): ?CarbonInterface
{
return $this->resolvedValidityTimes()
->filter(fn (ValidityTime $validityTime): bool => $validityTime->type === ValidityTimeType::FixedWindow)
->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->fixed_starts_at)
->filter()
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'type',
'start_time',
'end_time',
'fixed_starts_at',
'fixed_expires_at',
])]
class ValidityTime extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'type' => ValidityTimeType::class,
'fixed_starts_at' => 'datetime',
'fixed_expires_at' => 'datetime',
];
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
/** @return HasMany<AttributeOption, $this> */
public function attributeOptions(): HasMany
{
return $this->hasMany(AttributeOption::class);
}
/** @return HasOne<EventDate, $this> */
public function eventDate(): HasOne
{
return $this->hasOne(EventDate::class);
}
/** @return BelongsToMany<TicketValidityGroup, $this> */
public function ticketValidityGroups(): BelongsToMany
{
return $this->belongsToMany(
TicketValidityGroup::class,
'ticket_validity_group_times',
'validity_time_id',
'ticket_validity_group_id',
);
}
public function startsAt(
?CarbonInterface $at = null,
): ?CarbonInterface {
if ($this->type === ValidityTimeType::FixedWindow) {
return $this->fixed_starts_at;
}
return $this->atCurrentDate($this->start_time, $at);
}
public function expiresAt(
?CarbonInterface $at = null,
): ?CarbonInterface {
if ($this->type === ValidityTimeType::FixedWindow) {
return $this->fixed_expires_at;
}
return $this->atCurrentDate($this->end_time, $at);
}
public function isValid(
?CarbonInterface $at = null,
): bool {
$at ??= now();
$startsAt = $this->startsAt($at);
$expiresAt = $this->expiresAt($at);
return ($startsAt === null || $startsAt->lessThanOrEqualTo($at))
&& ($expiresAt === null || $expiresAt->greaterThan($at));
}
private function atCurrentDate(
?string $time,
?CarbonInterface $at,
): ?CarbonInterface {
if ($time === null) {
return null;
}
$at ??= now();
$localDate = CarbonImmutable::instance($at)
->format('Y-m-d');
return CarbonImmutable::parse($localDate.' '.$time);
}
}

View File

@@ -20,6 +20,10 @@ class TicketResource extends JsonResource
'description' => $this->description,
'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id,
'validity_times' => ValidityTimeResource::collection($this->allValidityTimes()),
'validity_groups' => TicketValidityGroupResource::collection(
$this->resolvedValidityGroups()
),
'starts_at' => $this->getEffectiveStartsAt(),
'expires_at' => $this->getEffectiveExpiresAt(),
'used_at' => $this->used_at,

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Ticket\Resources;
use App\Domains\Ticket\Models\TicketValidityGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin TicketValidityGroup */
class TicketValidityGroupResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'validity_times' => ValidityTimeResource::collection($this->resolvedValidityTimes()),
'starts_at' => $this->effectiveStartsAt(),
'expires_at' => $this->effectiveExpiresAt(),
'is_valid' => $this->isValid(),
'is_expired' => $this->isExpired(),
];
}
}

Some files were not shown because too many files have changed in this diff Show More