Compare commits

...

12 Commits

20 changed files with 291 additions and 50 deletions

View File

@@ -172,17 +172,29 @@ class CatalogItem extends Model
} }
/** @param Builder<CatalogItem> $query */ /** @param Builder<CatalogItem> $query */
public function scopeWhereVariantsAvailable(Builder $query): Builder public function scopeWhereAvailable(Builder $query): Builder
{ {
return $query->where(function (Builder $query): void { return $query->where(function (Builder $query): void {
$query $query
->whereDoesntHave('variants') ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhereHas( ->orWhereHas(
'variants.inventory', 'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
); )
->orWhere(function (Builder $directItemQuery): void {
$directItemQuery
->whereDoesntHave('variants')
->where(function (Builder $inventoryQuery): void {
$inventoryQuery
->whereNull('catalog_items.inventory_id')
->orWhereHas(
'inventory',
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
);
});
});
}); });
} }

View File

@@ -46,7 +46,7 @@ class CatalogFeaturedItemResource extends JsonResource
$availableStock, $availableStock,
$remainingUserQuota, $remainingUserQuota,
), ),
'variants' => $catalogItem->variants 'variants' => $catalogItem->visibleVariants()
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array { ->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited $variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null ? null
@@ -115,7 +115,7 @@ class CatalogFeaturedItemResource extends JsonResource
private function firstImageUrl(CatalogItem $catalogItem): ?string private function firstImageUrl(CatalogItem $catalogItem): ?string
{ {
$attachment = $catalogItem->attachments->first() $attachment = $catalogItem->attachments->first()
?? $catalogItem->variants ?? $catalogItem->visibleVariants()
->flatMap(fn (Variant $variant) => $variant->attachments) ->flatMap(fn (Variant $variant) => $variant->attachments)
->first(); ->first();

View File

@@ -16,8 +16,9 @@ class CatalogSearchItemResource extends JsonResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$availableStock = $this->availableStock(); $availableStock = $this->availableStock();
$visibleVariants = $this->visibleVariants();
$attachment = $this->attachments->first() $attachment = $this->attachments->first()
?? $this->variants ?? $visibleVariants
->flatMap(fn (Variant $variant) => $variant->attachments) ->flatMap(fn (Variant $variant) => $variant->attachments)
->first(); ->first();
@@ -30,7 +31,7 @@ class CatalogSearchItemResource extends JsonResource
'image' => $attachment?->getTemporaryUrl(1440), 'image' => $attachment?->getTemporaryUrl(1440),
'maximum_addable_quantity' => $this->maximumAddable($availableStock), 'maximum_addable_quantity' => $this->maximumAddable($availableStock),
'unavailable_message' => $this->unavailableMessage($availableStock), 'unavailable_message' => $this->unavailableMessage($availableStock),
'variants' => $this->variants 'variants' => $visibleVariants
->map(function (Variant $variant): array { ->map(function (Variant $variant): array {
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited $variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
? null ? null

View File

@@ -205,6 +205,13 @@ class CatalogService
]); ]);
$visibleVariants = $catalogItem->visibleVariants(); $visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
$catalogItem->setRelation('variants', $visibleVariants);
$selectedVariant = $variantId === null $selectedVariant = $variantId === null
? $visibleVariants->first() ? $visibleVariants->first()
: $visibleVariants->firstWhere('id', $variantId); : $visibleVariants->firstWhere('id', $variantId);
@@ -231,6 +238,7 @@ class CatalogService
$paginator = CatalogItem::query() $paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->whereAvailable()
->where(function (Builder $query) use ($containsPattern): void { ->where(function (Builder $query) use ($containsPattern): void {
$query $query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
@@ -280,6 +288,7 @@ class CatalogService
return CatalogItem::query() return CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id) ->where('category_id', $category->id)
->whereAvailable()
->with([ ->with([
'attachments', 'attachments',
'inventory', 'inventory',

View File

@@ -49,6 +49,7 @@ class FeaturedGroupService
{ {
$query = CatalogItem::query() $query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code) ->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->whereAvailable()
->where(function (Builder $query): void { ->where(function (Builder $query): void {
$query $query
->whereDoesntHave('category') ->whereDoesntHave('category')

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Sale\Controllers\AdminApp; namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest; use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource; use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource; use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource; use App\Domains\Sale\Resources\AdminApp\SaleResource;
@@ -71,23 +73,25 @@ class SaleController extends Controller
); );
} }
public function downloadPdf(AdminAppSaleIndexRequest $request): Response public function downloadPdf(AdminAppSalePdfRequest $request): Response
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadSales( return $this->salePdfService->downloadSales(
$tenant, $tenant,
$this->saleService->salesForExport($tenant, $request->validated()), $this->saleService->salesForExport($tenant, $request->validated()),
$request->validated('timezone'),
); );
} }
public function downloadModificationsPdf(Request $request): Response public function downloadModificationsPdf(AdminAppSaleModificationPdfRequest $request): Response
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadModifications( return $this->salePdfService->downloadModifications(
$tenant, $tenant,
$this->saleService->modificationsForExport($tenant), $this->saleService->modificationsForExport($tenant),
$request->validated('timezone'),
); );
} }
} }

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
use Illuminate\Foundation\Http\FormRequest;
class AdminAppSaleModificationPdfRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
class AdminAppSalePdfRequest extends AdminAppSaleIndexRequest
{
/** @return array<string, list<string>> */
public function rules(): array
{
return [
...parent::rules(),
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -13,12 +13,14 @@ use Illuminate\Support\Collection;
class AdminAppSalePdfService class AdminAppSalePdfService
{ {
/** @param Collection<int, Purchase> $sales */ /** @param Collection<int, Purchase> $sales */
public function downloadSales(Tenant $tenant, Collection $sales): Response public function downloadSales(Tenant $tenant, Collection $sales, string $timeZone): Response
{ {
$generatedAt = now();
$pdf = Pdf::loadView('pdf.adminapp.sales', [ $pdf = Pdf::loadView('pdf.adminapp.sales', [
'tenant' => $tenant, 'tenant' => $tenant,
'sales' => $sales, 'sales' => $sales,
'generatedAt' => now(), 'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
'confirmedSalesTotal' => number_format( 'confirmedSalesTotal' => number_format(
(float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'), (float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'),
2, 2,
@@ -30,23 +32,30 @@ class AdminAppSalePdfService
$this->addPageNumbers($pdf); $this->addPageNumbers($pdf);
return $pdf->download( return $pdf->download(
'ventas_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf' 'ventas_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
); );
} }
/** @param Collection<int, ValueChange> $modifications */ /** @param Collection<int, ValueChange> $modifications */
public function downloadModifications(Tenant $tenant, Collection $modifications): Response public function downloadModifications(
{ Tenant $tenant,
Collection $modifications,
string $timeZone,
): Response {
$generatedAt = now();
$pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [ $pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [
'tenant' => $tenant, 'tenant' => $tenant,
'modifications' => $modifications, 'modifications' => $modifications,
'generatedAt' => now(), 'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
])->setPaper('a4', 'landscape'); ])->setPaper('a4', 'landscape');
$this->addPageNumbers($pdf); $this->addPageNumbers($pdf);
return $pdf->download( return $pdf->download(
'historial_modificaciones_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf' 'historial_modificaciones_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
); );
} }

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Shared\Rules;
use Closure;
use DateTimeZone;
use Exception;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidTimezone implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
try {
new DateTimeZone($value);
} catch (Exception) {
$fail(__('validation.timezone'));
}
}
}

View File

@@ -37,6 +37,7 @@ return [
'required_with' => 'The :attribute field is required when :values is present.', 'required_with' => 'The :attribute field is required when :values is present.',
'required_without' => 'The :attribute field is required when :values is not present.', 'required_without' => 'The :attribute field is required when :values is not present.',
'string' => 'The :attribute must be a string.', 'string' => 'The :attribute must be a string.',
'timezone' => 'The timezone sent by the browser is invalid.',
'unique' => 'The :attribute has already been taken.', 'unique' => 'The :attribute has already been taken.',
'url' => 'The :attribute must be a valid URL.', 'url' => 'The :attribute must be a valid URL.',
'uuid' => 'The :attribute must be a valid UUID.', 'uuid' => 'The :attribute must be a valid UUID.',
@@ -52,6 +53,7 @@ return [
'password_confirmation' => 'password confirmation', 'password_confirmation' => 'password confirmation',
'telefono' => 'phone number', 'telefono' => 'phone number',
'tenant_codigo' => 'tenant', 'tenant_codigo' => 'tenant',
'timezone' => 'timezone',
'variant_id' => 'variant', 'variant_id' => 'variant',
], ],
]; ];

View File

@@ -37,6 +37,7 @@ return [
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.', 'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.', 'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
'string' => ':Attribute debe ser texto.', 'string' => ':Attribute debe ser texto.',
'timezone' => 'La zona horaria enviada por el navegador no es válida.',
'unique' => 'El :attribute ya está en uso.', 'unique' => 'El :attribute ya está en uso.',
'url' => ':Attribute debe ser una URL válida.', 'url' => ':Attribute debe ser una URL válida.',
'uuid' => ':Attribute debe ser un UUID válido.', 'uuid' => ':Attribute debe ser un UUID válido.',
@@ -52,6 +53,7 @@ return [
'password_confirmation' => 'confirmación de contraseña', 'password_confirmation' => 'confirmación de contraseña',
'telefono' => 'teléfono', 'telefono' => 'teléfono',
'tenant_codigo' => 'tenant', 'tenant_codigo' => 'tenant',
'timezone' => 'zona horaria',
'variant_id' => 'variante', 'variant_id' => 'variante',
], ],
]; ];

View File

@@ -25,7 +25,7 @@
</head> </head>
<body> <body>
<h1>Historial de modificaciones de ventas</h1> <h1>Historial de modificaciones de ventas</h1>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p> <p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->copy()->timezone($timeZone)->format('d/m/Y H:i') }}</p>
<table> <table>
<thead> <thead>
@@ -44,8 +44,8 @@
@forelse ($modifications as $modification) @forelse ($modifications as $modification)
@php($sale = $modification->trackable) @php($sale = $modification->trackable)
<tr> <tr>
<td>{{ $modification->changed_at->format('d/m/Y') }}</td> <td>{{ $modification->changed_at->copy()->timezone($timeZone)->format('d/m/Y') }}</td>
<td>{{ $modification->changed_at->format('H:i:s') }}</td> <td>{{ $modification->changed_at->copy()->timezone($timeZone)->format('H:i:s') }}</td>
<td>#{{ $modification->trackable_id }}</td> <td>#{{ $modification->trackable_id }}</td>
<td>{{ $sale?->nombre_apellido ?: 'Sin nombre' }}</td> <td>{{ $sale?->nombre_apellido ?: 'Sin nombre' }}</td>
<td>{{ $modification->attribute }}</td> <td>{{ $modification->attribute }}</td>

View File

@@ -22,7 +22,7 @@
</head> </head>
<body> <body>
<h1>Historial de ventas</h1> <h1>Historial de ventas</h1>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p> <p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->copy()->timezone($timeZone)->format('d/m/Y H:i') }}</p>
<div class="summary"> <div class="summary">
Total de ventas confirmadas en este reporte: <strong>${{ number_format((float) $confirmedSalesTotal, 2, ',', '.') }}</strong> Total de ventas confirmadas en este reporte: <strong>${{ number_format((float) $confirmedSalesTotal, 2, ',', '.') }}</strong>
@@ -44,7 +44,7 @@
@forelse ($sales as $sale) @forelse ($sales as $sale)
<tr> <tr>
<td>#{{ $sale->id }}</td> <td>#{{ $sale->id }}</td>
<td>{{ $sale->created_at?->format('d/m/Y H:i') ?? '-' }}</td> <td>{{ $sale->created_at?->copy()->timezone($timeZone)->format('d/m/Y H:i') ?? '-' }}</td>
<td>{{ $sale->nombre_apellido ?: 'Sin nombre' }}</td> <td>{{ $sale->nombre_apellido ?: 'Sin nombre' }}</td>
<td class="center">{{ (int) ($sale->quantity ?? 0) }}</td> <td class="center">{{ (int) ($sale->quantity ?? 0) }}</td>
<td>{{ match ($sale->status) { <td>{{ match ($sale->status) {

View File

@@ -73,19 +73,18 @@ class CatalogControllerTest extends TestCase
->assertJsonPath('0.items.0.descripcion', 'Variants description') ->assertJsonPath('0.items.0.descripcion', 'Variants description')
->assertJsonPath('0.items.0.precio', '100.00') ->assertJsonPath('0.items.0.precio', '100.00')
->assertJsonPath('0.items.0.maximum_addable_quantity', 7) ->assertJsonPath('0.items.0.maximum_addable_quantity', 7)
->assertJsonCount(3, '0.items.0.variants') ->assertJsonCount(2, '0.items.0.variants')
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4) ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4)
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3) ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3)
->assertJsonPath('0.items.0.variants.2.id', $unavailableVariant->id)
->assertJsonPath('0.items.0.variants.2.maximum_addable_quantity', 0)
->assertJsonPath(
'0.items.0.variants.2.unavailable_message',
'Este producto no tiene stock disponible.',
)
->assertJsonPath('1.title', 'Row') ->assertJsonPath('1.title', 'Row')
->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8) ->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8)
->assertJsonMissingPath('1.items.data.0.stock_tecnico') ->assertJsonMissingPath('1.items.data.0.stock_tecnico')
->assertJsonCount(0, '1.items.data.0.variants'); ->assertJsonCount(0, '1.items.data.0.variants');
$this->assertNotContains(
$unavailableVariant->id,
collect($response->json('0.items.0.variants'))->pluck('id')->all(),
);
} }
public function test_maximum_addable_quantity_shares_the_authenticated_user_quota_between_variants(): void public function test_maximum_addable_quantity_shares_the_authenticated_user_quota_between_variants(): void
@@ -133,7 +132,7 @@ class CatalogControllerTest extends TestCase
->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico'); ->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico');
} }
public function test_it_includes_out_of_stock_items_with_an_unavailable_message(): void public function test_it_excludes_out_of_stock_items(): void
{ {
$tenant = $this->createTenant('catalog-available-variants'); $tenant = $this->createTenant('catalog-available-variants');
$group = $this->createGroup( $group = $this->createGroup(
@@ -161,15 +160,10 @@ class CatalogControllerTest extends TestCase
$this->getJson("/api/tenants/{$tenant->codigo}/catalog") $this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk() ->assertOk()
->assertJsonCount(2, '0.items') ->assertJsonCount(1, '0.items')
->assertJsonPath('0.items.0.nombre', 'Unavailable') ->assertJsonPath('0.items.0.nombre', 'Available')
->assertJsonPath('0.items.0.maximum_addable_quantity', 0) ->assertJsonPath('0.items.0.unavailable_message', null)
->assertJsonPath( ->assertJsonMissing(['nombre' => 'Unavailable']);
'0.items.0.unavailable_message',
'Este producto no tiene stock disponible.',
)
->assertJsonPath('0.items.1.nombre', 'Available')
->assertJsonPath('0.items.1.unavailable_message', null);
} }
public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void

View File

@@ -48,7 +48,7 @@ class CatalogItemDetailControllerTest extends TestCase
$this->assertStringContainsString($itemImage->path, $response->json('data.images.0')); $this->assertStringContainsString($itemImage->path, $response->json('data.images.0'));
} }
public function test_it_lists_unavailable_variants_and_selects_the_first_available_one(): void public function test_it_omits_unavailable_variants_and_selects_the_first_available_one(): void
{ {
Storage::fake('s3'); Storage::fake('s3');
$tenant = $this->createTenant('detail-default'); $tenant = $this->createTenant('detail-default');
@@ -69,14 +69,8 @@ class CatalogItemDetailControllerTest extends TestCase
$response $response
->assertOk() ->assertOk()
->assertJsonCount(2, 'data.variants') ->assertJsonCount(1, 'data.variants')
->assertJsonPath('data.variants.0.id', $firstVariant->id) ->assertJsonPath('data.variants.0.id', $secondVariant->id)
->assertJsonPath('data.variants.0.maximum_addable_quantity', 0)
->assertJsonPath(
'data.variants.0.unavailable_message',
'Este producto no tiene stock disponible.',
)
->assertJsonPath('data.variants.1.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.id', $secondVariant->id) ->assertJsonPath('data.selected_variant.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6) ->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6)
->assertJsonMissingPath('data.selected_variant.stock_tecnico') ->assertJsonMissingPath('data.selected_variant.stock_tecnico')
@@ -84,6 +78,10 @@ class CatalogItemDetailControllerTest extends TestCase
$response $response
->assertJsonMissingPath('data.stock_tecnico') ->assertJsonMissingPath('data.stock_tecnico')
->assertJsonMissingPath('data.images'); ->assertJsonMissingPath('data.images');
$this->assertNotContains(
$firstVariant->id,
collect($response->json('data.variants'))->pluck('id')->all(),
);
$this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0')); $this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0')); $this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0')); $this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0'));
@@ -93,6 +91,19 @@ class CatalogItemDetailControllerTest extends TestCase
)->assertNotFound(); )->assertNotFound();
} }
public function test_it_does_not_return_an_out_of_stock_item(): void
{
$tenant = $this->createTenant('detail-out-of-stock');
$inventory = Inventory::query()->create([
'real_stock' => 5,
'reserved_stock' => 5,
]);
$item = $this->createItem($tenant, 'Sold out item', $inventory);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
->assertNotFound();
}
public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void
{ {
Storage::fake('s3'); Storage::fake('s3');

View File

@@ -62,6 +62,24 @@ class CatalogSearchTest extends TestCase
$this->createCatalogItem($tenant, "Running {$number}"); $this->createCatalogItem($tenant, "Running {$number}");
} }
$exactMatch = $this->createCatalogItem($tenant, 'Running'); $exactMatch = $this->createCatalogItem($tenant, 'Running');
$outOfStock = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'running-sold-out',
'nombre' => 'Running sold out',
'descripcion' => 'Running sold out description',
'precio' => 100,
]);
$outOfStock->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id,
]);
CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'inventory_id' => Inventory::query()->create(['real_stock' => 2, 'reserved_stock' => 2])->id,
'slug' => 'running-direct-sold-out',
'nombre' => 'Running direct sold out',
'descripcion' => 'Running direct sold out description',
'precio' => 100,
]);
$this->createCatalogItem($tenant, 'Unrelated'); $this->createCatalogItem($tenant, 'Unrelated');
$this->createCatalogItem($otherTenant, 'Running foreign'); $this->createCatalogItem($otherTenant, 'Running foreign');
@@ -76,6 +94,8 @@ class CatalogSearchTest extends TestCase
->assertJsonPath('meta.total', 6) ->assertJsonPath('meta.total', 6)
->assertJsonCount(4, 'data') ->assertJsonCount(4, 'data')
->assertJsonPath('data.0.id', $exactMatch->id) ->assertJsonPath('data.0.id', $exactMatch->id)
->assertJsonMissing(['nombre' => 'Running sold out'])
->assertJsonMissing(['nombre' => 'Running direct sold out'])
->assertJsonMissing(['nombre' => 'Running foreign']) ->assertJsonMissing(['nombre' => 'Running foreign'])
->assertJsonMissing(['nombre' => 'Unrelated']); ->assertJsonMissing(['nombre' => 'Unrelated']);
} }

View File

@@ -29,6 +29,17 @@ class CategoryDetailTest extends TestCase
$this->createCatalogItem($tenant, $category, 'Remera C'); $this->createCatalogItem($tenant, $category, 'Remera C');
$firstItem = $this->createCatalogItem($tenant, $category, 'Remera A'); $firstItem = $this->createCatalogItem($tenant, $category, 'Remera A');
$secondItem = $this->createCatalogItem($tenant, $category, 'Remera B'); $secondItem = $this->createCatalogItem($tenant, $category, 'Remera B');
$outOfStock = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'category_id' => $category->id,
'slug' => 'remera-agotada',
'nombre' => 'Remera agotada',
'descripcion' => 'Sin stock',
'precio' => 100,
]);
$outOfStock->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id,
]);
$this->createCatalogItem($tenant, $otherCategory, 'Pantalón'); $this->createCatalogItem($tenant, $otherCategory, 'Pantalón');
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}") $this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}")
@@ -44,6 +55,7 @@ class CategoryDetailTest extends TestCase
->assertJsonCount(2, 'data') ->assertJsonCount(2, 'data')
->assertJsonPath('data.0.id', $firstItem->id) ->assertJsonPath('data.0.id', $firstItem->id)
->assertJsonPath('data.1.id', $secondItem->id) ->assertJsonPath('data.1.id', $secondItem->id)
->assertJsonMissing(['nombre' => 'Remera agotada'])
->assertJsonMissing(['nombre' => 'Pantalón']); ->assertJsonMissing(['nombre' => 'Pantalón']);
} }

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit\Sale;
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
class AdminAppSalePdfRequestTest extends TestCase
{
public function test_pdf_requests_accept_php_timezone_identifiers_including_browser_aliases(): void
{
$requests = [
new AdminAppSalePdfRequest,
new AdminAppSaleModificationPdfRequest,
];
foreach ($requests as $request) {
$this->assertTrue(Validator::make([
'timezone' => 'America/Argentina/Buenos_Aires',
], $request->rules())->passes());
$this->assertTrue(Validator::make([
'timezone' => 'America/Buenos_Aires',
], $request->rules())->passes());
$this->assertFalse(Validator::make([
'timezone' => 'Invalid/Timezone',
], $request->rules())->passes());
$this->assertFalse(Validator::make([], $request->rules())->passes());
}
}
public function test_invalid_timezone_message_uses_the_application_locale(): void
{
$request = new AdminAppSalePdfRequest;
App::setLocale('es');
$spanishValidator = Validator::make(['timezone' => 'Invalid/Timezone'], $request->rules());
$this->assertSame(
'La zona horaria enviada por el navegador no es válida.',
$spanishValidator->errors()->first('timezone'),
);
App::setLocale('en');
$englishValidator = Validator::make(['timezone' => 'Invalid/Timezone'], $request->rules());
$this->assertSame(
'The timezone sent by the browser is invalid.',
$englishValidator->errors()->first('timezone'),
);
}
}

View File

@@ -8,6 +8,7 @@ use App\Domains\Purchase\Models\Purchase;
use App\Domains\Sale\Services\AdminAppSalePdfService; use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Barryvdh\DomPDF\ServiceProvider; use Barryvdh\DomPDF\ServiceProvider;
use Illuminate\Support\Carbon;
use Tests\TestCase; use Tests\TestCase;
class AdminAppSalePdfServiceTest extends TestCase class AdminAppSalePdfServiceTest extends TestCase
@@ -17,6 +18,14 @@ class AdminAppSalePdfServiceTest extends TestCase
parent::setUp(); parent::setUp();
$this->app->register(ServiceProvider::class); $this->app->register(ServiceProvider::class);
Carbon::setTestNow(Carbon::parse('2026-08-24 17:53:00', 'UTC'));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
} }
public function test_it_downloads_the_sales_report_as_a_pdf(): void public function test_it_downloads_the_sales_report_as_a_pdf(): void
@@ -24,11 +33,12 @@ class AdminAppSalePdfServiceTest extends TestCase
$response = app(AdminAppSalePdfService::class)->downloadSales( $response = app(AdminAppSalePdfService::class)->downloadSales(
$this->tenant(), $this->tenant(),
collect([$this->sale()]), collect([$this->sale()]),
'America/La_Paz',
); );
$this->assertSame('application/pdf', $response->headers->get('content-type')); $this->assertSame('application/pdf', $response->headers->get('content-type'));
$this->assertStringContainsString( $this->assertStringContainsString(
'attachment; filename=ventas_acme_', 'attachment; filename=ventas_acme_20260824_135300.pdf',
(string) $response->headers->get('content-disposition'), (string) $response->headers->get('content-disposition'),
); );
$this->assertStringStartsWith('%PDF', $response->getContent()); $this->assertStringStartsWith('%PDF', $response->getContent());
@@ -57,16 +67,52 @@ class AdminAppSalePdfServiceTest extends TestCase
$response = app(AdminAppSalePdfService::class)->downloadModifications( $response = app(AdminAppSalePdfService::class)->downloadModifications(
$this->tenant(), $this->tenant(),
collect([$modification]), collect([$modification]),
'America/La_Paz',
); );
$this->assertSame('application/pdf', $response->headers->get('content-type')); $this->assertSame('application/pdf', $response->headers->get('content-type'));
$this->assertStringContainsString( $this->assertStringContainsString(
'attachment; filename=historial_modificaciones_acme_', 'attachment; filename=historial_modificaciones_acme_20260824_135300.pdf',
(string) $response->headers->get('content-disposition'), (string) $response->headers->get('content-disposition'),
); );
$this->assertStringStartsWith('%PDF', $response->getContent()); $this->assertStringStartsWith('%PDF', $response->getContent());
} }
public function test_it_renders_pdf_dates_in_the_requested_timezone(): void
{
$sale = $this->sale();
$modification = (new ValueChange)->forceFill([
'id' => 1,
'trackable_id' => $sale->id,
'attribute' => 'status',
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
'new_value' => Purchase::STATUS_PAID,
'changed_at' => now(),
'actor_type' => 'system',
]);
$modification->setRelation('trackable', $sale);
$modification->setRelation('user', null);
$salesHtml = view('pdf.adminapp.sales', [
'tenant' => $this->tenant(),
'sales' => collect([$sale]),
'generatedAt' => now(),
'timeZone' => 'America/La_Paz',
'confirmedSalesTotal' => '25000.00',
])->render();
$modificationsHtml = view('pdf.adminapp.sale-modifications', [
'tenant' => $this->tenant(),
'modifications' => collect([$modification]),
'generatedAt' => now(),
'timeZone' => 'America/La_Paz',
])->render();
$this->assertStringContainsString('Generado el 24/08/2026 13:53', $salesHtml);
$this->assertStringContainsString('24/08/2026 13:53', $salesHtml);
$this->assertStringContainsString('Generado el 24/08/2026 13:53', $modificationsHtml);
$this->assertStringContainsString('13:53:00', $modificationsHtml);
}
private function tenant(): Tenant private function tenant(): Tenant
{ {
return (new Tenant)->forceFill([ return (new Tenant)->forceFill([