feat(F-106): completed feature

This commit is contained in:
chattie
2026-08-21 09:27:55 +02:00
parent 5458789634
commit dca7c3214f
21 changed files with 958 additions and 73 deletions

View File

@@ -12,6 +12,7 @@ import { OrderNotFoundError, OrderStateTransitionError } from '../domain/errors.
import type { OrderState } from '../domain/order.js';
import { PgOrderRepository } from '../infrastructure/pg-order-repository.js';
import { NoOpOrderEventPublisher } from '../infrastructure/no-op-event-publisher.js';
import { sendOrderStatusEmail } from '../infrastructure/order-status-mailer.js';
export interface OrdersRoutesDeps {
pool: pg.Pool;
@@ -225,6 +226,7 @@ export async function registerOrdersRoutes(
'PARTIALLY_REFUNDED',
],
},
trackingNumber: { type: 'string', maxLength: 120, description: 'Número de seguimiento del envío (obligatorio al marcar como Enviado)' },
},
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
@@ -236,15 +238,157 @@ export async function registerOrdersRoutes(
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
const { state } = parseJson(transitionSchema, request.body);
const { state, trackingNumber } = parseJson(
transitionSchema.extend({ trackingNumber: z.string().min(1).max(120).optional() }),
request.body,
);
if (state === 'SHIPPED' && !trackingNumber?.trim()) {
throw new AppError(422, 'TRACKING_NUMBER_REQUIRED', 'Indica el número de seguimiento para marcar el pedido como enviado');
}
let order;
try {
const order = await service.transitionAdmin(id, state as OrderState);
return reply.send(serializeOrder(order));
order = await service.transitionAdmin(id, state as OrderState, trackingNumber?.trim() || undefined);
} catch (error) {
throw mapOrderError(error);
}
// Notificar al cliente el cambio de estado (sin bloquear la transición).
let notified = false;
let notificationError = '';
try {
const emailRow = await deps.pool.query<{ email: string }>(
'SELECT email FROM identity_users WHERE id = $1',
[order.userId],
);
const to = emailRow.rows[0]?.email;
if (to) {
await sendOrderStatusEmail(deps.pool, {
to,
orderId: order.id,
state: order.state,
trackingNumber: order.trackingNumber,
});
notified = true;
} else {
notificationError = 'El cliente no tiene email asociado';
}
} catch (error) {
notificationError = error instanceof Error ? error.message : 'No se pudo enviar el email';
request.log.warn({ err: error, orderId: order.id }, 'order_status_email_failed');
}
return reply.send({ ...serializeOrder(order), notified, notificationError: notificationError || undefined });
},
);
const editItemsSchema: FastifySchema = {
tags: ['Orders'],
summary: 'Edit order items (admin)',
description:
'Reemplaza los artículos del pedido (agregar/quitar productos y ajustar cantidades). Los totales se recalculan con los precios e IVA actuales.',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: { type: 'object' },
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema, 422: errorSchema },
};
app.put('/orders/:id/items', { schema: editItemsSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
const body = parseJson(
z.object({
items: z
.array(
z.object({
variantId: z.uuid(),
quantity: z.number().int().positive().max(999),
}),
)
.min(1),
}),
request.body,
);
const existing = await service.getOrderAdmin(id);
if (!existing) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
// Consolidar cantidades por variante.
const byVariant = new Map<string, number>();
for (const item of body.items) {
byVariant.set(item.variantId, (byVariant.get(item.variantId) ?? 0) + item.quantity);
}
const variantIds = [...byVariant.keys()];
// Resolver datos de catálogo y precio vigente de cada variante.
const variantResult = await deps.pool.query<{
variant_id: string;
sku: string;
ean: string | null;
product_id: string;
product_name: string;
net_unit_amount_cents: number | null;
vat_rate: string | null;
}>(
`SELECT v.id AS variant_id, v.sku, v.ean, v.product_id, p.name AS product_name,
pr.net_unit_amount_cents, pr.vat_rate
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN pricing_variant_prices pr ON pr.variant_id = v.id
WHERE v.id = ANY($1::uuid[])`,
[variantIds],
);
const found = new Map(variantResult.rows.map((row) => [row.variant_id, row]));
for (const variantId of variantIds) {
if (!found.has(variantId)) {
throw new AppError(422, 'VARIANT_NOT_FOUND', `La variante ${variantId.slice(0, 8)} no existe en el catálogo`);
}
const row = found.get(variantId);
if (row?.net_unit_amount_cents == null) {
throw new AppError(422, 'PRICE_MISSING', `Falta el precio de la variante ${row?.sku ?? variantId.slice(0, 8)}`);
}
}
const VAT_POINTS: Record<string, number> = { general: 2100, reduced: 1000, 'super-reduced': 400 };
const itemInputs = variantIds.map((variantId) => {
const row = found.get(variantId)!;
const quantity = byVariant.get(variantId)!;
const unitPriceCents = row.net_unit_amount_cents!;
const points = VAT_POINTS[row.vat_rate ?? 'general'] ?? 2100;
const taxCents = Math.round((unitPriceCents * quantity * points) / 10_000);
return {
productId: row.product_id,
variantId,
sku: row.sku,
ean: row.ean,
name: row.product_name,
unitPriceCents,
discountCents: 0,
taxCents,
quantity,
};
});
const netSubtotalCents = itemInputs.reduce((acc, item) => acc + item.unitPriceCents * item.quantity, 0);
const taxCents = itemInputs.reduce((acc, item) => acc + item.taxCents, 0);
const subtotalCents = netSubtotalCents + taxCents;
const discountCents = Math.min(existing.discountCents, subtotalCents);
// El coste de envío original se conserva: total - (subtotal - descuento).
const shippingCents = Math.max(0, existing.totalCents - Math.max(0, existing.subtotalCents - existing.discountCents));
const totalCents = Math.max(0, subtotalCents - discountCents) + shippingCents;
try {
const updated = await service.editItems(id, itemInputs, {
subtotalCents,
discountCents,
taxCents,
totalCents,
});
return reply.send(serializeOrder(updated));
} catch (error) {
throw mapOrderError(error);
}
});
}
function mapOrderError(error: unknown): Error {
@@ -263,6 +407,7 @@ function serializeOrder(order: {
discountCents: number;
taxCents: number;
totalCents: number;
trackingNumber?: string | null;
createdAt: Date;
updatedAt: Date;
idempotencyKey: string | null;
@@ -290,6 +435,7 @@ function serializeOrder(order: {
discountCents: order.discountCents,
taxCents: order.taxCents,
totalCents: order.totalCents,
trackingNumber: order.trackingNumber ?? null,
idempotencyKey: order.idempotencyKey,
items: order.items.map((item) => ({
id: item.id,