feat(F-193): completed feature

This commit is contained in:
chattie
2026-08-23 07:58:50 +02:00
parent a372e5abfb
commit 4b3a506166
18 changed files with 244 additions and 37 deletions

View File

@@ -151,6 +151,19 @@ export default async function ProductPage({ params }: Props) {
<div className="mt-2 text-sm text-gray-500">
{formatPrice(netCents)} sin IVA ({vatPercent}%)
</div>
{/* F-193: expiry and weight */}
{(product as { expirationDate?: string }).expirationDate && (
<div className="mt-1 text-sm text-gray-400">
Caduca: {new Date((product as { expirationDate: string }).expirationDate).toLocaleDateString('es-ES')}
</div>
)}
{primaryVariant?.weightGrams && (
<div className="mt-1 text-sm text-gray-400">
Peso: {primaryVariant.weightGrams >= 1000
? `${(primaryVariant.weightGrams / 1000).toFixed(2)} kg`
: `${primaryVariant.weightGrams} g`}
</div>
)}
</div>
) : (
<div className="mt-6 bg-gray-50 rounded-xl p-6">

View File

@@ -88,6 +88,7 @@ export interface ProductVariant {
sku: string;
ean: string | null;
attributes: Record<string, unknown>;
weightGrams: number | null; // F-193
}
export interface VariantPrice {

View File

@@ -39,6 +39,7 @@ export interface Product {
unitWeightKg?: number;
minPurchaseQty?: number;
aiAssisted?: boolean;
expirationDate?: string; // F-193
}
export interface Category {

View File

@@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/naming-convention */
'use strict';
/**
* F-193: Adds `weight_grams` to catalog_product_variants.
* Authoritative per-variant shipping weight in grams.
* Defaults to NULL (fallback to product-level unit_weight_kg in checkout).
* Note: expiration_date already exists on catalog_products (migration 038).
*/
exports.up = function (db) {
return db.addColumn('catalog_product_variants', 'weight_grams', {
type: 'integer',
notNull: false,
default: null,
check: 'weight_grams IS NULL OR weight_grams > 0',
}, 'ean');
};
exports.down = function (db) {
return db.removeColumn('catalog_product_variants', 'weight_grams');
};
exports._meta = {
version: 57,
};

View File

@@ -111,6 +111,7 @@ const newVariantSchema = z.object({
sku: z.string().min(1).max(100),
ean: z.string().min(1).max(32).optional().nullable(),
attributes: jsonRecordSchema.optional(),
weightGrams: z.number().int().min(1).optional().nullable(), // F-193
});
const variantPatchSchema = newVariantSchema
@@ -778,6 +779,7 @@ function serializeVariant(variant: ProductVariant) {
sku: variant.sku,
ean: variant.ean,
attributes: variant.attributes,
weightGrams: variant.weightGrams, // F-193
createdAt: variant.createdAt.toISOString(),
updatedAt: variant.updatedAt.toISOString(),
};

View File

@@ -14,6 +14,7 @@ export interface ProductVariant {
sku: string;
ean: string | null;
attributes: JsonRecord;
weightGrams: number | null; // F-193: authoritative per-variant shipping weight
createdAt: Date;
updatedAt: Date;
}
@@ -22,9 +23,10 @@ export interface NewProductVariant {
sku: string;
ean?: string | null;
attributes?: JsonRecord;
weightGrams?: number | null; // F-193
}
export type ProductVariantPatch = Partial<NewProductVariant>;
export type ProductVariantPatch = Partial<NewProductVariant & { weightGrams?: number | null }>;
export interface ProductRichData {
productId: string;

View File

@@ -12,6 +12,7 @@ interface VariantRow {
sku: string;
ean: string | null;
attributes: Record<string, unknown>;
weight_grams: number | null; // F-193
created_at: Date;
updated_at: Date;
}
@@ -22,6 +23,7 @@ const UPDATABLE: ReadonlyArray<[keyof ProductVariantPatch, string]> = [
['sku', 'sku'],
['ean', 'ean'],
['attributes', 'attributes'],
['weightGrams', 'weight_grams'], // F-193
];
export class PgProductVariantRepository implements ProductVariantRepository {
@@ -38,10 +40,10 @@ export class PgProductVariantRepository implements ProductVariantRepository {
async create(productId: string, input: NewProductVariant): Promise<ProductVariant> {
try {
const result = await this.pool.query<VariantRow>(
`INSERT INTO catalog_product_variants (product_id, sku, ean, attributes)
VALUES ($1, $2, $3, $4)
`INSERT INTO catalog_product_variants (product_id, sku, ean, attributes, weight_grams)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[productId, input.sku, input.ean ?? null, input.attributes ?? {}],
[productId, input.sku, input.ean ?? null, input.attributes ?? {}, input.weightGrams ?? null],
);
const row = result.rows[0];
if (!row) {
@@ -111,6 +113,7 @@ function toVariant(row: VariantRow): ProductVariant {
sku: row.sku,
ean: row.ean,
attributes: row.attributes,
weightGrams: row.weight_grams,
createdAt: row.created_at,
updatedAt: row.updated_at,
};

View File

@@ -15,6 +15,7 @@ function makeVariant(productId: string, sku: string): ProductVariant {
sku,
ean: null,
attributes: {},
weightGrams: null, // F-193
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
};

View File

@@ -57,18 +57,37 @@ export async function registerCheckoutRoutes(
orderLookup,
metrics,
tracer,
// F-193: prefer authoritative variant-level weight_grams over product-level unit_weight_kg
getCartWeightKg: async (items) => {
if (items.length === 0) return 0;
const variantIds = items.map((i) => i.variantId).filter(Boolean);
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],
// Fetch variant-level weight (grams -> kg conversion) and product-level fallback (kg)
const [variantWeightRows, productWeightRows] = await Promise.all([
variantIds.length > 0
? deps.pool.query<{ id: string; weight_grams: number | null }>(
`SELECT id, weight_grams FROM catalog_product_variants WHERE id = ANY($1::uuid[])`,
[variantIds],
)
: Promise.resolve({ rows: [] }),
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 variantWeights = new Map(
variantWeightRows.rows.map((r) => [r.id, r.weight_grams] as [string, number | null]),
);
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 productWeights = new Map(
productWeightRows.rows.map((r) => [r.id, Number(r.unit_weight_kg ?? 1)] as [string, number]),
);
return items.reduce((total, item) => {
const variantWeightG = item.variantId ? variantWeights.get(item.variantId) : null;
const kg = variantWeightG !== null && variantWeightG !== undefined
? variantWeightG / 1000
: productWeights.get(item.productId) ?? 1;
return total + kg * item.quantity;
}, 0);
},
});

View File

@@ -29,7 +29,7 @@ export interface CheckoutServiceDeps {
tracer?: Tracer;
/** Peso total del carrito en kg (cantidad × peso unitario del producto). */
getCartWeightKg?: (
items: Array<{ productId: string; quantity: number }>,
items: Array<{ productId: string; variantId: string; quantity: number }>,
) => Promise<number>;
}
@@ -138,9 +138,7 @@ export class CheckoutService {
}
const cartWeightKg = this.deps.getCartWeightKg
? await this.deps.getCartWeightKg(
cart.items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
).catch(() => 0)
? await this.deps.getCartWeightKg(cart.items).catch(() => 0)
: 0;
const shipping = await this.deps.shipping