feat(catalog): replace stock_tecnico with maximum_addable_quantity across components and services

This commit is contained in:
2026-08-19 17:00:03 -03:00
parent cc446433f6
commit 5c6a03b10d
15 changed files with 231 additions and 74 deletions

View File

@@ -232,7 +232,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1001,
precio: 250000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
@@ -243,7 +243,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1002,
precio: 250000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
@@ -254,7 +254,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1003,
precio: 250000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('c', 'Sector C'),
@@ -265,7 +265,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1004,
precio: 200000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
@@ -276,7 +276,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1005,
precio: 200000,
stock_tecnico: 0,
maximum_addable_quantity: 0,
values: {
tipo: ticketOption('vip_lunch', 'VIP + Lunch'),
sector: ticketOption('a', 'Sector A'),
@@ -287,7 +287,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1006,
precio: 100000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('b', 'Sector B'),
@@ -298,7 +298,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1007,
precio: 100000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('b', 'Sector B'),
@@ -309,7 +309,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1008,
precio: 90000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
@@ -320,7 +320,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1009,
precio: 65000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),
@@ -331,7 +331,7 @@ export class ReutilizablesTestPageComponent {
{
id: 1010,
precio: 40000,
stock_tecnico: 1,
maximum_addable_quantity: 1,
values: {
tipo: ticketOption('general', 'General'),
sector: ticketOption('d', 'Sector D'),

View File

@@ -31,7 +31,7 @@ describe('ProductAttributeSelectorComponent', () => {
fixture.componentRef.setInput('variants', [
{
id: 1,
stock_tecnico: null,
maximum_addable_quantity: null,
values: { size: 'S' },
},
]);
@@ -51,12 +51,12 @@ describe('ProductAttributeSelectorComponent', () => {
fixture.componentRef.setInput('variants', [
{
id: 1,
stock_tecnico: 0,
maximum_addable_quantity: 0,
values: { size: 'S' },
},
{
id: 2,
stock_tecnico: 2,
maximum_addable_quantity: 2,
values: { size: 'M' },
},
]);
@@ -92,9 +92,9 @@ describe('ProductAttributeSelectorComponent', () => {
]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{ id: 1, stock_tecnico: null, values: { event_date: '1' } },
{ id: 2, stock_tecnico: null, values: { event_date: '2' } },
{ id: 3, stock_tecnico: null, values: { event_date: ['1', '2'] } },
{ id: 1, maximum_addable_quantity: null, values: { event_date: '1' } },
{ id: 2, maximum_addable_quantity: null, values: { event_date: '2' } },
{ id: 3, maximum_addable_quantity: null, values: { event_date: ['1', '2'] } },
]);
fixture.detectChanges();
@@ -135,8 +135,8 @@ describe('ProductAttributeSelectorComponent', () => {
]);
fixture.componentRef.setInput('inventoryPolicy', 'unlimited');
fixture.componentRef.setInput('variants', [
{ id: 1, stock_tecnico: null, values: { size: 'S', internal_type: 'adult' } },
{ id: 2, stock_tecnico: null, values: { size: 'M', internal_type: 'child' } },
{ id: 1, maximum_addable_quantity: null, values: { size: 'S', internal_type: 'adult' } },
{ id: 2, maximum_addable_quantity: null, values: { size: 'M', internal_type: 'child' } },
]);
fixture.detectChanges();

View File

@@ -227,7 +227,7 @@ export class ProductAttributeSelectorComponent {
}
private isVariantAvailable(variant: CatalogItemVariant): boolean {
return this.inventoryPolicy() === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
return variant.maximum_addable_quantity !== 0;
}
private findFirstHexValue(value: unknown): string | null {

View File

@@ -1,5 +1,6 @@
import { TestBed, getTestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { By } from '@angular/platform-browser';
@@ -36,7 +37,7 @@ describe('ProductDetailPageComponent', () => {
has_tickets: false,
minimum_use_date: null,
maximum_use_date: null,
stock_tecnico: 10,
maximum_addable_quantity: 10,
attributes: [],
variants: [],
};
@@ -190,10 +191,10 @@ describe('ProductDetailPageComponent', () => {
const detailProduct: CatalogItemDetail = {
...mockProduct,
images: ['https://example.com/product.png'],
variants: [{ id: 123, stock_tecnico: 10, values: {} }],
variants: [{ id: 123, maximum_addable_quantity: 10, values: {} }],
selected_variant: {
id: 123,
stock_tecnico: 10,
maximum_addable_quantity: 10,
images: ['https://example.com/variant1.png', 'https://example.com/variant2.png'],
values: {},
},
@@ -294,10 +295,12 @@ describe('ProductDetailPageComponent', () => {
],
},
],
variants: [{ id: 123, stock_tecnico: 10, values: { color: 'beige', material: 'Cuero' } }],
variants: [
{ id: 123, maximum_addable_quantity: 10, values: { color: 'beige', material: 'Cuero' } },
],
selected_variant: {
id: 123,
stock_tecnico: 10,
maximum_addable_quantity: 10,
images: ['https://example.com/variant1.png'],
values: {
color: 'beige',
@@ -334,8 +337,8 @@ describe('ProductDetailPageComponent', () => {
purpose: 'entry',
has_tickets: true,
variants: [
{ id: 101, event_date_id: 20, stock_tecnico: 10, values: { event_date: '20' } },
{ id: 102, event_date_id: 21, stock_tecnico: 10, values: { event_date: '21' } },
{ id: 101, event_date_id: 20, maximum_addable_quantity: 10, values: { event_date: '20' } },
{ id: 102, event_date_id: 21, maximum_addable_quantity: 10, values: { event_date: '21' } },
],
attributes: [
{
@@ -366,7 +369,7 @@ describe('ProductDetailPageComponent', () => {
selected_variant: {
id: 101,
event_date_id: 20,
stock_tecnico: 10,
maximum_addable_quantity: 10,
images: [],
values: {},
},
@@ -410,7 +413,7 @@ describe('ProductDetailPageComponent', () => {
fixture.componentInstance['selectedVariant'].set({
id: 1,
stock_tecnico: 10,
maximum_addable_quantity: 10,
values: {},
});
fixture.detectChanges();
@@ -493,19 +496,45 @@ describe('ProductDetailPageComponent', () => {
});
});
it('shows the backend purchase-limit message for a direct checkout', async () => {
checkoutServiceStub.startCheckout.mockRejectedValue(
new HttpErrorResponse({
status: 422,
error: {
code: 'purchase.limit_exceeded',
message: 'Podés agregar hasta 2 unidades más de “Auriculares Bluetooth”.',
maximum_addable_quantity: 2,
},
}),
);
await configureTestingModule();
const fixture = TestBed.createComponent(ProductDetailPageComponent);
fixture.detectChanges();
const buyButton = Array.from(fixture.nativeElement.querySelectorAll('app-button button')).find(
(button) => button.textContent?.trim() === 'Comprar',
) as HTMLButtonElement;
buyButton.click();
await Promise.resolve();
expect(toastServiceStub.danger).toHaveBeenCalledWith(
'Podés agregar hasta 2 unidades más de “Auriculares Bluetooth”.',
);
});
it('calls CartService.addItem when variant is selected and Add to Cart is clicked', async () => {
const detailProduct: CatalogItemDetail = {
...mockProduct,
variants: [
{
id: 123,
stock_tecnico: 5,
maximum_addable_quantity: 5,
values: {},
},
],
selected_variant: {
id: 123,
stock_tecnico: 5,
maximum_addable_quantity: 5,
images: [],
values: {},
},
@@ -538,7 +567,7 @@ describe('ProductDetailPageComponent', () => {
resolveProduct({
...mockProduct,
variants: [],
stock_tecnico: 4,
maximum_addable_quantity: 4,
});
await configureTestingModule();
@@ -564,13 +593,13 @@ describe('ProductDetailPageComponent', () => {
variants: [
{
id: 123,
stock_tecnico: 5,
maximum_addable_quantity: 5,
values: {},
},
],
selected_variant: {
id: 123,
stock_tecnico: 5,
maximum_addable_quantity: 5,
images: [],
values: {},
},
@@ -602,7 +631,7 @@ describe('ProductDetailPageComponent', () => {
it('allows unlimited variants to increase quantity without a maximum', async () => {
const unlimitedVariant = {
id: 321,
stock_tecnico: null,
maximum_addable_quantity: null,
values: {},
};
resolveProduct({
@@ -610,7 +639,7 @@ describe('ProductDetailPageComponent', () => {
inventory_policy: 'unlimited',
selected_variant: {
id: 321,
stock_tecnico: null,
maximum_addable_quantity: null,
images: [],
values: {},
},
@@ -634,7 +663,7 @@ describe('ProductDetailPageComponent', () => {
it('caps an unlimited variant at the per-user purchase limit', async () => {
const unlimitedVariant = {
id: 322,
stock_tecnico: null,
maximum_addable_quantity: 2,
values: {},
};
resolveProduct({
@@ -643,7 +672,7 @@ describe('ProductDetailPageComponent', () => {
max_units_per_user: 2,
selected_variant: {
id: 322,
stock_tecnico: null,
maximum_addable_quantity: 2,
images: [],
values: { event_date: '20' },
},
@@ -667,14 +696,14 @@ describe('ProductDetailPageComponent', () => {
it('disables purchase actions for tracked variants without stock', async () => {
const trackedVariant = {
id: 654,
stock_tecnico: 0,
maximum_addable_quantity: 0,
values: {},
};
resolveProduct({
...mockProduct,
selected_variant: {
id: 654,
stock_tecnico: 0,
maximum_addable_quantity: 0,
images: [],
values: {},
},

View File

@@ -98,27 +98,23 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
const variant = this.selectedVariant();
if (!prod) return 0;
const stockLimit = variant
? variant.stock_tecnico
return variant
? (variant.maximum_addable_quantity ?? null)
: prod.variants.length === 0
? (prod.stock_tecnico ?? null)
? (prod.maximum_addable_quantity ?? null)
: 0;
const userLimit = prod.max_units_per_user ?? null;
if (stockLimit === null) return userLimit;
if (userLimit === null) return stockLimit;
return Math.min(stockLimit, userLimit);
});
protected readonly selectedVariantAvailable = computed(() => {
const prod = this.product();
if (!prod) return false;
if (this.selectedVariantMax() === 0) return false;
const variant = this.selectedVariant();
if (variant) return this.isVariantAvailable(variant, prod);
if (variant) return this.isVariantAvailable(variant);
if (prod.purpose === 'entry') return false;
if (prod.variants.length > 0) return false;
return prod.inventory_policy === 'unlimited' || (prod.stock_tecnico ?? 0) > 0;
return this.selectedVariantMax() !== 0;
});
protected readonly descriptionExpanded = signal(false);
protected readonly descriptionMaxHeight = signal(0);
@@ -333,14 +329,18 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
});
} catch (error) {
console.error('Failed to create direct purchase:', error);
this.toastService.danger('No se pudo iniciar la compra directa.');
const message =
error instanceof HttpErrorResponse && typeof error.error?.message === 'string'
? error.error.message
: 'No se pudo iniciar la compra directa.';
this.toastService.danger(message);
} finally {
this.creatingDirectPurchase.set(false);
}
}
private isVariantAvailable(variant: CatalogItemVariant, product: CatalogItemDetail): boolean {
return product.inventory_policy === 'unlimited' || (variant.stock_tecnico ?? 0) > 0;
private isVariantAvailable(variant: CatalogItemVariant): boolean {
return variant.maximum_addable_quantity !== 0;
}
protected toggleDescription(): void {

View File

@@ -28,7 +28,7 @@ describe('productDetailResolver', () => {
has_tickets: false,
minimum_use_date: null,
maximum_use_date: null,
stock_tecnico: 0,
maximum_addable_quantity: 0,
attributes: [],
variants: [],
};

View File

@@ -224,6 +224,11 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
return;
}
if (error instanceof HttpErrorResponse && typeof error.error?.message === 'string') {
this.toastService.danger(error.error.message);
return;
}
this.toastService.danger('No se pudo iniciar la compra directa.');
} finally {
this.creatingDirectPurchase.set(false);