Compare commits
8 Commits
feature/ch
...
b4b888adef
| Author | SHA1 | Date | |
|---|---|---|---|
| b4b888adef | |||
| 3afef63fc7 | |||
| e325c49a89 | |||
| 9c7d025258 | |||
| ba58bf79f8 | |||
| 2910d3f929 | |||
| ca4fea6bcd | |||
| 73bf285bad |
18
app/Domains/Auth/Controllers/UpdateProfileController.php
Normal file
18
app/Domains/Auth/Controllers/UpdateProfileController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Requests\UpdateProfileRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\ProfileService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UpdateProfileController
|
||||
{
|
||||
public function __invoke(UpdateProfileRequest $request, ProfileService $service): JsonResponse
|
||||
{
|
||||
$user = $service->update($request->user(), $request->validated());
|
||||
|
||||
return response()->json(UserResource::make($user)->resolve());
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class RegisterUserRequest extends FormRequest
|
||||
return [
|
||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => ['required', 'string', 'confirmed'],
|
||||
'password' => ['required', 'string', 'confirmed', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
|
||||
'dni' => ['nullable', 'string', 'max:255'],
|
||||
'telefono' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
29
app/Domains/Auth/Requests/UpdateProfileRequest.php
Normal file
29
app/Domains/Auth/Requests/UpdateProfileRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
Rule::unique('users', 'email')->ignore($this->user()->id),
|
||||
],
|
||||
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
||||
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
||||
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Domains/Auth/Services/ProfileService.php
Normal file
41
app/Domains/Auth/Services/ProfileService.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class ProfileService
|
||||
{
|
||||
/**
|
||||
* Update the given user's profile information.
|
||||
*
|
||||
* @param User $user
|
||||
* @param array $data
|
||||
* @return User
|
||||
*/
|
||||
public function update(User $user, array $data): User
|
||||
{
|
||||
// Handle password hashing if a new password is provided
|
||||
if (!empty($data['password'])) {
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
} else {
|
||||
// Remove password from array if empty so we don't overwrite it with null
|
||||
unset($data['password']);
|
||||
}
|
||||
|
||||
// Standardize phone number (strip all but numbers and leading '+')
|
||||
if (!empty($data['telefono'])) {
|
||||
$data['telefono'] = preg_replace('/[^\+0-9]/', '', $data['telefono']);
|
||||
}
|
||||
|
||||
// DNI is already validated as numbers only, but we can do a quick strip just in case
|
||||
if (!empty($data['dni'])) {
|
||||
$data['dni'] = preg_replace('/[^0-9]/', '', $data['dni']);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ use App\Domains\Auth\Controllers\LoginController;
|
||||
use App\Domains\Auth\Controllers\LogoutController;
|
||||
use App\Domains\Auth\Controllers\MeController;
|
||||
use App\Domains\Auth\Controllers\RegisterController;
|
||||
use App\Domains\Auth\Controllers\UpdateProfileController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/register', RegisterController::class);
|
||||
Route::post('/login', LoginController::class);
|
||||
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
||||
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
||||
|
||||
50
app/Domains/Menu/Controllers/MenuController.php
Normal file
50
app/Domains/Menu/Controllers/MenuController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Controllers;
|
||||
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MenuController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$menues = Menu::all();
|
||||
return response()->json($menues);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|unique:menues,code',
|
||||
'route' => 'required|string',
|
||||
]);
|
||||
|
||||
$menu = Menu::create($validated);
|
||||
return response()->json($menu, 201);
|
||||
}
|
||||
|
||||
public function show(Menu $menu): JsonResponse
|
||||
{
|
||||
return response()->json($menu);
|
||||
}
|
||||
|
||||
public function update(Request $request, Menu $menu): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'sometimes|required|string|unique:menues,code,' . $menu->id,
|
||||
'route' => 'sometimes|required|string',
|
||||
]);
|
||||
|
||||
$menu->update($validated);
|
||||
return response()->json($menu);
|
||||
}
|
||||
|
||||
public function destroy(Menu $menu): JsonResponse
|
||||
{
|
||||
$menu->delete();
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
32
app/Domains/Menu/Models/Menu.php
Normal file
32
app/Domains/Menu/Models/Menu.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Models;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Menu extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'menues';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'route',
|
||||
];
|
||||
|
||||
public function tenants(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Tenant::class,
|
||||
'tenant_menues',
|
||||
'menu_code',
|
||||
'tenant_codigo',
|
||||
'code',
|
||||
'codigo'
|
||||
)->withTimestamps();
|
||||
}
|
||||
}
|
||||
10
app/Domains/Menu/Models/TenantMenu.php
Normal file
10
app/Domains/Menu/Models/TenantMenu.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class TenantMenu extends Pivot
|
||||
{
|
||||
protected $table = 'tenant_menues';
|
||||
}
|
||||
6
app/Domains/Menu/routes/api.php
Normal file
6
app/Domains/Menu/routes/api.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Menu\Controllers\MenuController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::apiResource('menues', MenuController::class);
|
||||
20
app/Domains/Product/Models/FeaturedGroup.php
Normal file
20
app/Domains/Product/Models/FeaturedGroup.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Product\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class FeaturedGroup extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tenant_codigo',
|
||||
'group_name',
|
||||
'product_layout',
|
||||
];
|
||||
|
||||
public function featuredProducts(): HasMany
|
||||
{
|
||||
return $this->hasMany(FeaturedProduct::class);
|
||||
}
|
||||
}
|
||||
25
app/Domains/Product/Models/FeaturedProduct.php
Normal file
25
app/Domains/Product/Models/FeaturedProduct.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Product\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class FeaturedProduct extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'featured_group_id',
|
||||
'product_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
public function featuredGroup(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FeaturedGroup::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ class PurchaseController extends Controller
|
||||
{
|
||||
return PurchaseResource::collection(
|
||||
Purchase::query()
|
||||
->with(['items.variant.product', 'items.variant.definitions.productAttribute.attribute'])
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('user_id', $request->user()->id)
|
||||
->when($request->query('status'), function ($query, $status) {
|
||||
@@ -48,7 +47,7 @@ class PurchaseController extends Controller
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
return PurchaseResource::make(
|
||||
$compra->loadMissing(['items.variant.product', 'items.variant.definitions.productAttribute.attribute'])
|
||||
$compra->loadMissing($this->purchaseDetailRelations())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,4 +139,19 @@ class PurchaseController extends Controller
|
||||
|
||||
return $purchase;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function purchaseDetailRelations(): array
|
||||
{
|
||||
return [
|
||||
'items.variant.product',
|
||||
'items.variant.definitions.productAttribute.attribute',
|
||||
'items.variant.attachments',
|
||||
'cart.items.variant.product',
|
||||
'cart.items.variant.definitions.productAttribute.attribute',
|
||||
'cart.items.variant.attachments',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
namespace App\Domains\Purchase\Resources;
|
||||
|
||||
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Purchase\Models\PurchaseItem
|
||||
* @mixin \App\Domains\Purchase\Models\PurchaseItem|\App\Domains\Cart\Models\CartItem
|
||||
*/
|
||||
class PurchaseItemResource extends JsonResource
|
||||
{
|
||||
@@ -18,24 +19,90 @@ class PurchaseItemResource extends JsonResource
|
||||
{
|
||||
$variant = $this->variant;
|
||||
$product = $variant?->product;
|
||||
$quantity = (int) ($this->cantidad ?? 0);
|
||||
$unitPrice = $this->resolveUnitPrice();
|
||||
$lineTotal = $this->resolveLineTotal($unitPrice, $quantity);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'cantidad' => $this->cantidad,
|
||||
'precio_unitario' => $this->formatMoney($this->precio_unitario),
|
||||
'total' => $this->formatMoney($this->total),
|
||||
'quantity' => $quantity,
|
||||
'unit_price' => $this->formatMoney($unitPrice),
|
||||
'line_total' => $this->formatMoney($lineTotal),
|
||||
'product' => $product === null ? null : [
|
||||
'id' => $product->id,
|
||||
'nombre' => $product->nombre,
|
||||
'slug' => $product->slug,
|
||||
'imagen' => $this->resolveImageUrl(),
|
||||
],
|
||||
'variant' => $variant === null ? null : [
|
||||
'id' => $variant->id,
|
||||
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions),
|
||||
'attributes' => $this->resolveAttributes(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveUnitPrice(): float
|
||||
{
|
||||
if ($this->resource instanceof PurchaseItem) {
|
||||
return (float) ($this->precio_unitario ?? 0);
|
||||
}
|
||||
|
||||
if ($this->resource instanceof CartItem) {
|
||||
return (float) ($this->variant?->product?->precio ?? 0);
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
protected function resolveLineTotal(float $unitPrice, int $quantity): float
|
||||
{
|
||||
if ($this->resource instanceof PurchaseItem) {
|
||||
return (float) ($this->total ?? 0);
|
||||
}
|
||||
|
||||
return $unitPrice * $quantity;
|
||||
}
|
||||
|
||||
protected function resolveImageUrl(): ?string
|
||||
{
|
||||
$variant = $this->variant;
|
||||
|
||||
if ($variant === null || ! $variant->relationLoaded('attachments')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attachment = $variant->attachments->first();
|
||||
|
||||
if ($attachment === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $attachment->getTemporaryUrl(1440);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, value: mixed}>
|
||||
*/
|
||||
protected function resolveAttributes(): array
|
||||
{
|
||||
$variant = $this->variant;
|
||||
|
||||
if ($variant === null || ! $variant->relationLoaded('definitions')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $variant->definitions
|
||||
->map(function ($definition): array {
|
||||
return [
|
||||
'name' => (string) ($definition->productAttribute?->attribute?->nombre ?? ''),
|
||||
'value' => $definition->value,
|
||||
];
|
||||
})
|
||||
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function formatMoney(float|int|string|null $amount): ?string
|
||||
{
|
||||
if ($amount === null) {
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
namespace App\Domains\Purchase\Resources;
|
||||
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Purchase\Models\Purchase
|
||||
@@ -15,20 +18,18 @@ class PurchaseResource extends JsonResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$items = $this->resource->relationLoaded('items')
|
||||
? $this->resource->getRelation('items')
|
||||
: collect();
|
||||
[$items, $itemsSource] = $this->resolveItems();
|
||||
|
||||
$subtotal = $items->isNotEmpty()
|
||||
? $items->reduce(
|
||||
fn (float $carry, $item): float => $carry + ((float) $item->precio_unitario * $item->cantidad),
|
||||
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item),
|
||||
0.0,
|
||||
)
|
||||
: (float) ($this->total ?? 0);
|
||||
|
||||
$total = $items->isNotEmpty()
|
||||
? $items->reduce(
|
||||
fn (float $carry, $item): float => $carry + (float) $item->total,
|
||||
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item),
|
||||
0.0,
|
||||
)
|
||||
: (float) ($this->total ?? 0);
|
||||
@@ -38,18 +39,66 @@ class PurchaseResource extends JsonResource
|
||||
'cart_id' => $this->cart_id,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'user_id' => $this->user_id,
|
||||
'created_at' => $this->created_at,
|
||||
'status' => $this->status,
|
||||
'payment_method' => $this->payment_method,
|
||||
'dni' => $this->dni,
|
||||
'telefono' => $this->telefono,
|
||||
'nombre_apellido' => $this->nombre_apellido,
|
||||
'email' => $this->email,
|
||||
'items_source' => $itemsSource,
|
||||
'items' => PurchaseItemResource::collection($items),
|
||||
'subtotal' => $this->formatMoney($subtotal),
|
||||
'total' => $this->formatMoney($total),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Collection<int, PurchaseItem|CartItem>, 1: string|null}
|
||||
*/
|
||||
protected function resolveItems(): array
|
||||
{
|
||||
if (! $this->resource->relationLoaded('items')) {
|
||||
return [collect(), null];
|
||||
}
|
||||
|
||||
$purchaseItems = $this->resource->getRelation('items');
|
||||
|
||||
if ($purchaseItems->isNotEmpty()) {
|
||||
return [$purchaseItems, 'purchase'];
|
||||
}
|
||||
|
||||
if (! $this->resource->relationLoaded('cart')) {
|
||||
return [collect(), null];
|
||||
}
|
||||
|
||||
$cart = $this->resource->getRelation('cart');
|
||||
|
||||
if ($cart === null || ! $cart->relationLoaded('items')) {
|
||||
return [collect(), null];
|
||||
}
|
||||
|
||||
return [$cart->getRelation('items'), 'cart'];
|
||||
}
|
||||
|
||||
protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float
|
||||
{
|
||||
if ($item instanceof PurchaseItem) {
|
||||
return (float) $item->precio_unitario * $item->cantidad;
|
||||
}
|
||||
|
||||
return (float) ($item->variant?->product?->precio ?? 0) * $item->cantidad;
|
||||
}
|
||||
|
||||
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
|
||||
{
|
||||
if ($item instanceof PurchaseItem) {
|
||||
return (float) ($item->total ?? 0);
|
||||
}
|
||||
|
||||
return $this->resolveItemSubtotal($item);
|
||||
}
|
||||
|
||||
protected function formatMoney(float|int|string|null $amount): string
|
||||
{
|
||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||
|
||||
@@ -15,7 +15,7 @@ class BootstrapTenantController extends Controller
|
||||
$dominio = $request->validated('dominio');
|
||||
|
||||
return TenantResource::make(
|
||||
Tenant::query()->with(['headerLogo', 'footerLogo'])->where('dominio', $dominio)->firstOrFail()
|
||||
Tenant::query()->with(['headerLogo', 'footerLogo', 'menues'])->where('dominio', $dominio)->firstOrFail()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'footer_bg_color',
|
||||
'header_logo_id',
|
||||
'footer_logo_id',
|
||||
'hero_config',
|
||||
'event_config',
|
||||
])]
|
||||
class Tenant extends Model
|
||||
{
|
||||
@@ -32,6 +34,19 @@ class Tenant extends Model
|
||||
return 'codigo';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'hero_config' => 'array',
|
||||
'event_config' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Attachment, $this>
|
||||
*/
|
||||
@@ -52,5 +67,17 @@ class Tenant extends Model
|
||||
{
|
||||
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
public function menues(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
\App\Domains\Menu\Models\Menu::class,
|
||||
'tenant_menues',
|
||||
'tenant_codigo',
|
||||
'menu_code',
|
||||
'codigo',
|
||||
'code'
|
||||
)->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,17 @@ class StoreTenantRequest extends FormRequest
|
||||
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
'hero_config' => ['nullable', 'array'],
|
||||
'hero_config.background_image' => ['nullable', 'string'],
|
||||
'hero_config.title_html' => ['nullable', 'string'],
|
||||
'hero_config.description_html' => ['nullable', 'string'],
|
||||
'hero_config.button_text' => ['nullable', 'string'],
|
||||
'hero_config.button_href' => ['nullable', 'string'],
|
||||
'event_config' => ['nullable', 'array'],
|
||||
'event_config.title' => ['nullable', 'string'],
|
||||
'event_config.location' => ['nullable', 'string'],
|
||||
'event_config.dates' => ['nullable', 'array'],
|
||||
'event_config.dates.*' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,17 @@ class UpdateTenantRequest extends FormRequest
|
||||
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
'hero_config' => ['nullable', 'array'],
|
||||
'hero_config.background_image' => ['nullable', 'string'],
|
||||
'hero_config.title_html' => ['nullable', 'string'],
|
||||
'hero_config.description_html' => ['nullable', 'string'],
|
||||
'hero_config.button_text' => ['nullable', 'string'],
|
||||
'hero_config.button_href' => ['nullable', 'string'],
|
||||
'event_config' => ['nullable', 'array'],
|
||||
'event_config.title' => ['nullable', 'string'],
|
||||
'event_config.location' => ['nullable', 'string'],
|
||||
'event_config.dates' => ['nullable', 'array'],
|
||||
'event_config.dates.*' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ class TenantResource extends JsonResource
|
||||
// 1 day
|
||||
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
||||
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440 ),
|
||||
'hero_config' => $this->hero_config,
|
||||
'event_config' => $this->event_config,
|
||||
'menues' => $this->whenLoaded('menues'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->json('hero_config')->nullable();
|
||||
$table->json('event_config')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->dropColumn(['hero_config', 'event_config']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('menues', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->unique();
|
||||
$table->string('route');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('menues');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_menues', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('tenant_codigo');
|
||||
$table->string('menu_code');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('tenant_codigo')->references('codigo')->on('tenants')->onDelete('cascade');
|
||||
$table->foreign('menu_code')->references('code')->on('menues')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_menues');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('featured_groups', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('tenant_codigo');
|
||||
$table->string('group_name');
|
||||
$table->enum('product_layout', ['row', 'column_with_image', 'column_with_cart']);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('featured_groups');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('featured_products', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('featured_group_id')->constrained('featured_groups')->onDelete('cascade');
|
||||
$table->unsignedBigInteger('product_id');
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('featured_products');
|
||||
}
|
||||
};
|
||||
@@ -29,6 +29,7 @@ class DatabaseSeeder extends Seeder
|
||||
BrandSeeder::class,
|
||||
ProductCatalogFromImagesSeeder::class,
|
||||
TelepagosIntegrationSeeder::class,
|
||||
MenuSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
48
database/seeders/MenuSeeder.php
Normal file
48
database/seeders/MenuSeeder.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
|
||||
class MenuSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$menus = [
|
||||
['code' => 'index', 'route' => '/'],
|
||||
['code' => 'product.detail', 'route' => '/product/:id'],
|
||||
['code' => 'checkout', 'route' => '/checkout'],
|
||||
['code' => 'profile', 'route' => '/profile'],
|
||||
['code' => 'purchases', 'route' => '/purchases'],
|
||||
['code' => 'tickets', 'route' => '/tickets'],
|
||||
];
|
||||
|
||||
foreach ($menus as $menuData) {
|
||||
Menu::firstOrCreate(['code' => $menuData['code']], $menuData);
|
||||
}
|
||||
|
||||
$tenants = Tenant::all();
|
||||
|
||||
$allMenus = Menu::pluck('code')->toArray();
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
$menuCodes = $allMenus;
|
||||
|
||||
if ($tenant->codigo === 'sonder') {
|
||||
// Sonder NO tiene tickets
|
||||
$menuCodes = array_diff($menuCodes, ['tickets']);
|
||||
} else {
|
||||
// Los demás NO tienen product.detail
|
||||
$menuCodes = array_diff($menuCodes, ['product.detail']);
|
||||
}
|
||||
|
||||
// Usar sync para asociar los menues al tenant
|
||||
$tenant->menues()->sync($menuCodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,5 +85,80 @@ class TenantSeeder extends Seeder
|
||||
'header_logo' => $headerLogo,
|
||||
'footer_logo' => $footerLogo,
|
||||
]);
|
||||
|
||||
// Check if tenant 'fiesta_futbol_infantil' already exists
|
||||
$existingFiesta = Tenant::query()->where('codigo', 'fiesta_futbol_infantil')->first();
|
||||
if ($existingFiesta) {
|
||||
$attachmentService = app(\App\Domains\Attachable\Services\AttachmentService::class);
|
||||
if ($existingFiesta->headerLogo) {
|
||||
try {
|
||||
$attachmentService->delete($existingFiesta->headerLogo);
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
if ($existingFiesta->footerLogo && $existingFiesta->footer_logo_id !== $existingFiesta->header_logo_id) {
|
||||
try {
|
||||
$attachmentService->delete($existingFiesta->footerLogo);
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
$existingFiesta->delete();
|
||||
}
|
||||
|
||||
$fiestaDomain = 'fiesta-futbol-infantil.localhost';
|
||||
$existingFiestaDomain = Tenant::query()->where('dominio', $fiestaDomain)->first();
|
||||
if ($existingFiestaDomain) {
|
||||
$existingFiestaDomain->delete();
|
||||
}
|
||||
|
||||
$fiestaHeaderImagePath = public_path('images/futbol_infantil_header.png');
|
||||
$fiestaFooterImagePath = public_path('images/futbol_infantil_footer.png');
|
||||
|
||||
if (! file_exists($fiestaHeaderImagePath)) {
|
||||
throw new \RuntimeException("Image not found at path: {$fiestaHeaderImagePath}");
|
||||
}
|
||||
|
||||
if (! file_exists($fiestaFooterImagePath)) {
|
||||
throw new \RuntimeException("Image not found at path: {$fiestaFooterImagePath}");
|
||||
}
|
||||
|
||||
$fiestaHeaderLogo = new UploadedFile(
|
||||
$fiestaHeaderImagePath,
|
||||
'futbol_infantil_header.png',
|
||||
'image/png',
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
$fiestaFooterLogo = new UploadedFile(
|
||||
$fiestaFooterImagePath,
|
||||
'futbol_infantil_footer.png',
|
||||
'image/png',
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
$this->tenantService->create([
|
||||
'codigo' => 'fiesta_futbol_infantil',
|
||||
'nombre' => 'Fiesta Fútbol Infantil',
|
||||
'dominio' => $fiestaDomain,
|
||||
'primary_color' => '#00973F',
|
||||
'secondary_color' => '#A0A0A0',
|
||||
'danger_color' => '#FF8888',
|
||||
'success_color' => '#198754',
|
||||
'header_bg_color' => '#015327',
|
||||
'footer_bg_color' => '#015327',
|
||||
'header_logo' => $fiestaHeaderLogo,
|
||||
'footer_logo' => $fiestaFooterLogo,
|
||||
'hero_config' => [
|
||||
'title_html' => '<strong>ASEGURÁ TU LUGAR</strong>',
|
||||
'description_html' => '<strong>Comprá tu entrada oficial en segundos</strong> de forma 100% segura. Preparate para vivir la experiencia completa.',
|
||||
'button_text' => 'Quiero mi entrada',
|
||||
'button_href' => null,
|
||||
],
|
||||
'event_config' => [
|
||||
'title' => 'FIESTA NACIONAL DEL FÚTBOL INFANTIL',
|
||||
'location' => 'Sunchales, Santa Fe',
|
||||
'dates' => ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/images/futbol_infantil_footer.png
Normal file
BIN
public/images/futbol_infantil_footer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/futbol_infantil_header.png
Normal file
BIN
public/images/futbol_infantil_header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -10,3 +10,4 @@ require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Integration/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Menu/routes/api.php';
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\Product;
|
||||
use App\Domains\Catalog\Models\ProductVariant;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
@@ -76,6 +77,7 @@ class StorePurchaseTest extends TestCase
|
||||
$response->assertJsonPath('data.email', 'juan.perez@example.com');
|
||||
$response->assertJsonPath('data.tenant_codigo', 'sonder');
|
||||
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
|
||||
$response->assertJsonPath('data.items_source', null);
|
||||
$response->assertJsonPath('data.items', []);
|
||||
$response->assertJsonPath('data.subtotal', '100.00');
|
||||
$response->assertJsonPath('data.total', '100.00');
|
||||
@@ -159,6 +161,150 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_purchase_detail_uses_cart_items_for_created_purchase(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'buyer@example.com',
|
||||
]);
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
|
||||
->assertJsonPath('data.items_source', 'cart')
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.quantity', 2)
|
||||
->assertJsonPath('data.items.0.unit_price', '50.00')
|
||||
->assertJsonPath('data.items.0.line_total', '100.00')
|
||||
->assertJsonPath('data.items.0.product.id', $variant->product->id)
|
||||
->assertJsonPath('data.items.0.product.nombre', $variant->product->nombre)
|
||||
->assertJsonPath('data.items.0.product.slug', $variant->product->slug)
|
||||
->assertJsonPath('data.items.0.product.imagen', null)
|
||||
->assertJsonPath('data.items.0.variant.id', $variant->id)
|
||||
->assertJsonPath('data.items.0.variant.attributes', [])
|
||||
->assertJsonPath('data.subtotal', '100.00')
|
||||
->assertJsonPath('data.total', '100.00');
|
||||
}
|
||||
|
||||
public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'buyer@example.com',
|
||||
]);
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
|
||||
$purchase->update([
|
||||
'payment_method' => 'transfer',
|
||||
]);
|
||||
|
||||
app(CheckoutService::class)->completePurchase($purchase);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
|
||||
->assertJsonPath('data.items_source', 'cart')
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.quantity', 2)
|
||||
->assertJsonPath('data.items.0.unit_price', '50.00')
|
||||
->assertJsonPath('data.items.0.line_total', '100.00')
|
||||
->assertJsonPath('data.items.0.product.imagen', null)
|
||||
->assertJsonPath('data.items.0.variant.attributes', [])
|
||||
->assertJsonPath('data.subtotal', '100.00')
|
||||
->assertJsonPath('data.total', '100.00');
|
||||
}
|
||||
|
||||
public function test_purchase_detail_uses_purchase_items_for_paid_purchase_even_without_cart(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'buyer@example.com',
|
||||
]);
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
|
||||
$purchase->update([
|
||||
'payment_method' => 'transfer',
|
||||
]);
|
||||
|
||||
$checkoutService = app(CheckoutService::class);
|
||||
$purchase = $checkoutService->completePurchase($purchase);
|
||||
$checkoutService->confirmPurchase($purchase);
|
||||
$purchase->refresh()->markAsPaid();
|
||||
|
||||
$this->assertSoftDeleted('carritos', [
|
||||
'id' => $purchase->cart_id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_PAID)
|
||||
->assertJsonPath('data.items_source', 'purchase')
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.quantity', 2)
|
||||
->assertJsonPath('data.items.0.unit_price', '50.00')
|
||||
->assertJsonPath('data.items.0.line_total', '100.00')
|
||||
->assertJsonPath('data.items.0.product.id', $variant->product->id)
|
||||
->assertJsonPath('data.items.0.product.imagen', null)
|
||||
->assertJsonPath('data.items.0.variant.id', $variant->id)
|
||||
->assertJsonPath('data.items.0.variant.attributes', [])
|
||||
->assertJsonPath('data.subtotal', '100.00')
|
||||
->assertJsonPath('data.total', '100.00');
|
||||
}
|
||||
|
||||
public function test_purchase_detail_prefers_purchase_items_when_both_sources_exist(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'buyer@example.com',
|
||||
]);
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
|
||||
$purchase->items()->create([
|
||||
'producto_variante_id' => $variant->id,
|
||||
'cantidad' => 1,
|
||||
'precio_unitario' => '50.00',
|
||||
'discount_total' => null,
|
||||
'tax_total' => null,
|
||||
'total' => '50.00',
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items_source', 'purchase')
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.quantity', 1)
|
||||
->assertJsonPath('data.items.0.line_total', '50.00')
|
||||
->assertJsonPath('data.subtotal', '50.00')
|
||||
->assertJsonPath('data.total', '50.00');
|
||||
}
|
||||
|
||||
public function test_purchase_index_returns_empty_items_without_loaded_relations(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'buyer@example.com',
|
||||
]);
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/tenants/sonder/compras?status=created')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.items_source', null)
|
||||
->assertJsonPath('data.0.items', [])
|
||||
->assertJsonPath('data.0.total', '100.00');
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_cart_from_another_user(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
@@ -290,6 +436,30 @@ class StorePurchaseTest extends TestCase
|
||||
])->load('product');
|
||||
}
|
||||
|
||||
protected function createCheckoutPurchase(
|
||||
User $user,
|
||||
string $tenantCode,
|
||||
ProductVariant $variant,
|
||||
int $quantity,
|
||||
): Purchase {
|
||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||
$cart = Cart::query()->create([
|
||||
'tenant_codigo' => $tenantCode,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$cart->addItem($variant->id, $quantity);
|
||||
|
||||
return app(CheckoutService::class)->startCheckout($tenant, $user->id, [
|
||||
'cart_id' => $cart->id,
|
||||
'dni' => '987654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
|
||||
{
|
||||
$hdrKey = (string) \Illuminate\Support\Str::uuid();
|
||||
|
||||
Reference in New Issue
Block a user