semillero-special-hotel/src/app/api/sales/import/route.ts

427 lines
12 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
import * as XLSX from 'xlsx';
export const POST = withAuth(async (req, { session, prisma }) => {
try {
const idempotencyKey = req.headers.get('idempotency-key');
if (!idempotencyKey) {
return NextResponse.json({
success: false,
error: {
code: 'MISSING_IDEMPOTENCY_KEY',
metadata: {}
}
}, { status: 400 });
}
// 1. Check idempotency / job status
const existingJob = await prisma.salesImportJob.findUnique({
where: { idempotencyKey }
});
if (existingJob) {
if (existingJob.status === 'SUCCESS') {
const existingSales = await prisma.salesResult.findMany({
where: {
idempotencyKey: {
startsWith: `${idempotencyKey}-`
}
}
});
const totalAmount = existingSales.reduce((acc: number, cur: any) => acc + Number(cur.amount), 0);
return NextResponse.json({
success: true,
code: 'IMPORT_ALREADY_PROCESSED',
metadata: {
count: existingSales.length,
totalAmount,
idempotencyKey
}
});
}
if (existingJob.status === 'PROCESSING') {
return NextResponse.json({
success: true,
code: 'IMPORT_ACCEPTED',
metadata: {
idempotencyKey,
status: 'PROCESSING'
}
}, { status: 202 });
}
// If FAILED, allow retrying by deleting the old failed job
if (existingJob.status === 'FAILED') {
await prisma.salesImportJob.delete({
where: { idempotencyKey }
});
}
}
// 2. Parse file
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({
success: false,
error: {
code: 'FILE_REQUIRED',
metadata: {}
}
}, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const workbook = XLSX.read(buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const rawRows = XLSX.utils.sheet_to_json(sheet) as any[];
if (!rawRows || rawRows.length === 0) {
return NextResponse.json({
success: false,
error: {
code: 'EMPTY_FILE',
metadata: {}
}
}, { status: 400 });
}
// 3. Collect rows and validate
const rows: {
username: string;
period: string;
hotelCode: string;
amount: number;
salesCount: number;
transactionId?: string;
rowIndex: number;
}[] = [];
const errors: {
row: number;
column: string;
value: any;
code: string;
metadata: any;
}[] = [];
for (let i = 0; i < rawRows.length; i++) {
const row = rawRows[i];
const rowIndex = i + 2;
const username = String(row['Colaborador'] || '').trim();
const period = String(row['Periodo'] || '').trim();
const hotelCode = String(row['Hotel'] || '').trim();
const amountVal = row['Monto'];
const salesCountVal = row['Cantidad'];
const transactionId = row['Id_Transaccion'] ? String(row['Id_Transaccion']).trim() : undefined;
const amount = typeof amountVal === 'number' ? amountVal : parseFloat(String(amountVal || ''));
const salesCount = typeof salesCountVal === 'number' ? salesCountVal : parseInt(String(salesCountVal || ''));
rows.push({
username,
period,
hotelCode,
amount,
salesCount,
transactionId,
rowIndex
});
// Basic validations
if (!username) {
errors.push({
row: rowIndex,
column: 'Colaborador',
value: '',
code: 'USER_REQUIRED',
metadata: {}
});
}
if (!period || !/^\d{4}-\d{2}$/.test(period)) {
errors.push({
row: rowIndex,
column: 'Periodo',
value: period,
code: 'INVALID_PERIOD_FORMAT',
metadata: { expected: 'YYYY-MM' }
});
}
if (!hotelCode) {
errors.push({
row: rowIndex,
column: 'Hotel',
value: '',
code: 'HOTEL_REQUIRED',
metadata: {}
});
}
if (isNaN(amount) || amount <= 0) {
errors.push({
row: rowIndex,
column: 'Monto',
value: amountVal,
code: 'INVALID_AMOUNT',
metadata: { value: amountVal }
});
}
if (isNaN(salesCount) || salesCount <= 0) {
errors.push({
row: rowIndex,
column: 'Cantidad',
value: salesCountVal,
code: 'INVALID_COUNT',
metadata: { value: salesCountVal }
});
}
}
// Resolve entities and check exists/RBAC
const uniqueUsernames = Array.from(new Set(rows.map(r => r.username).filter(Boolean)));
const uniqueHotelCodes = Array.from(new Set(rows.map(r => r.hotelCode).filter(Boolean)));
const dbUsers = await prisma.user.findMany({
where: { username: { in: uniqueUsernames } }
});
const dbHotels = await prisma.hotel.findMany({
where: { code: { in: uniqueHotelCodes } }
});
const userMap = new Map<string, any>(dbUsers.map((u: any) => [u.username, u]));
const hotelMap = new Map<string, any>(dbHotels.map((h: any) => [h.code, h]));
// Find active user for region verification
const activeUser = await prisma.user.findUnique({
where: { id: session.userId },
include: { hotel: true }
});
const activeRegionId = activeUser?.hotel?.regionId;
for (const r of rows) {
if (r.username && !userMap.has(r.username)) {
errors.push({
row: r.rowIndex,
column: 'Colaborador',
value: r.username,
code: 'USER_NOT_FOUND',
metadata: { username: r.username }
});
}
const hotel = hotelMap.get(r.hotelCode);
if (r.hotelCode && !hotel) {
errors.push({
row: r.rowIndex,
column: 'Hotel',
value: r.hotelCode,
code: 'HOTEL_NOT_FOUND',
metadata: { hotelCode: r.hotelCode }
});
}
// Enforce Leader region isolation
if (session.role === 'commercial_leader' && hotel && hotel.regionId !== activeRegionId) {
errors.push({
row: r.rowIndex,
column: 'Hotel',
value: r.hotelCode,
code: 'HOTEL_REGION_MISMATCH',
metadata: { hotelCode: r.hotelCode, regionId: activeRegionId }
});
}
}
if (errors.length > 0) {
return NextResponse.json({
success: false,
error: {
code: 'IMPORT_VALIDATION_FAILED',
details: errors
}
}, { status: 400 });
}
// Create the SalesImportJob record first
await prisma.salesImportJob.create({
data: {
idempotencyKey,
status: 'PROCESSING',
uploadedBy: session.userId
}
});
// 5. Dispatch to n8n or direct save
const useN8n = process.env.N8N_WEBHOOK_URL && process.env.NODE_ENV !== 'test' && process.env.IS_E2E_TEST !== 'true' && !req.nextUrl.searchParams.has('direct');
if (useN8n) {
try {
const response = await fetch(process.env.N8N_WEBHOOK_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET || ''
},
body: JSON.stringify({
idempotencyKey,
uploaderId: session.userId,
sales: rows.map(r => ({
username: r.username,
hotelCode: r.hotelCode,
period: r.period,
amount: r.amount,
salesCount: r.salesCount,
transactionId: r.transactionId
}))
})
});
if (!response.ok) {
console.error('n8n integration failed:', await response.text());
await prisma.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'FAILED',
errorMessage: `n8n webhook responded with status ${response.status}`,
uploadedBy: session.userId
},
update: {
status: 'FAILED',
errorMessage: `n8n webhook responded with status ${response.status}`
}
});
return NextResponse.json({
success: false,
error: {
code: 'INTEGRATION_ERROR',
metadata: { status: response.status }
}
}, { status: 500 });
}
return NextResponse.json({
success: true,
code: 'IMPORT_ACCEPTED',
metadata: {
idempotencyKey,
status: 'PROCESSING'
}
}, { status: 202 });
} catch (err: any) {
await prisma.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'FAILED',
errorMessage: err.message,
uploadedBy: session.userId
},
update: {
status: 'FAILED',
errorMessage: err.message
}
});
throw err;
}
}
// Direct Import Fallback / Test mode
const totalAmount = rows.reduce((acc, r) => acc + r.amount, 0);
try {
await prisma.$transaction(async (tx: any) => {
const createdSales = [];
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
const user = userMap.get(r.username)!;
const hotel = hotelMap.get(r.hotelCode)!;
const created = await tx.salesResult.create({
data: {
source: 'EXCEL',
hotelId: hotel.id,
userId: user.id,
period: r.period,
amount: r.amount,
salesCount: r.salesCount,
idempotencyKey: `${idempotencyKey}-${i}`,
transactionId: r.transactionId || null,
uploadedBy: session.userId,
status: 'PENDING'
}
});
createdSales.push(created);
}
await tx.auditLog.create({
data: {
userId: session.userId,
action: 'CREATE',
targetTable: 'sales_results',
targetId: createdSales[0]?.id || 0,
newValue: {
count: createdSales.length,
idempotencyKey
}
}
});
// Update import job to SUCCESS
await tx.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'SUCCESS',
uploadedBy: session.userId
},
update: {
status: 'SUCCESS'
}
});
});
return NextResponse.json({
success: true,
code: 'IMPORT_SUCCESSFUL',
metadata: {
count: rows.length,
totalAmount
}
}, { status: 201 });
} catch (txErr: any) {
await prisma.salesImportJob.upsert({
where: { idempotencyKey },
create: {
idempotencyKey,
status: 'FAILED',
errorMessage: txErr.message,
uploadedBy: session.userId
},
update: {
status: 'FAILED',
errorMessage: txErr.message
}
});
throw txErr;
}
} catch (err: any) {
console.error('Sales import error:', err);
return NextResponse.json({
success: false,
error: {
code: 'INTERNAL_SERVER_ERROR',
metadata: { message: err.message }
}
}, { status: 500 });
}
}, ['admin', 'analyst', 'commercial_leader']);