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 const existingSales = await prisma.salesResult.findMany({ where: { idempotencyKey: { startsWith: `${idempotencyKey}-` } } }); if (existingSales.length > 0) { 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 } }); } // 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(dbUsers.map((u: any) => [u.username, u])); const hotelMap = new Map(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 }); } // 4. Dispatch to n8n or direct save const useN8n = process.env.N8N_WEBHOOK_URL && !req.nextUrl.searchParams.has('direct'); if (useN8n) { 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()); 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 }); } // Direct Import Fallback / Test mode const totalAmount = rows.reduce((acc, r) => acc + r.amount, 0); 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 } } }); }); return NextResponse.json({ success: true, code: 'IMPORT_SUCCESSFUL', metadata: { count: rows.length, totalAmount } }, { status: 201 }); } 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']);