feat(F-102): completed feature
This commit is contained in:
@@ -94,6 +94,8 @@ const newProductSchema = z.object({
|
||||
categoryIds: z.array(z.uuid()).max(50).optional(),
|
||||
brandId: z.uuid().optional().nullable(),
|
||||
expirationDate: z.iso.date().optional().nullable(),
|
||||
unitWeightKg: z.number().positive().max(1000).optional(),
|
||||
minPurchaseQty: z.number().int().min(1).max(999).optional(),
|
||||
});
|
||||
|
||||
const productPatchSchema = newProductSchema
|
||||
@@ -727,6 +729,8 @@ function serializeProduct(product: Product, images: ProductImage[] = []) {
|
||||
brandId: product.brandId,
|
||||
brand: product.brand,
|
||||
expirationDate: product.expirationDate,
|
||||
unitWeightKg: product.unitWeightKg,
|
||||
minPurchaseQty: product.minPurchaseQty,
|
||||
createdAt: product.createdAt.toISOString(),
|
||||
updatedAt: product.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface Product {
|
||||
brandId: string | null;
|
||||
brand?: ProductBrandSummary;
|
||||
expirationDate: string | null;
|
||||
/** Peso unitario en kg (el peso del pedido es cantidad × este valor). */
|
||||
unitWeightKg: number;
|
||||
/** Cantidad mínima de compra; el frontend bloquea pedidos por debajo. */
|
||||
minPurchaseQty: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -68,6 +72,8 @@ export interface NewProduct {
|
||||
categoryIds?: string[];
|
||||
brandId?: string | null;
|
||||
expirationDate?: string | null;
|
||||
unitWeightKg?: number;
|
||||
minPurchaseQty?: number;
|
||||
}
|
||||
|
||||
/** Fields a product update may set. Undefined = leave unchanged. */
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface ProductRow {
|
||||
brand_name: string | null;
|
||||
brand_slug: string | null;
|
||||
expiration_date: string | null;
|
||||
unit_weight_kg: string | number;
|
||||
min_purchase_qty: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -55,6 +57,8 @@ const UPDATABLE: ReadonlyArray<[keyof ProductPatch, string]> = [
|
||||
['seoDescription', 'seo_description'],
|
||||
['brandId', 'brand_id'],
|
||||
['expirationDate', 'expiration_date'],
|
||||
['unitWeightKg', 'unit_weight_kg'],
|
||||
['minPurchaseQty', 'min_purchase_qty'],
|
||||
];
|
||||
|
||||
export class PgProductRepository implements ProductRepository {
|
||||
@@ -93,8 +97,8 @@ export class PgProductRepository implements ProductRepository {
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await client.query<ProductRow>(
|
||||
`INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id, expiration_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id, expiration_date, unit_weight_kg, min_purchase_qty)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *, ARRAY[]::uuid[] AS category_ids`,
|
||||
[
|
||||
input.name,
|
||||
@@ -105,6 +109,8 @@ export class PgProductRepository implements ProductRepository {
|
||||
input.seoDescription ?? null,
|
||||
input.brandId ?? null,
|
||||
input.expirationDate ?? null,
|
||||
input.unitWeightKg ?? 1,
|
||||
input.minPurchaseQty ?? 1,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
@@ -306,6 +312,8 @@ export function toProduct(row: ProductRow): Product {
|
||||
: undefined,
|
||||
categoryIds: row.category_ids ?? [],
|
||||
expirationDate: row.expiration_date ?? null,
|
||||
unitWeightKg: Number(row.unit_weight_kg ?? 1),
|
||||
minPurchaseQty: row.min_purchase_qty ?? 1,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>
|
||||
categoryIds: [],
|
||||
brandId: null,
|
||||
expirationDate: null,
|
||||
unitWeightKg: 1,
|
||||
minPurchaseQty: 1,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...input,
|
||||
|
||||
@@ -21,6 +21,8 @@ function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>
|
||||
categoryIds: [],
|
||||
brandId: null,
|
||||
expirationDate: null,
|
||||
unitWeightKg: 1,
|
||||
minPurchaseQty: 1,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...input,
|
||||
|
||||
@@ -57,6 +57,19 @@ export async function registerCheckoutRoutes(
|
||||
orderLookup,
|
||||
metrics,
|
||||
tracer,
|
||||
getCartWeightKg: async (items) => {
|
||||
if (items.length === 0) return 0;
|
||||
const productIds = [...new Set(items.map((item) => item.productId))];
|
||||
const result = await deps.pool.query<{ id: string; unit_weight_kg: string | number }>(
|
||||
`SELECT id, unit_weight_kg FROM catalog_products WHERE id = ANY($1::uuid[])`,
|
||||
[productIds],
|
||||
);
|
||||
const weights = new Map(result.rows.map((row) => [row.id, Number(row.unit_weight_kg ?? 1)]));
|
||||
return items.reduce(
|
||||
(total, item) => total + (weights.get(item.productId) ?? 1) * item.quantity,
|
||||
0,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const checkoutSchema: FastifySchema = {
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface CheckoutServiceDeps {
|
||||
orderLookup: CheckoutOrderLookup;
|
||||
metrics: CheckoutMetrics;
|
||||
tracer?: Tracer;
|
||||
/** Peso total del carrito en kg (cantidad × peso unitario del producto). */
|
||||
getCartWeightKg?: (
|
||||
items: Array<{ productId: string; quantity: number }>,
|
||||
) => Promise<number>;
|
||||
}
|
||||
|
||||
export interface CheckoutCommand {
|
||||
@@ -132,8 +136,18 @@ export class CheckoutService {
|
||||
}
|
||||
}
|
||||
|
||||
const cartWeightKg = this.deps.getCartWeightKg
|
||||
? await this.deps.getCartWeightKg(
|
||||
cart.items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
|
||||
).catch(() => 0)
|
||||
: 0;
|
||||
|
||||
const shipping = await this.deps.shipping
|
||||
.calculate(Math.max(0, netSubtotalCents + taxCents - discountCents), command.address)
|
||||
.calculate(
|
||||
Math.max(0, netSubtotalCents + taxCents - discountCents),
|
||||
command.address,
|
||||
cartWeightKg,
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof Error && error.name === 'ShippingZoneNotFoundError') return null;
|
||||
throw error;
|
||||
|
||||
@@ -29,6 +29,8 @@ const methodBodySchema = z.object({
|
||||
baseCostCents: z.number().int().min(0),
|
||||
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
maxWeightKg: z.number().positive().max(100000).optional().nullable(),
|
||||
freeShippingMaxWeightKg: z.number().positive().max(100000).optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -37,6 +39,7 @@ const calculateBodySchema = z
|
||||
cartTotalCents: z.number().int().min(0),
|
||||
country: z.string().min(2).max(80),
|
||||
postalCode: z.string().min(1).max(20),
|
||||
cartWeightKg: z.number().min(0).max(100000).optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
@@ -75,14 +78,16 @@ export async function registerShippingRoutes(
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(methodBodySchema, request.body);
|
||||
const result = await deps.pool.query<{ id: string }>(
|
||||
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, description, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, description, max_weight_kg, free_shipping_max_weight_kg, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||
[
|
||||
input.zoneId,
|
||||
input.name,
|
||||
input.baseCostCents,
|
||||
input.freeShippingThresholdCents ?? null,
|
||||
input.description ?? null,
|
||||
input.maxWeightKg ?? null,
|
||||
input.freeShippingMaxWeightKg ?? null,
|
||||
input.active ?? true,
|
||||
],
|
||||
);
|
||||
@@ -202,6 +207,8 @@ export async function registerShippingRoutes(
|
||||
base_cost_cents: number;
|
||||
free_shipping_threshold_cents: number | null;
|
||||
description: string | null;
|
||||
max_weight_kg: string | number | null;
|
||||
free_shipping_max_weight_kg: string | number | null;
|
||||
active: boolean;
|
||||
}>(
|
||||
`SELECT sm.*, sz.name as zone_name FROM shipping_methods sm
|
||||
@@ -217,6 +224,9 @@ export async function registerShippingRoutes(
|
||||
baseCostCents: r.base_cost_cents,
|
||||
freeShippingThresholdCents: r.free_shipping_threshold_cents,
|
||||
description: r.description,
|
||||
maxWeightKg: r.max_weight_kg === null ? null : Number(r.max_weight_kg),
|
||||
freeShippingMaxWeightKg:
|
||||
r.free_shipping_max_weight_kg === null ? null : Number(r.free_shipping_max_weight_kg),
|
||||
active: r.active,
|
||||
})),
|
||||
});
|
||||
@@ -227,13 +237,15 @@ export async function registerShippingRoutes(
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(methodBodySchema, request.body);
|
||||
const result = await deps.pool.query<{ id: string }>(
|
||||
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, active)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, max_weight_kg, free_shipping_max_weight_kg, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
[
|
||||
input.zoneId,
|
||||
input.name,
|
||||
input.baseCostCents,
|
||||
input.freeShippingThresholdCents ?? null,
|
||||
input.maxWeightKg ?? null,
|
||||
input.freeShippingMaxWeightKg ?? null,
|
||||
input.active ?? true,
|
||||
],
|
||||
);
|
||||
@@ -264,6 +276,8 @@ export async function registerShippingRoutes(
|
||||
baseCostCents: z.number().int().min(0).optional(),
|
||||
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
maxWeightKg: z.number().positive().max(100000).optional().nullable(),
|
||||
freeShippingMaxWeightKg: z.number().positive().max(100000).optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
}),
|
||||
request.body,
|
||||
@@ -287,6 +301,14 @@ export async function registerShippingRoutes(
|
||||
sets.push(`description = $${i++}`);
|
||||
values.push(patch.description);
|
||||
}
|
||||
if (patch.maxWeightKg !== undefined) {
|
||||
sets.push(`max_weight_kg = $${i++}`);
|
||||
values.push(patch.maxWeightKg);
|
||||
}
|
||||
if (patch.freeShippingMaxWeightKg !== undefined) {
|
||||
sets.push(`free_shipping_max_weight_kg = $${i++}`);
|
||||
values.push(patch.freeShippingMaxWeightKg);
|
||||
}
|
||||
if (patch.active !== undefined) {
|
||||
sets.push(`active = $${i++}`);
|
||||
values.push(patch.active);
|
||||
@@ -342,10 +364,14 @@ export async function registerShippingRoutes(
|
||||
app.post('/shipping/calculate', { schema: calcShippingSchema }, async (request, reply) => {
|
||||
const input = parseJson(calculateBodySchema, request.body);
|
||||
try {
|
||||
const quote = await service.calculate(input.cartTotalCents, {
|
||||
country: input.country,
|
||||
postalCode: input.postalCode,
|
||||
});
|
||||
const quote = await service.calculate(
|
||||
input.cartTotalCents,
|
||||
{
|
||||
country: input.country,
|
||||
postalCode: input.postalCode,
|
||||
},
|
||||
input.cartWeightKg ?? 0,
|
||||
);
|
||||
return reply.send(serializeQuote(quote));
|
||||
} catch (error) {
|
||||
throw mapShippingError(error);
|
||||
@@ -375,9 +401,12 @@ export async function registerShippingRoutes(
|
||||
base_cost_cents: number;
|
||||
free_shipping_threshold_cents: number | null;
|
||||
description: string | null;
|
||||
max_weight_kg: string | number | null;
|
||||
free_shipping_max_weight_kg: string | number | null;
|
||||
}>(
|
||||
`SELECT sm.id, sm.zone_id, sm.name, sm.base_cost_cents,
|
||||
sm.free_shipping_threshold_cents, sm.description
|
||||
sm.free_shipping_threshold_cents, sm.description,
|
||||
sm.max_weight_kg, sm.free_shipping_max_weight_kg
|
||||
FROM shipping_methods sm
|
||||
JOIN shipping_zones sz ON sz.id = sm.zone_id
|
||||
WHERE sm.active = true
|
||||
@@ -395,6 +424,9 @@ export async function registerShippingRoutes(
|
||||
baseCostCents: r.base_cost_cents,
|
||||
freeShippingThresholdCents: r.free_shipping_threshold_cents,
|
||||
description: r.description,
|
||||
maxWeightKg: r.max_weight_kg === null ? null : Number(r.max_weight_kg),
|
||||
freeShippingMaxWeightKg:
|
||||
r.free_shipping_max_weight_kg === null ? null : Number(r.free_shipping_max_weight_kg),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import type { ShippingAddress, ShippingQuote } from '../domain/shipping.js';
|
||||
export class ShippingService implements ShippingServicePort {
|
||||
constructor(private readonly repo: ShippingRepository) {}
|
||||
|
||||
async calculate(cartTotalCents: number, address: ShippingAddress): Promise<ShippingQuote> {
|
||||
async calculate(
|
||||
cartTotalCents: number,
|
||||
address: ShippingAddress,
|
||||
cartWeightKg = 0,
|
||||
): Promise<ShippingQuote> {
|
||||
ensureValidAddress(address);
|
||||
ensureNonNegativeTotal(cartTotalCents);
|
||||
|
||||
@@ -17,10 +21,19 @@ export class ShippingService implements ShippingServicePort {
|
||||
|
||||
const candidates = match.methods
|
||||
.filter((method) => method.active)
|
||||
// Max weight per method: carts above the limit cannot use the method.
|
||||
.filter(
|
||||
(method) => method.maxWeightKg === null || cartWeightKg <= method.maxWeightKg,
|
||||
)
|
||||
.map((method) => {
|
||||
// Free shipping applies by total and only within the free-shipping
|
||||
// weight range configured on the method.
|
||||
const withinFreeWeightRange =
|
||||
method.freeShippingMaxWeightKg === null || cartWeightKg <= method.freeShippingMaxWeightKg;
|
||||
const freeApplied =
|
||||
method.freeShippingThresholdCents !== null &&
|
||||
cartTotalCents >= method.freeShippingThresholdCents;
|
||||
cartTotalCents >= method.freeShippingThresholdCents &&
|
||||
withinFreeWeightRange;
|
||||
return { method, freeApplied, costCents: freeApplied ? 0 : method.baseCostCents };
|
||||
});
|
||||
if (candidates.length === 0) throw new ShippingZoneNotFoundError();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { ShippingAddress, ShippingQuote } from './shipping.js';
|
||||
|
||||
export interface ShippingService {
|
||||
calculate(cartTotalCents: number, address: ShippingAddress): Promise<ShippingQuote>;
|
||||
calculate(
|
||||
cartTotalCents: number,
|
||||
address: ShippingAddress,
|
||||
cartWeightKg?: number,
|
||||
): Promise<ShippingQuote>;
|
||||
}
|
||||
|
||||
export interface ShippingRepository {
|
||||
@@ -16,6 +20,8 @@ export interface ShippingRepository {
|
||||
name: string;
|
||||
baseCostCents: number;
|
||||
freeShippingThresholdCents: number | null;
|
||||
maxWeightKg: number | null;
|
||||
freeShippingMaxWeightKg: number | null;
|
||||
active: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface ShippingMethod {
|
||||
name: string;
|
||||
baseCostCents: number;
|
||||
freeShippingThresholdCents: number | null;
|
||||
/** Peso máximo (kg) que admite el método; null = sin límite. */
|
||||
maxWeightKg: number | null;
|
||||
/** Peso máximo (kg) para aplicar envío gratuito; null = sin límite. */
|
||||
freeShippingMaxWeightKg: number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ interface MethodRow {
|
||||
name: string;
|
||||
base_cost_cents: number;
|
||||
free_shipping_threshold_cents: number | null;
|
||||
max_weight_kg: string | number | null;
|
||||
free_shipping_max_weight_kg: string | number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
@@ -20,6 +22,8 @@ interface ZoneMatch {
|
||||
name: string;
|
||||
baseCostCents: number;
|
||||
freeShippingThresholdCents: number | null;
|
||||
maxWeightKg: number | null;
|
||||
freeShippingMaxWeightKg: number | null;
|
||||
active: boolean;
|
||||
}>;
|
||||
}
|
||||
@@ -40,7 +44,8 @@ export class PgShippingRepository implements ShippingRepository {
|
||||
if (!best) return undefined;
|
||||
|
||||
const methods = await this.pool.query<MethodRow>(
|
||||
`SELECT id, name, base_cost_cents, free_shipping_threshold_cents, active
|
||||
`SELECT id, name, base_cost_cents, free_shipping_threshold_cents,
|
||||
max_weight_kg, free_shipping_max_weight_kg, active
|
||||
FROM shipping_methods WHERE zone_id = $1`,
|
||||
[best.id],
|
||||
);
|
||||
@@ -51,6 +56,9 @@ export class PgShippingRepository implements ShippingRepository {
|
||||
name: row.name,
|
||||
baseCostCents: row.base_cost_cents,
|
||||
freeShippingThresholdCents: row.free_shipping_threshold_cents,
|
||||
maxWeightKg: row.max_weight_kg === null ? null : Number(row.max_weight_kg),
|
||||
freeShippingMaxWeightKg:
|
||||
row.free_shipping_max_weight_kg === null ? null : Number(row.free_shipping_max_weight_kg),
|
||||
active: row.active,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -11,10 +11,22 @@ function repo(
|
||||
baseCostCents: number;
|
||||
freeShippingThresholdCents: number | null;
|
||||
active: boolean;
|
||||
maxWeightKg?: number | null;
|
||||
freeShippingMaxWeightKg?: number | null;
|
||||
}>,
|
||||
): ShippingRepository {
|
||||
return {
|
||||
findMatchingZone: async () => (zoneId ? { zoneId, methods } : undefined),
|
||||
findMatchingZone: async () =>
|
||||
zoneId
|
||||
? {
|
||||
zoneId,
|
||||
methods: methods.map((method) => ({
|
||||
maxWeightKg: null,
|
||||
freeShippingMaxWeightKg: null,
|
||||
...method,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,4 +91,48 @@ describe('ShippingService', () => {
|
||||
service.calculate(-1, { country: 'ES', postalCode: '28001' }),
|
||||
).rejects.toBeInstanceOf(InvalidShippingAddressError);
|
||||
});
|
||||
|
||||
it('excludes methods whose max weight is below the cart weight (F-102)', async () => {
|
||||
const service = new ShippingService(
|
||||
repo('zone-1', [
|
||||
{
|
||||
id: 'm-light',
|
||||
name: 'Ligero',
|
||||
baseCostCents: 300,
|
||||
freeShippingThresholdCents: null,
|
||||
active: true,
|
||||
maxWeightKg: 2,
|
||||
},
|
||||
{
|
||||
id: 'm-heavy',
|
||||
name: 'Pesado',
|
||||
baseCostCents: 900,
|
||||
freeShippingThresholdCents: null,
|
||||
active: true,
|
||||
maxWeightKg: 30,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const quote = await service.calculate(1000, { country: 'ES', postalCode: '28001' }, 5);
|
||||
expect(quote).toMatchObject({ methodId: 'm-heavy', costCents: 900 });
|
||||
});
|
||||
|
||||
it('denies free shipping when cart weight exceeds the free-shipping range (F-102)', async () => {
|
||||
const service = new ShippingService(
|
||||
repo('zone-1', [
|
||||
{
|
||||
id: 'm-1',
|
||||
name: 'Standard',
|
||||
baseCostCents: 500,
|
||||
freeShippingThresholdCents: 3000,
|
||||
active: true,
|
||||
freeShippingMaxWeightKg: 4,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const free = await service.calculate(5000, { country: 'ES', postalCode: '28001' }, 3);
|
||||
expect(free).toMatchObject({ costCents: 0, freeApplied: true });
|
||||
const paid = await service.calculate(5000, { country: 'ES', postalCode: '28001' }, 6);
|
||||
expect(paid).toMatchObject({ costCents: 500, freeApplied: false });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user