feat(F-106): completed feature
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user