427 lines
12 KiB
TypeScript
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']);
|