feat: implement phase 5 settlement calculation and approvals
442
n8n/settlement_calculation_workflow.json
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
{
|
||||
"name": "Settlement Calculation Workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "calculate-settlements",
|
||||
"responseMode": "responseNode",
|
||||
"options": {
|
||||
"rawBody": false
|
||||
}
|
||||
},
|
||||
"id": "Webhook-Node-1",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
100,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"string": [
|
||||
{
|
||||
"value1": "={{ $node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\" }}",
|
||||
"operation": "contains",
|
||||
"value2": "webhook-test"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "If-Node-1",
|
||||
"name": "Is Test/Dev Mode?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
300,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"values": {
|
||||
"string": [
|
||||
{
|
||||
"name": "appUrl",
|
||||
"value": "https://special-hotel-dev.gaboggamer.online"
|
||||
},
|
||||
{
|
||||
"name": "signature",
|
||||
"value": "qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc="
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "Set-Dev-Env",
|
||||
"name": "Set Dev Environment",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
500,
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"values": {
|
||||
"string": [
|
||||
{
|
||||
"name": "appUrl",
|
||||
"value": "https://special-hotel.gaboggamer.online"
|
||||
},
|
||||
{
|
||||
"name": "signature",
|
||||
"value": "Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ="
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "Set-Prod-Env",
|
||||
"name": "Set Prod Environment",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
500,
|
||||
400
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "GET",
|
||||
"url": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.appUrl : $('Set Prod Environment').item.json.appUrl }}/api/n8n/fetch-calculation-data",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "x-n8n-signature",
|
||||
"value": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.signature : $('Set Prod Environment').item.json.signature }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendQueryParameters": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "period",
|
||||
"value": "={{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "Http-Request-Fetch",
|
||||
"name": "Fetch Calculation Data",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.1,
|
||||
"position": [
|
||||
700,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.appUrl : $('Set Prod Environment').item.json.appUrl }}/api/n8n/process-formula",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "x-n8n-signature",
|
||||
"value": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.signature : $('Set Prod Environment').item.json.signature }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={\n \"period\": \"{{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}\",\n \"simulateOnly\": {{ $node[\"Webhook\"].json[\"body\"][\"simulateOnly\"] }},\n \"collaborators\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"collaborators\"]) }},\n \"activePlans\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"activePlans\"]) }},\n \"goals\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"goals\"]) }},\n \"salesResults\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"salesResults\"]) }},\n \"pastPeriods\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"pastPeriods\"]) }},\n \"approvedPastSettlements\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"approvedPastSettlements\"]) }},\n \"pastSalesResults\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"pastSalesResults\"]) }},\n \"pastGoals\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"pastGoals\"]) }}\n}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "Http-Request-Process",
|
||||
"name": "Process Formula",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.1,
|
||||
"position": [
|
||||
900,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=Review the calculated settlements for compliance. For each item in the results array, evaluate the main settlement totals and adjustments. Return a JSON object with a \"results\" array matching the input structure, but adding the fields \"aiAudited\" (boolean) and \"aiAuditNotes\" (string or null).\nIf a collaborator's total payout is negative, set aiAudited to true and aiAuditNotes to \"Audit warning: Negative net payout detected due to clawback adjustments.\"\nIf a collaborator's total payout exceeds 10,000, set aiAudited to true and aiAuditNotes to \"Audit warning: High commission payout exceeding 10K threshold.\"\nOtherwise, set aiAudited to false and aiAuditNotes to null.\n\nInput calculations data:\n{{ JSON.stringify($node[\"Process Formula\"].json[\"results\"]) }}",
|
||||
"hasOutputParser": true,
|
||||
"needsFallback": true,
|
||||
"options": {}
|
||||
},
|
||||
"id": "LLM-Chain-Audit",
|
||||
"name": "LLM Chain: Compliance Audit",
|
||||
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
||||
"typeVersion": 1.9,
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "DeepSeek-Model-Node-2",
|
||||
"name": "Primary Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatDeepSeek",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
950,
|
||||
480
|
||||
],
|
||||
"credentials": {
|
||||
"deepSeekApi": {
|
||||
"id": "YbGKBhQ9rlUebE4F",
|
||||
"name": "Semillero2_Primary_deepSeekApi"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "Google-Gemini-Model-Node-2",
|
||||
"name": "Fallback Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1100,
|
||||
480
|
||||
],
|
||||
"credentials": {
|
||||
"googlePalmApi": {
|
||||
"id": "1Vz05qteUH9kLf7Z",
|
||||
"name": "Google Gemini(PaLM) Api account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsonSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"main\": {\n \"type\": \"object\",\n \"properties\": {\n \"period\": { \"type\": \"string\" },\n \"planId\": { \"type\": \"number\" },\n \"userId\": { \"type\": \"number\" },\n \"salesAmount\": { \"type\": \"number\" },\n \"goalAmount\": { \"type\": \"number\" },\n \"achievementPercentage\": { \"type\": \"number\" },\n \"calculatedCommission\": { \"type\": \"number\" },\n \"calculatedBonus\": { \"type\": \"number\" },\n \"adjustmentAmount\": { \"type\": \"number\" },\n \"totalPayout\": { \"type\": \"number\" },\n \"status\": { \"type\": \"string\" },\n \"adjustmentNotes\": { \"type\": \"string\", \"nullable\": true }\n }\n },\n \"adjustments\": { \"type\": \"array\" },\n \"aiAudited\": { \"type\": \"boolean\" },\n \"aiAuditNotes\": { \"type\": \"string\", \"nullable\": true }\n }\n }\n }\n },\n \"required\": [\"results\"]\n}"
|
||||
},
|
||||
"id": "Structured-Output-Parser-Node-2",
|
||||
"name": "Structured Output Parser",
|
||||
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1250,
|
||||
480
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"boolean": [
|
||||
{
|
||||
"value1": "={{ $node[\"Webhook\"].json[\"body\"][\"simulateOnly\"] }}",
|
||||
"value2": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "If-Node-Simulation",
|
||||
"name": "Is Simulation Only?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1350,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.appUrl : $('Set Prod Environment').item.json.appUrl }}/api/n8n/save-settlements",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "x-n8n-signature",
|
||||
"value": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.signature : $('Set Prod Environment').item.json.signature }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={\n \"period\": \"{{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}\",\n \"simulateOnly\": false,\n \"results\": {{ JSON.stringify($node[\"LLM Chain: Compliance Audit\"].json[\"results\"]) }},\n \"uploaderId\": {{ $node[\"Webhook\"].json[\"body\"][\"uploaderId\"] || 1 }}\n}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "Http-Request-Save",
|
||||
"name": "Save Settlements",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.1,
|
||||
"position": [
|
||||
1550,
|
||||
400
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={\n \"success\": true,\n \"code\": \"SETTLEMENTS_CALCULATED\",\n \"metadata\": {\n \"count\": {{ $node[\"Process Formula\"].json[\"metadata\"][\"count\"] }},\n \"totalCommission\": {{ $node[\"Process Formula\"].json[\"metadata\"][\"totalCommission\"] }},\n \"totalAdjustment\": {{ $node[\"Process Formula\"].json[\"metadata\"][\"totalAdjustment\"] }}\n },\n \"simulated\": {{ JSON.stringify($node[\"LLM Chain: Compliance Audit\"].json[\"results\"]) }}\n}"
|
||||
},
|
||||
"id": "Respond-To-Webhook-Calculated",
|
||||
"name": "Respond to Webhook (Calculated)",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1750,
|
||||
300
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Is Test/Dev Mode?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Is Test/Dev Mode?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Set Dev Environment",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Set Prod Environment",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Set Dev Environment": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Fetch Calculation Data",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Set Prod Environment": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Fetch Calculation Data",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fetch Calculation Data": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Process Formula",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Process Formula": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "LLM Chain: Compliance Audit",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Primary Chat Model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "LLM Chain: Compliance Audit",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fallback Chat Model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "LLM Chain: Compliance Audit",
|
||||
"type": "ai_languageModel",
|
||||
"index": 1
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Structured Output Parser": {
|
||||
"outputParser": [
|
||||
[
|
||||
{
|
||||
"node": "LLM Chain: Compliance Audit",
|
||||
"type": "ai_outputParser",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"LLM Chain: Compliance Audit": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Is Simulation Only?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Is Simulation Only?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook (Calculated)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Save Settlements",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Save Settlements": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook (Calculated)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": true,
|
||||
"settings": {},
|
||||
"id": "settlement-calculation-workflow"
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@
|
|||
"n8n:bootstrap": "node scripts/n8n-bootstrap.js",
|
||||
"test:rls": "node prisma/test-rls.js",
|
||||
"test:auth-rls": "node prisma/test-auth-rls.js",
|
||||
"test:ui": "next build && node prisma/test-phase3-ui.js && node prisma/test-phase4-ui.js",
|
||||
"test:phase5": "node prisma/test-phase5-ui.js",
|
||||
"test:ui": "next build && node prisma/test-phase3-ui.js && node prisma/test-phase4-ui.js && node prisma/test-phase5-ui.js",
|
||||
"test:n8n": "node prisma/test-n8n-real.js",
|
||||
"test:all": "pnpm run test:rls && pnpm run test:auth-rls && pnpm run test:ui && pnpm run test:n8n",
|
||||
"test": "pnpm run test:all"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
BIN
prisma/screenshots-phase5/01_simulation_ready.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
prisma/screenshots-phase5/02_simulation_success.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
prisma/screenshots-phase5/03_lider_ctg_approvals_view.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
prisma/screenshots-phase5/04_rejection_modal.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
prisma/screenshots-phase5/05_rejection_done.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
prisma/screenshots-phase5/06_clawback_calculated.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
prisma/screenshots-phase5/error_screenshot.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
|
@ -10,7 +10,7 @@ const BASE_URL = "http://127.0.0.1:3001";
|
|||
|
||||
let prisma;
|
||||
let pool;
|
||||
let workflowId;
|
||||
const deployedWorkflowIds = [];
|
||||
let n8nUrl;
|
||||
let n8nApiKey;
|
||||
|
||||
|
|
@ -61,11 +61,13 @@ async function cleanupDb() {
|
|||
console.log("Cleaning up integration test records in test database...");
|
||||
try {
|
||||
await runAsAdmin(async (tx) => {
|
||||
console.log("DEBUG: Available models on tx:", Object.keys(tx).filter(k => !k.startsWith('$')));
|
||||
// Clean sales results
|
||||
await tx.salesResult.deleteMany({
|
||||
where: {
|
||||
idempotencyKey: { startsWith: 'key-integration' }
|
||||
OR: [
|
||||
{ idempotencyKey: { startsWith: 'key-integration' } },
|
||||
{ period: '2026-07' }
|
||||
]
|
||||
}
|
||||
});
|
||||
// Clean import jobs
|
||||
|
|
@ -75,9 +77,26 @@ async function cleanupDb() {
|
|||
idempotencyKey: { startsWith: 'key-integration' }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.warn("WARNING: tx.salesImportJob is undefined, skipping deleteMany.");
|
||||
}
|
||||
// Clean settlements
|
||||
await tx.settlement.deleteMany({
|
||||
where: {
|
||||
period: '2026-07'
|
||||
}
|
||||
});
|
||||
// Clean goals
|
||||
await tx.goal.deleteMany({
|
||||
where: {
|
||||
period: '2026-07'
|
||||
}
|
||||
});
|
||||
// Clean test plans
|
||||
await tx.calculationRule.deleteMany({
|
||||
where: { plan: { code: 'PLAN-INT-P5' } }
|
||||
});
|
||||
await tx.compensationPlan.deleteMany({
|
||||
where: { code: 'PLAN-INT-P5' }
|
||||
});
|
||||
// Clean audit logs
|
||||
await tx.auditLog.deleteMany({
|
||||
where: {
|
||||
|
|
@ -114,30 +133,7 @@ async function runTests() {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Load and modify n8n workflow JSON to point to our local test server via Docker bridge
|
||||
const workflowPath = path.join(__dirname, '../n8n/sales_import_workflow.json');
|
||||
if (!fs.existsSync(workflowPath)) {
|
||||
console.error("Error: n8n/sales_import_workflow.json not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workflowJson = JSON.parse(fs.readFileSync(workflowPath, 'utf8'));
|
||||
const webhookPath = `calculate-commissions-${Date.now()}`;
|
||||
|
||||
// Point callbacks to dev server, set dev test signature, and randomize path to prevent webhook conflicts
|
||||
for (const node of workflowJson.nodes) {
|
||||
if (node.id === 'Set-Dev-Env' || node.id === 'Set-Prod-Env') {
|
||||
node.parameters.values.string = [
|
||||
{ name: 'appUrl', value: 'http://special-hotel-dev:3000' },
|
||||
{ name: 'signature', value: process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' }
|
||||
];
|
||||
}
|
||||
if (node.type === 'n8n-nodes-base.webhook') {
|
||||
node.parameters.path = webhookPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5. Fetch existing workflows to retrieve the project ID dynamically
|
||||
// 3. Fetch existing workflows to retrieve the project ID dynamically
|
||||
console.log("Fetching project ID from existing workflows...");
|
||||
let projectId;
|
||||
try {
|
||||
|
|
@ -159,7 +155,7 @@ async function runTests() {
|
|||
console.warn("Warning: Could not fetch project ID dynamically, falling back to default.", err.message);
|
||||
}
|
||||
|
||||
// 3.6. Fetch credentials dynamically to resolve AI accounts
|
||||
// 4. Fetch credentials dynamically to resolve AI accounts
|
||||
console.log("Fetching credentials from n8n to resolve AI accounts...");
|
||||
let deepSeekCred = null;
|
||||
let geminiCred = null;
|
||||
|
|
@ -183,89 +179,11 @@ async function runTests() {
|
|||
if (geminiCreds.length > 0) {
|
||||
geminiCred = geminiCreds.find(c => c.name.toLowerCase().includes('gemini') || c.name.toLowerCase().includes('google')) || geminiCreds[0];
|
||||
}
|
||||
|
||||
if (deepSeekCred) {
|
||||
console.log(`Resolved DeepSeek credential: "${deepSeekCred.name}" (ID: ${deepSeekCred.id})`);
|
||||
}
|
||||
if (geminiCred) {
|
||||
console.log(`Resolved Gemini credential: "${geminiCred.name}" (ID: ${geminiCred.id})`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Warning: Failed to fetch credentials. Status: ${credsRes.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Warning: Could not fetch credentials dynamically:", err.message);
|
||||
}
|
||||
|
||||
// Inject resolved credentials dynamically into the workflow JSON
|
||||
for (const node of workflowJson.nodes) {
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) {
|
||||
node.credentials = {
|
||||
deepSeekApi: {
|
||||
id: deepSeekCred.id,
|
||||
name: deepSeekCred.name
|
||||
}
|
||||
};
|
||||
console.log(`Injected DeepSeek credential into node "${node.name}"`);
|
||||
}
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) {
|
||||
node.credentials = {
|
||||
googlePalmApi: {
|
||||
id: geminiCred.id,
|
||||
name: geminiCred.name
|
||||
}
|
||||
};
|
||||
console.log(`Injected Gemini credential into node "${node.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create workflow in n8n
|
||||
console.log("Deploying workflow to real n8n instance...");
|
||||
const workflowName = `Semillero E2E Integration: Sales Import - ${Date.now()}`;
|
||||
const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-N8N-API-KEY': n8nApiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: workflowName,
|
||||
nodes: workflowJson.nodes,
|
||||
connections: workflowJson.connections,
|
||||
settings: workflowJson.settings || {},
|
||||
projectId
|
||||
})
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
const errorText = await createRes.text();
|
||||
console.error(`Error: Failed to create n8n workflow. Status: ${createRes.status}. Output: ${errorText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const createData = await createRes.json();
|
||||
workflowId = createData.id;
|
||||
console.log(`Workflow deployed successfully! ID: ${workflowId}. Full response:`, JSON.stringify(createData));
|
||||
|
||||
// Activate the workflow if it is inactive (normally setting active in POST is enough, but double-checking)
|
||||
if (!createData.active) {
|
||||
console.log("Activating workflow...");
|
||||
const activateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}/activate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-N8N-API-KEY': n8nApiKey
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
if (!activateRes.ok) {
|
||||
console.error("Failed to activate workflow:", await activateRes.text());
|
||||
} else {
|
||||
console.log("Workflow activated successfully! Waiting 5 seconds for n8n webhooks to register...");
|
||||
await sleep(5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Verify the external dev server is running
|
||||
console.log(`Verifying target dev server at ${BASE_URL}...`);
|
||||
try {
|
||||
|
|
@ -279,19 +197,74 @@ async function runTests() {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// 6. Generate test parameters
|
||||
const idempotencyKey = `key-integration-${Date.now()}`;
|
||||
// ==========================================
|
||||
// TEST CASE 1: SALES IMPORT WORKFLOW
|
||||
// ==========================================
|
||||
console.log("\n[Test 1] Deploying and triggering Sales Import Workflow...");
|
||||
const importWorkflowPath = path.join(__dirname, '../n8n/sales_import_workflow.json');
|
||||
if (!fs.existsSync(importWorkflowPath)) {
|
||||
console.error("Error: n8n/sales_import_workflow.json not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 7. Trigger the n8n webhook directly
|
||||
console.log(`Triggering n8n webhook directly at ${n8nUrl}/webhook/${webhookPath}...`);
|
||||
const uploadRes = await fetch(`${n8nUrl}/webhook/${webhookPath}`, {
|
||||
const importWfJson = JSON.parse(fs.readFileSync(importWorkflowPath, 'utf8'));
|
||||
const importWebhookPath = `calculate-commissions-${Date.now()}`;
|
||||
|
||||
// Configure dev app urls and webhooks
|
||||
for (const node of importWfJson.nodes) {
|
||||
if (node.id === 'Set-Dev-Env' || node.id === 'Set-Prod-Env') {
|
||||
node.parameters.values.string = [
|
||||
{ name: 'appUrl', value: 'http://special-hotel-dev:3000' },
|
||||
{ name: 'signature', value: process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' }
|
||||
];
|
||||
}
|
||||
if (node.type === 'n8n-nodes-base.webhook') {
|
||||
node.parameters.path = importWebhookPath;
|
||||
}
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) {
|
||||
node.credentials = { deepSeekApi: { id: deepSeekCred.id, name: deepSeekCred.name } };
|
||||
}
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) {
|
||||
node.credentials = { googlePalmApi: { id: geminiCred.id, name: geminiCred.name } };
|
||||
}
|
||||
}
|
||||
|
||||
const importWfName = `Semillero E2E Integration: Sales Import - ${Date.now()}`;
|
||||
const importCreateRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-N8N-API-KEY': n8nApiKey },
|
||||
body: JSON.stringify({
|
||||
name: importWfName,
|
||||
nodes: importWfJson.nodes,
|
||||
connections: importWfJson.connections,
|
||||
settings: importWfJson.settings || {},
|
||||
projectId
|
||||
})
|
||||
});
|
||||
|
||||
if (!importCreateRes.ok) {
|
||||
console.error("Failed to deploy import workflow:", await importCreateRes.text());
|
||||
process.exit(1);
|
||||
}
|
||||
const importWfData = await importCreateRes.json();
|
||||
deployedWorkflowIds.push(importWfData.id);
|
||||
|
||||
// Activate import workflow
|
||||
await fetch(`${n8nUrl}/api/v1/workflows/${importWfData.id}/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-N8N-API-KEY': n8nApiKey }
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
const importIdempotencyKey = `key-integration-import-${Date.now()}`;
|
||||
const uploadRes = await fetch(`${n8nUrl}/webhook/${importWebhookPath}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc='
|
||||
},
|
||||
body: JSON.stringify({
|
||||
idempotencyKey,
|
||||
idempotencyKey: importIdempotencyKey,
|
||||
uploaderId: 1,
|
||||
sales: [
|
||||
{
|
||||
|
|
@ -300,38 +273,190 @@ async function runTests() {
|
|||
period: '2026-06',
|
||||
amount: 15000,
|
||||
salesCount: 5,
|
||||
transactionId: 'TX-EST-MDE-001'
|
||||
transactionId: `TX-INT-COMM-${Date.now()}`
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
assert(uploadRes.status === 200 || uploadRes.status === 202, `n8n webhook response code is ${uploadRes.status} (expected 200/202)`);
|
||||
assert(uploadRes.status === 200 || uploadRes.status === 202, `Import webhook response code: ${uploadRes.status}`);
|
||||
const uploadData = await uploadRes.json();
|
||||
assert(uploadData.success && uploadData.code === 'IMPORT_ACCEPTED', "n8n accepted the webhook trigger");
|
||||
assert(uploadData.success && uploadData.code === 'IMPORT_ACCEPTED', "Import accepted by n8n");
|
||||
|
||||
// 8. Poll DB for record insert from n8n callback
|
||||
console.log("Waiting for n8n to complete processing and send callback to Next.js...");
|
||||
let callbackCompleted = false;
|
||||
let importCallbackCompleted = false;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await sleep(2000);
|
||||
const dbSales = await runAsAdmin(tx => tx.salesResult.findMany({
|
||||
where: {
|
||||
idempotencyKey: {
|
||||
startsWith: idempotencyKey
|
||||
}
|
||||
}
|
||||
where: { idempotencyKey: { startsWith: importIdempotencyKey } }
|
||||
}));
|
||||
|
||||
if (dbSales.length > 0) {
|
||||
assert(dbSales.length === 1, "1 sales result record successfully processed and saved via n8n integration!");
|
||||
assert(parseFloat(dbSales[0].amount) === 15000.00, "Sales amount verified (15,000.00)");
|
||||
callbackCompleted = true;
|
||||
assert(dbSales.length === 1, "Sales result saved to dev database by n8n callback");
|
||||
assert(parseFloat(dbSales[0].amount) === 15000.00, "Imported amount verified");
|
||||
importCallbackCompleted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert(importCallbackCompleted, "Sales import integration completed successfully");
|
||||
|
||||
assert(callbackCompleted, "n8n background execution and database write callback verified");
|
||||
// ==========================================
|
||||
// TEST CASE 2: SETTLEMENT CALCULATION WORKFLOW
|
||||
// ==========================================
|
||||
console.log("\n[Test 2] Deploying and triggering Settlement Calculation Workflow...");
|
||||
const calcWorkflowPath = path.join(__dirname, '../n8n/settlement_calculation_workflow.json');
|
||||
if (!fs.existsSync(calcWorkflowPath)) {
|
||||
console.error("Error: n8n/settlement_calculation_workflow.json not found.");
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const calcWfJson = JSON.parse(fs.readFileSync(calcWorkflowPath, 'utf8'));
|
||||
const calcWebhookPath = `calculate-settlements-${Date.now()}`;
|
||||
|
||||
// Configure dev app urls and webhooks
|
||||
for (const node of calcWfJson.nodes) {
|
||||
if (node.id === 'Set-Dev-Env' || node.id === 'Set-Prod-Env') {
|
||||
node.parameters.values.string = [
|
||||
{ name: 'appUrl', value: 'http://special-hotel-dev:3000' },
|
||||
{ name: 'signature', value: process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' }
|
||||
];
|
||||
}
|
||||
if (node.type === 'n8n-nodes-base.webhook') {
|
||||
node.parameters.path = calcWebhookPath;
|
||||
}
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) {
|
||||
node.credentials = { deepSeekApi: { id: deepSeekCred.id, name: deepSeekCred.name } };
|
||||
}
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) {
|
||||
node.credentials = { googlePalmApi: { id: geminiCred.id, name: geminiCred.name } };
|
||||
}
|
||||
}
|
||||
|
||||
const calcWfName = `Semillero E2E Integration: Settlement Calc - ${Date.now()}`;
|
||||
const calcCreateRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-N8N-API-KEY': n8nApiKey },
|
||||
body: JSON.stringify({
|
||||
name: calcWfName,
|
||||
nodes: calcWfJson.nodes,
|
||||
connections: calcWfJson.connections,
|
||||
settings: calcWfJson.settings || {},
|
||||
projectId
|
||||
})
|
||||
});
|
||||
|
||||
if (!calcCreateRes.ok) {
|
||||
console.error("Failed to deploy calculation workflow:", await calcCreateRes.text());
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
const calcWfData = await calcCreateRes.json();
|
||||
deployedWorkflowIds.push(calcWfData.id);
|
||||
|
||||
// Activate calculation workflow
|
||||
await fetch(`${n8nUrl}/api/v1/workflows/${calcWfData.id}/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-N8N-API-KEY': n8nApiKey }
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
// Seed DB records for the calculation E2E test
|
||||
console.log("Seeding test database with plan, rules, goal, and sales results for calculation E2E test...");
|
||||
const adminUser = (await runAsAdmin(tx => tx.user.findMany())).find(u => u.username === 'admin');
|
||||
const colaboradorMde = (await runAsAdmin(tx => tx.user.findMany())).find(u => u.username === 'colaborador_mde');
|
||||
|
||||
const testPlan = await runAsAdmin(async (tx) => {
|
||||
// Delete existing PLAN-INT-P5 if any
|
||||
await tx.calculationRule.deleteMany({ where: { plan: { code: 'PLAN-INT-P5' } } });
|
||||
await tx.compensationPlan.deleteMany({ where: { code: 'PLAN-INT-P5' } });
|
||||
|
||||
const plan = await tx.compensationPlan.create({
|
||||
data: {
|
||||
name: 'Integration Test Plan Phase 5',
|
||||
code: 'PLAN-INT-P5',
|
||||
validityStart: new Date('2026-01-01'),
|
||||
type: 'SCALE',
|
||||
status: 'ACTIVE',
|
||||
version: 1,
|
||||
createdBy: adminUser.id
|
||||
}
|
||||
});
|
||||
|
||||
await tx.calculationRule.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
type: 'TIER',
|
||||
minAchievement: 0.0,
|
||||
maxAchievement: 2.0,
|
||||
rate: 0.02,
|
||||
payoutAmount: 0.0
|
||||
}
|
||||
});
|
||||
|
||||
return plan;
|
||||
});
|
||||
|
||||
// Seed Goal and SalesResult for period 2026-07
|
||||
await runAsAdmin(async (tx) => {
|
||||
await tx.goal.deleteMany({ where: { period: '2026-07', targetId: colaboradorMde.id } });
|
||||
await tx.goal.create({
|
||||
data: {
|
||||
targetType: 'INDIVIDUAL',
|
||||
targetId: colaboradorMde.id,
|
||||
period: '2026-07',
|
||||
amount: 10000.00
|
||||
}
|
||||
});
|
||||
|
||||
await tx.salesResult.deleteMany({ where: { period: '2026-07', userId: colaboradorMde.id } });
|
||||
await tx.salesResult.create({
|
||||
data: {
|
||||
source: 'API',
|
||||
hotelId: colaboradorMde.hotelId,
|
||||
userId: colaboradorMde.id,
|
||||
period: '2026-07',
|
||||
amount: 5000.00,
|
||||
salesCount: 2,
|
||||
idempotencyKey: 'key-integration-sales-p5',
|
||||
uploadedBy: adminUser.id,
|
||||
status: 'PENDING'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger calculation webhook (commit mode)
|
||||
console.log(`Triggering settlement calculation webhook directly at ${n8nUrl}/webhook/${calcWebhookPath}...`);
|
||||
const calcRes = await fetch(`${n8nUrl}/webhook/${calcWebhookPath}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc='
|
||||
},
|
||||
body: JSON.stringify({
|
||||
period: '2026-07',
|
||||
simulateOnly: false,
|
||||
uploaderId: adminUser.id
|
||||
})
|
||||
});
|
||||
|
||||
assert(calcRes.status === 200 || calcRes.status === 201, `Calculation webhook response code: ${calcRes.status}`);
|
||||
const calcData = await calcRes.json();
|
||||
assert(calcData.success && calcData.code === 'SETTLEMENTS_CALCULATED', "Calculation execution successful");
|
||||
|
||||
// Verify settlement created in database
|
||||
const finalSettlement = await runAsAdmin(tx => tx.settlement.findFirst({
|
||||
where: {
|
||||
userId: colaboradorMde.id,
|
||||
period: '2026-07',
|
||||
planId: testPlan.id
|
||||
}
|
||||
}));
|
||||
|
||||
assert(finalSettlement !== null, "Settlement record created in database for colaborador_mde in 2026-07");
|
||||
if (finalSettlement) {
|
||||
assert(finalSettlement.status === 'PENDING', "Settlement status is set to PENDING");
|
||||
assert(parseFloat(finalSettlement.calculatedCommission) === 100.00, "Calculated commission is correctly 100.00 (5,000 * 2%)");
|
||||
assert(parseFloat(finalSettlement.totalPayout) === 100.00, "Total payout is correctly 100.00");
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
|
||||
|
|
@ -346,26 +471,26 @@ async function runTests() {
|
|||
async function cleanup() {
|
||||
console.log("\nCleaning up integration test processes...");
|
||||
|
||||
// 1. Delete workflow from n8n
|
||||
if (workflowId) {
|
||||
// 1. Delete all deployed workflows from n8n
|
||||
for (const id of deployedWorkflowIds) {
|
||||
if (failed > 0) {
|
||||
console.log(`[TEST FAILED] Skipping test workflow ${workflowId} deletion to allow troubleshooting in n8n.gaboggamer.online.`);
|
||||
console.log(`[TEST FAILED] Skipping test workflow ${id} deletion to allow troubleshooting in n8n.gaboggamer.online.`);
|
||||
} else {
|
||||
console.log(`Deleting n8n test workflow ${workflowId}...`);
|
||||
console.log(`Deleting n8n test workflow ${id}...`);
|
||||
try {
|
||||
const delRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}`, {
|
||||
const delRes = await fetch(`${n8nUrl}/api/v1/workflows/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-N8N-API-KEY': n8nApiKey
|
||||
}
|
||||
});
|
||||
if (delRes.ok) {
|
||||
console.log("Test workflow deleted successfully from n8n.");
|
||||
console.log(`Test workflow ${id} deleted successfully from n8n.`);
|
||||
} else {
|
||||
console.error("Failed to delete test workflow:", await delRes.text());
|
||||
console.error(`Failed to delete test workflow ${id}:`, await delRes.text());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error deleting workflow:", e);
|
||||
console.error(`Error deleting workflow ${id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -391,6 +516,7 @@ process.on('SIGINT', async () => {
|
|||
|
||||
runTests().catch(async err => {
|
||||
console.error("Fatal E2E integration test runner error:", err);
|
||||
failed++;
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
558
prisma/test-phase5-ui.js
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
const { spawn } = require('child_process');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
const { Pool } = require('pg');
|
||||
const puppeteer = require('puppeteer');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const PORT = 3015;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
const SCREENSHOT_DIR = path.join(__dirname, 'screenshots-phase5');
|
||||
|
||||
let nextProcess;
|
||||
let prisma;
|
||||
let pool;
|
||||
let browser;
|
||||
|
||||
// Ensure screenshot dir exists
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function getPrisma() {
|
||||
if (!prisma) {
|
||||
const dbUrl = new URL(process.env.DATABASE_URL);
|
||||
pool = new Pool({
|
||||
host: dbUrl.hostname,
|
||||
port: dbUrl.port ? parseInt(dbUrl.port) : 5432,
|
||||
user: decodeURIComponent(dbUrl.username),
|
||||
password: decodeURIComponent(dbUrl.password),
|
||||
database: dbUrl.pathname.substring(1).split('?')[0],
|
||||
ssl: false
|
||||
});
|
||||
const adapter = new PrismaPg(pool);
|
||||
prisma = new PrismaClient({ adapter });
|
||||
}
|
||||
return prisma;
|
||||
}
|
||||
|
||||
async function runAsAdmin(queryFn) {
|
||||
const db = getPrisma();
|
||||
return db.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||
return queryFn(tx);
|
||||
});
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
console.log(` ✓ PASS: ${message}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.error(` ✗ FAIL: ${message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDb() {
|
||||
console.log("Cleaning up database records for Phase 5 tests...");
|
||||
await runAsAdmin(async (tx) => {
|
||||
// Delete settlements created during E2E tests
|
||||
await tx.settlement.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ period: '2026-06' },
|
||||
{ period: '2026-05' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Delete goals created during E2E tests
|
||||
await tx.goal.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ period: '2026-06' },
|
||||
{ period: '2026-05' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Delete sales results created during E2E tests
|
||||
await tx.salesResult.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ period: '2026-06' },
|
||||
{ period: '2026-05' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Delete test plans
|
||||
await tx.calculationRule.deleteMany({
|
||||
where: { plan: { code: 'PLAN-E2E-P5' } }
|
||||
});
|
||||
await tx.compensationPlan.deleteMany({
|
||||
where: { code: 'PLAN-E2E-P5' }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log("=== STARTING PHASE 5 SETTLEMENT ENGINE & APPROVALS E2E PUPPETEER TEST SUITE ===");
|
||||
|
||||
// 1. Clean database
|
||||
await cleanupDb();
|
||||
|
||||
// 2. Seed active plan & rules for the tests
|
||||
console.log("Seeding test plan and rules...");
|
||||
const users = await runAsAdmin(tx => tx.user.findMany());
|
||||
const adminUser = users.find(u => u.username === 'admin');
|
||||
const colaboradorMde = users.find(u => u.username === 'colaborador_mde');
|
||||
const liderCtg = users.find(u => u.username === 'lider_ctg');
|
||||
|
||||
if (!colaboradorMde || !liderCtg) {
|
||||
console.error("Error: Collaborator or Leader user not found in database.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const testPlan = await runAsAdmin(async (tx) => {
|
||||
const plan = await tx.compensationPlan.create({
|
||||
data: {
|
||||
name: 'Plan Test Phase 5',
|
||||
code: 'PLAN-E2E-P5',
|
||||
validityStart: new Date('2026-01-01'),
|
||||
type: 'SCALE',
|
||||
status: 'ACTIVE',
|
||||
version: 1,
|
||||
createdBy: adminUser.id
|
||||
}
|
||||
});
|
||||
|
||||
await tx.calculationRule.createMany({
|
||||
data: [
|
||||
{
|
||||
planId: plan.id,
|
||||
type: 'TIER',
|
||||
minAchievement: 0.0,
|
||||
maxAchievement: 0.8,
|
||||
rate: 0.01,
|
||||
payoutAmount: 0.0
|
||||
},
|
||||
{
|
||||
planId: plan.id,
|
||||
type: 'TIER',
|
||||
minAchievement: 0.8,
|
||||
maxAchievement: 1.2,
|
||||
rate: 0.02,
|
||||
payoutAmount: 0.0
|
||||
},
|
||||
{
|
||||
planId: plan.id,
|
||||
type: 'BONUS',
|
||||
minAchievement: 1.0,
|
||||
maxAchievement: 2.0,
|
||||
rate: 0.0,
|
||||
payoutAmount: 500.00
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return plan;
|
||||
});
|
||||
|
||||
// 3. Start Next.js production server on port 3015
|
||||
console.log(`Starting Next.js production server on port ${PORT}...`);
|
||||
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'start', '--port', String(PORT)], {
|
||||
env: { ...process.env, PORT: String(PORT), IS_E2E_TEST: 'true' }
|
||||
});
|
||||
|
||||
nextProcess.stdout.on('data', (data) => {
|
||||
console.log(`[Next.js] ${data.toString().trim()}`);
|
||||
});
|
||||
nextProcess.stderr.on('data', (data) => {
|
||||
console.error(`[Next.js ERR] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
// Wait for server to start up
|
||||
let serverReady = false;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await sleep(2000);
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/auth/me`);
|
||||
serverReady = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
// Server not ready yet
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverReady) {
|
||||
console.error("Error: Next.js server failed to start within timeout.");
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Next.js server is ready!");
|
||||
|
||||
// 4. Launch Puppeteer browser
|
||||
console.log("Launching Puppeteer browser...");
|
||||
browser = await puppeteer.launch({
|
||||
headless: 'shell',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu']
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
|
||||
// Helper to set value of React-controlled inputs natively
|
||||
const setReactInput = async (selector, value) => {
|
||||
await page.$eval(selector, (el, val) => {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value"
|
||||
).set;
|
||||
nativeSetter.call(el, val);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, value);
|
||||
};
|
||||
|
||||
// --- TEST 1: ENGINE ACCURACY ---
|
||||
console.log("\n[Test 1] Seeding sales/goals and running calculation...");
|
||||
|
||||
// Seed goal and sales results for 2026-06
|
||||
await runAsAdmin(async (tx) => {
|
||||
await tx.goal.create({
|
||||
data: {
|
||||
targetType: 'INDIVIDUAL',
|
||||
targetId: colaboradorMde.id,
|
||||
period: '2026-06',
|
||||
amount: 10000.00
|
||||
}
|
||||
});
|
||||
|
||||
await tx.salesResult.create({
|
||||
data: {
|
||||
source: 'EXCEL',
|
||||
hotelId: colaboradorMde.hotelId,
|
||||
userId: colaboradorMde.id,
|
||||
period: '2026-06',
|
||||
amount: 9000.00,
|
||||
salesCount: 5,
|
||||
idempotencyKey: 'key-p5-test-1',
|
||||
uploadedBy: adminUser.id
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Login as admin
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
|
||||
await page.type('#username', 'admin');
|
||||
await page.type('#password', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForSelector('#nav-simulation');
|
||||
|
||||
// Go to simulation page
|
||||
await page.click('#nav-simulation');
|
||||
await page.waitForSelector('#sim-period');
|
||||
|
||||
// Run simulation in commit mode (check off dry-run)
|
||||
await setReactInput('#sim-period', '2026-06');
|
||||
|
||||
// Uncheck dry-run checkbox to write to DB
|
||||
const isChecked1 = await page.$eval('#sim-dry-run', el => el.checked);
|
||||
if (isChecked1) {
|
||||
await page.click('#sim-dry-run');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '01_simulation_ready.png') });
|
||||
await page.click('#btn-run-simulation');
|
||||
await page.waitForSelector('#simulation-success-msg');
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '02_simulation_success.png') });
|
||||
|
||||
// Verify in database
|
||||
const dbSettlement = await runAsAdmin(tx => tx.settlement.findFirst({
|
||||
where: {
|
||||
userId: colaboradorMde.id,
|
||||
period: '2026-06',
|
||||
originalSettlementId: null
|
||||
}
|
||||
}));
|
||||
|
||||
assert(dbSettlement !== null, "Settlement record created in database for colaborador_mde in 2026-06");
|
||||
assert(dbSettlement.status === 'PENDING', "Settlement status is set to PENDING");
|
||||
assert(parseFloat(dbSettlement.salesAmount) === 9000.00, "Sales amount is correctly 9,000.00");
|
||||
assert(parseFloat(dbSettlement.calculatedCommission) === 180.00, "Proposed commission is 180.00 (9,000 * 2%)");
|
||||
assert(parseFloat(dbSettlement.totalPayout) === 180.00, "Total payout is 180.00");
|
||||
|
||||
// --- TEST 2: LEADER ISOLATION & REJECTION CONSTRAINT ---
|
||||
console.log("\n[Test 2] Verifying leader isolation and rejection constraint...");
|
||||
|
||||
// Logout admin
|
||||
await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' });
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const logoutBtn = buttons.find(b => b.textContent.includes('Cerrar Sesión'));
|
||||
if (logoutBtn) logoutBtn.click();
|
||||
});
|
||||
await page.waitForSelector('#username');
|
||||
|
||||
// Login as lider_ctg (Caribe region leader)
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
|
||||
await page.type('#username', 'lider_ctg');
|
||||
await page.type('#password', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForSelector('#nav-approvals');
|
||||
|
||||
// Navigate to Approvals
|
||||
await page.click('#nav-approvals');
|
||||
await page.waitForSelector('#approvals-table');
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '03_lider_ctg_approvals_view.png') });
|
||||
|
||||
// Leader of Caribe should not see colaborador_mde's settlement in the table (which is in Antioquia region)
|
||||
const hasColabRow = await page.evaluate((username) => {
|
||||
return document.body.innerHTML.includes(username);
|
||||
}, colaboradorMde.username);
|
||||
assert(!hasColabRow, "Leader of Caribe region cannot see collaborator of Antioquia region in the segregated list");
|
||||
|
||||
const directApproveRes = await page.evaluate(async (settlementId) => {
|
||||
const res = await fetch(`/api/settlements/${settlementId}/approve`, {
|
||||
method: 'POST'
|
||||
});
|
||||
return { status: res.status };
|
||||
}, dbSettlement.id);
|
||||
assert(directApproveRes.status === 403, "Direct API call to approve another region's settlement returns 403 Forbidden, got " + directApproveRes.status);
|
||||
|
||||
const directRejectRes = await page.evaluate(async (settlementId) => {
|
||||
const res = await fetch(`/api/settlements/${settlementId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: 'Malicioso' })
|
||||
});
|
||||
return { status: res.status };
|
||||
}, dbSettlement.id);
|
||||
assert(directRejectRes.status === 403, "Direct API call to reject another region's settlement returns 403 Forbidden, got " + directRejectRes.status);
|
||||
|
||||
// Logout lider_ctg
|
||||
await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' });
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const logoutBtn = buttons.find(b => b.textContent.includes('Cerrar Sesión'));
|
||||
if (logoutBtn) logoutBtn.click();
|
||||
});
|
||||
await page.waitForSelector('#username');
|
||||
|
||||
// Login as admin to test rejection constraint
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
|
||||
await page.type('#username', 'admin');
|
||||
await page.type('#password', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForSelector('#nav-approvals');
|
||||
|
||||
// Navigate to Approvals
|
||||
await page.click('#nav-approvals');
|
||||
await page.waitForSelector('#approvals-table');
|
||||
|
||||
// Verify direct reject call with empty reason returns 400
|
||||
const emptyReasonRes = await page.evaluate(async (settlementId) => {
|
||||
const res = await fetch(`/api/settlements/${settlementId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: '' })
|
||||
});
|
||||
return { status: res.status };
|
||||
}, dbSettlement.id);
|
||||
assert(emptyReasonRes.status === 400, "Direct API call to reject settlement with empty reason returns 400 Bad Request");
|
||||
|
||||
// Reject the settlement using UI Modal
|
||||
await page.click(`#btn-reject-${dbSettlement.id}`);
|
||||
await page.waitForSelector('#reject-reason-input');
|
||||
await page.type('#reject-reason-input', 'Venta reportada del colaborador no coincide con el cierre del hotel');
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_rejection_modal.png') });
|
||||
await page.click('#btn-confirm-reject');
|
||||
await page.waitForSelector('#approvals-success-msg');
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '05_rejection_done.png') });
|
||||
|
||||
// Verify status in DB
|
||||
const rejectedSettlement = await runAsAdmin(tx => tx.settlement.findUnique({
|
||||
where: { id: dbSettlement.id }
|
||||
}));
|
||||
assert(rejectedSettlement.status === 'REJECTED', "Settlement status updated to REJECTED in database");
|
||||
assert(rejectedSettlement.rejectionReason === 'Venta reportada del colaborador no coincide con el cierre del hotel', "Rejection reason successfully stored in database");
|
||||
|
||||
// --- TEST 3: CLAWBACK DELTA LOGIC ---
|
||||
console.log("\n[Test 3] Testing clawback delta adjustments...");
|
||||
|
||||
// Reset database state:
|
||||
// Let's create an approved settlement for Month M-1 (2026-05) and simulate refund
|
||||
await runAsAdmin(async (tx) => {
|
||||
// Delete any existing settlement for 2026-05
|
||||
await tx.settlement.deleteMany({
|
||||
where: { userId: colaboradorMde.id, period: '2026-05' }
|
||||
});
|
||||
|
||||
// Create Goal for 2026-05
|
||||
await tx.goal.create({
|
||||
data: {
|
||||
targetType: 'INDIVIDUAL',
|
||||
targetId: colaboradorMde.id,
|
||||
period: '2026-05',
|
||||
amount: 10000.00
|
||||
}
|
||||
});
|
||||
|
||||
// Create SalesResult for 2026-05 (Original sales amount = 10,000.00)
|
||||
await tx.salesResult.create({
|
||||
data: {
|
||||
source: 'EXCEL',
|
||||
hotelId: colaboradorMde.hotelId,
|
||||
userId: colaboradorMde.id,
|
||||
period: '2026-05',
|
||||
amount: 10000.00,
|
||||
salesCount: 5,
|
||||
idempotencyKey: 'key-p5-past-sales',
|
||||
uploadedBy: adminUser.id
|
||||
}
|
||||
});
|
||||
|
||||
// Create APPROVED original settlement for 2026-05
|
||||
// Payout = 10,000 * 2% + 500 bonus = 700.00
|
||||
await tx.settlement.create({
|
||||
data: {
|
||||
period: '2026-05',
|
||||
planId: testPlan.id,
|
||||
userId: colaboradorMde.id,
|
||||
salesAmount: 10000.00,
|
||||
goalAmount: 10000.00,
|
||||
achievementPercentage: 1.00,
|
||||
calculatedCommission: 200.00,
|
||||
calculatedBonus: 500.00,
|
||||
adjustmentAmount: 0.00,
|
||||
totalPayout: 700.00,
|
||||
status: 'APPROVED',
|
||||
approvedBy: adminUser.id,
|
||||
approvedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Now, simulate a cancellation/refund in the PMS:
|
||||
// Update the SalesResult for 2026-05 to be 5,000.00 instead of 10,000.00
|
||||
await tx.salesResult.update({
|
||||
where: { idempotencyKey: 'key-p5-past-sales' },
|
||||
data: { amount: 5000.00 }
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate 2026-06 settlements again.
|
||||
// Current month (2026-06) sales = 9,000.00, Goal = 10,000.00.
|
||||
// Recalculating 2026-05:
|
||||
// - Live sales sum = 5,000.00
|
||||
// - Achievement = 50%
|
||||
// - Corrected payout = 5,000 * 1% = 50.00 (No bonus since achievement < 1.0)
|
||||
// - Previously paid = 700.00
|
||||
// - Delta = 50.00 - 700.00 = -650.00
|
||||
// Current month proposed = 9,000 * 2% = 180.00
|
||||
// Total payout = 180.00 + (-650.00) = -470.00
|
||||
|
||||
// Navigate to simulation page again
|
||||
await page.goto(`${BASE_URL}/sales/simulation`, { waitUntil: 'networkidle2' });
|
||||
await setReactInput('#sim-period', '2026-06');
|
||||
|
||||
// Uncheck dry-run checkbox to write to DB
|
||||
const isChecked2 = await page.$eval('#sim-dry-run', el => el.checked);
|
||||
if (isChecked2) {
|
||||
await page.click('#sim-dry-run');
|
||||
}
|
||||
|
||||
await page.click('#btn-run-simulation');
|
||||
await page.waitForSelector('#simulation-success-msg');
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '06_clawback_calculated.png') });
|
||||
|
||||
// Verify DB record
|
||||
const finalSettlement = await runAsAdmin(tx => tx.settlement.findFirst({
|
||||
where: {
|
||||
userId: colaboradorMde.id,
|
||||
period: '2026-06',
|
||||
originalSettlementId: null
|
||||
}
|
||||
}));
|
||||
|
||||
assert(finalSettlement !== null, "Main settlement record created for 2026-06");
|
||||
assert(parseFloat(finalSettlement.adjustmentAmount) === -650.00, "Retroactive adjustment delta matches expected clawback (-650.00)");
|
||||
assert(parseFloat(finalSettlement.totalPayout) === -470.00, "Total payout is correctly -470.00 (180 commission - 650 clawback)");
|
||||
|
||||
// Verify adjustment record in DB
|
||||
const adjSettlement = await runAsAdmin(tx => tx.settlement.findFirst({
|
||||
where: {
|
||||
userId: colaboradorMde.id,
|
||||
period: '2026-06',
|
||||
originalSettlementId: { not: null }
|
||||
}
|
||||
}));
|
||||
assert(adjSettlement !== null, "Adjustment settlement record created in DB linked to original settlement ID");
|
||||
assert(parseFloat(adjSettlement.adjustmentAmount) === -650.00, "Adjustment record amount is -650.00");
|
||||
|
||||
await cleanup();
|
||||
|
||||
console.log(`\n=== E2E TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
console.log("\nCleaning up E2E test browser and server processes...");
|
||||
try {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
await cleanupDb();
|
||||
if (prisma) {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error during DB cleanup:", e);
|
||||
}
|
||||
|
||||
if (nextProcess) {
|
||||
nextProcess.kill();
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
runTests().catch(async err => {
|
||||
console.error("Fatal E2E test runner error:", err);
|
||||
if (browser) {
|
||||
try {
|
||||
const pages = await browser.pages();
|
||||
if (pages.length > 0) {
|
||||
await pages[0].screenshot({ path: path.join(SCREENSHOT_DIR, 'error_screenshot.png') });
|
||||
console.log("Saved error_screenshot.png to", path.join(SCREENSHOT_DIR, 'error_screenshot.png'));
|
||||
}
|
||||
} catch (screenshotErr) {
|
||||
console.error("Failed to capture error screenshot:", screenshotErr);
|
||||
}
|
||||
}
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -22,17 +22,7 @@ async function bootstrap() {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Load the official local workflow JSON
|
||||
const workflowPath = path.join(__dirname, '../n8n/sales_import_workflow.json');
|
||||
if (!fs.existsSync(workflowPath)) {
|
||||
console.error("Error: n8n/sales_import_workflow.json not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workflowJson = JSON.parse(fs.readFileSync(workflowPath, 'utf8'));
|
||||
const targetName = workflowJson.name || "Sales Data Import & Validation";
|
||||
|
||||
// 2.5. Fetch credentials dynamically to resolve AI accounts
|
||||
// 2. Fetch credentials dynamically to resolve AI accounts
|
||||
console.log("Fetching credentials from n8n to resolve AI accounts...");
|
||||
let deepSeekCred = null;
|
||||
let geminiCred = null;
|
||||
|
|
@ -70,30 +60,8 @@ async function bootstrap() {
|
|||
console.warn("Warning: Could not fetch credentials dynamically:", err.message);
|
||||
}
|
||||
|
||||
// Inject resolved credentials dynamically into the workflow JSON
|
||||
for (const node of workflowJson.nodes) {
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) {
|
||||
node.credentials = {
|
||||
deepSeekApi: {
|
||||
id: deepSeekCred.id,
|
||||
name: deepSeekCred.name
|
||||
}
|
||||
};
|
||||
console.log(`Injected DeepSeek credential into node "${node.name}"`);
|
||||
}
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) {
|
||||
node.credentials = {
|
||||
googlePalmApi: {
|
||||
id: geminiCred.id,
|
||||
name: geminiCred.name
|
||||
}
|
||||
};
|
||||
console.log(`Injected Gemini credential into node "${node.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check if workflow already exists in n8n
|
||||
console.log(`Searching for existing workflow named "${targetName}"...`);
|
||||
// 3. Fetch project ID dynamically
|
||||
console.log("Fetching workflows list to resolve project ID...");
|
||||
const listRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -107,9 +75,6 @@ async function bootstrap() {
|
|||
}
|
||||
|
||||
const listData = await listRes.json();
|
||||
const existingWorkflow = listData.data.find(w => w.name === targetName);
|
||||
|
||||
// Retrieve project ID from existing workflows if available
|
||||
let projectId;
|
||||
const firstWf = listData.data.find(w => w.shared && w.shared.length > 0);
|
||||
if (firstWf) {
|
||||
|
|
@ -117,76 +82,120 @@ async function bootstrap() {
|
|||
console.log(`Resolved project ID dynamically: ${projectId}`);
|
||||
}
|
||||
|
||||
let workflowId;
|
||||
if (existingWorkflow) {
|
||||
workflowId = existingWorkflow.id;
|
||||
console.log(`Found existing workflow. ID: ${workflowId}. Updating...`);
|
||||
const workflowsToBootstrap = [
|
||||
'sales_import_workflow.json',
|
||||
'settlement_calculation_workflow.json'
|
||||
];
|
||||
|
||||
const updateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-N8N-API-KEY': n8nApiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: targetName,
|
||||
nodes: workflowJson.nodes,
|
||||
connections: workflowJson.connections,
|
||||
settings: workflowJson.settings || {}
|
||||
})
|
||||
});
|
||||
|
||||
if (!updateRes.ok) {
|
||||
console.error(`Failed to update workflow. Status: ${updateRes.status}`);
|
||||
process.exit(1);
|
||||
for (const filename of workflowsToBootstrap) {
|
||||
console.log(`\n--- Bootstrapping workflow: ${filename} ---`);
|
||||
const workflowPath = path.join(__dirname, '../n8n', filename);
|
||||
if (!fs.existsSync(workflowPath)) {
|
||||
console.error(`Error: ${workflowPath} not found.`);
|
||||
continue;
|
||||
}
|
||||
console.log(`Workflow updated successfully!`);
|
||||
} else {
|
||||
console.log("No existing workflow found. Creating new workflow...");
|
||||
|
||||
const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
|
||||
const workflowJson = JSON.parse(fs.readFileSync(workflowPath, 'utf8'));
|
||||
const targetName = workflowJson.name;
|
||||
|
||||
// Inject resolved credentials dynamically into the workflow JSON
|
||||
for (const node of workflowJson.nodes) {
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.chainLlm') {
|
||||
// Find deepseek node and gemini node to map credentials
|
||||
}
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) {
|
||||
node.credentials = {
|
||||
deepSeekApi: {
|
||||
id: deepSeekCred.id,
|
||||
name: deepSeekCred.name
|
||||
}
|
||||
};
|
||||
console.log(`Injected DeepSeek credential into node "${node.name}" in ${filename}`);
|
||||
}
|
||||
if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) {
|
||||
node.credentials = {
|
||||
googlePalmApi: {
|
||||
id: geminiCred.id,
|
||||
name: geminiCred.name
|
||||
}
|
||||
};
|
||||
console.log(`Injected Gemini credential into node "${node.name}" in ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
const existingWorkflow = listData.data.find(w => w.name === targetName);
|
||||
let workflowId;
|
||||
|
||||
if (existingWorkflow) {
|
||||
workflowId = existingWorkflow.id;
|
||||
console.log(`Found existing workflow "${targetName}". ID: ${workflowId}. Updating...`);
|
||||
|
||||
const updateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-N8N-API-KEY': n8nApiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: targetName,
|
||||
nodes: workflowJson.nodes,
|
||||
connections: workflowJson.connections,
|
||||
settings: workflowJson.settings || {}
|
||||
})
|
||||
});
|
||||
|
||||
if (!updateRes.ok) {
|
||||
console.error(`Failed to update workflow. Status: ${updateRes.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Workflow updated successfully!`);
|
||||
} else {
|
||||
console.log(`No existing workflow found for "${targetName}". Creating new workflow...`);
|
||||
|
||||
const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-N8N-API-KEY': n8nApiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: targetName,
|
||||
nodes: workflowJson.nodes,
|
||||
connections: workflowJson.connections,
|
||||
settings: workflowJson.settings || {},
|
||||
projectId
|
||||
})
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
console.error(`Failed to create workflow. Status: ${createRes.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const createData = await createRes.json();
|
||||
workflowId = createData.id;
|
||||
console.log(`Workflow created successfully! ID: ${workflowId}`);
|
||||
}
|
||||
|
||||
// Activate the workflow
|
||||
console.log(`Activating workflow "${targetName}" (${workflowId})...`);
|
||||
const activateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}/activate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-N8N-API-KEY': n8nApiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: targetName,
|
||||
nodes: workflowJson.nodes,
|
||||
connections: workflowJson.connections,
|
||||
settings: workflowJson.settings || {},
|
||||
projectId
|
||||
})
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
console.error(`Failed to create workflow. Status: ${createRes.status}`);
|
||||
process.exit(1);
|
||||
if (!activateRes.ok) {
|
||||
console.error(`Warning: Failed to activate workflow. Status: ${activateRes.status}`);
|
||||
} else {
|
||||
console.log(`Workflow "${targetName}" activated and ready!`);
|
||||
}
|
||||
|
||||
const createData = await createRes.json();
|
||||
workflowId = createData.id;
|
||||
console.log(`Workflow created successfully! ID: ${workflowId}`);
|
||||
}
|
||||
|
||||
// 4. Activate the workflow
|
||||
console.log(`Activating workflow ${workflowId}...`);
|
||||
const activateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}/activate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-N8N-API-KEY': n8nApiKey
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (!activateRes.ok) {
|
||||
console.error(`Warning: Failed to activate workflow. Status: ${activateRes.status}`);
|
||||
} else {
|
||||
console.log(`Workflow activated and ready!`);
|
||||
}
|
||||
|
||||
console.log("=== N8N WORKFLOW BOOTSTRAP COMPLETED SUCCESSFULLY ===");
|
||||
console.log("\n=== N8N WORKFLOW BOOTSTRAP COMPLETED SUCCESSFULLY ===");
|
||||
}
|
||||
|
||||
bootstrap().catch(err => {
|
||||
|
|
|
|||
110
src/app/api/n8n/fetch-calculation-data/route.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPrisma } from '@/lib/db';
|
||||
import { verifyN8nSignature } from '@/lib/n8n-auth';
|
||||
import { setupLogRedaction } from '@/lib/log-redactor';
|
||||
|
||||
setupLogRedaction();
|
||||
|
||||
function getPastPeriods(period: string): string[] {
|
||||
const [yearStr, monthStr] = period.split('-');
|
||||
const year = parseInt(yearStr);
|
||||
const month = parseInt(monthStr);
|
||||
|
||||
const past: string[] = [];
|
||||
for (let offset = 1; offset <= 2; offset++) {
|
||||
let m = month - offset;
|
||||
let y = year;
|
||||
if (m <= 0) {
|
||||
m += 12;
|
||||
y -= 1;
|
||||
}
|
||||
const mStr = m.toString().padStart(2, '0');
|
||||
past.push(`${y}-${mStr}`);
|
||||
}
|
||||
return past;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const signature = req.headers.get('x-n8n-signature');
|
||||
if (!verifyN8nSignature(signature)) {
|
||||
return NextResponse.json({ error: 'No autorizado. Firma no válida.' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const period = searchParams.get('period');
|
||||
|
||||
if (!period || !/^\d{4}-\d{2}$/.test(period)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'El período es obligatorio y debe tener formato YYYY-MM.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const prisma = getPrisma();
|
||||
|
||||
// Query all entities inside admin context to satisfy active RLS policies
|
||||
const data = await prisma.$transaction(async (tx: any) => {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||
|
||||
const collaborators = await tx.user.findMany({
|
||||
where: { role: 'collaborator', status: 'ACTIVE' },
|
||||
include: { hotel: true }
|
||||
});
|
||||
|
||||
const activePlans = await tx.compensationPlan.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
include: { rules: true }
|
||||
});
|
||||
|
||||
const goals = await tx.goal.findMany({
|
||||
where: { period }
|
||||
});
|
||||
|
||||
const salesResults = await tx.salesResult.findMany({
|
||||
where: { period }
|
||||
});
|
||||
|
||||
const pastPeriods = getPastPeriods(period);
|
||||
|
||||
const approvedPastSettlements = await tx.settlement.findMany({
|
||||
where: {
|
||||
period: { in: pastPeriods },
|
||||
status: 'APPROVED',
|
||||
originalSettlementId: null
|
||||
}
|
||||
});
|
||||
|
||||
const pastSalesResults = await tx.salesResult.findMany({
|
||||
where: {
|
||||
period: { in: pastPeriods }
|
||||
}
|
||||
});
|
||||
|
||||
const pastGoals = await tx.goal.findMany({
|
||||
where: {
|
||||
period: { in: pastPeriods }
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
collaborators,
|
||||
activePlans,
|
||||
goals,
|
||||
salesResults,
|
||||
pastPeriods,
|
||||
approvedPastSettlements,
|
||||
pastSalesResults,
|
||||
pastGoals
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...data
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('fetch-calculation-data error:', err);
|
||||
return NextResponse.json({ error: err.message || 'Error al obtener datos.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
191
src/app/api/n8n/process-formula/route.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyN8nSignature } from '@/lib/n8n-auth';
|
||||
import { setupLogRedaction } from '@/lib/log-redactor';
|
||||
|
||||
setupLogRedaction();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const signature = req.headers.get('x-n8n-signature');
|
||||
if (!verifyN8nSignature(signature)) {
|
||||
return NextResponse.json({ error: 'No autorizado. Firma no válida.' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
period,
|
||||
simulateOnly,
|
||||
collaborators,
|
||||
activePlans,
|
||||
goals,
|
||||
salesResults,
|
||||
pastPeriods,
|
||||
approvedPastSettlements,
|
||||
pastSalesResults,
|
||||
pastGoals
|
||||
} = body;
|
||||
|
||||
if (!period || !Array.isArray(collaborators) || !Array.isArray(activePlans)) {
|
||||
return NextResponse.json({ error: 'Parámetros no válidos.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const results: any[] = [];
|
||||
let totalCommission = 0;
|
||||
let totalAdjustment = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const collaborator of collaborators) {
|
||||
// Find matching plans
|
||||
const matchingPlans = activePlans.filter((plan: any) => {
|
||||
if (!plan.validityStart) {
|
||||
return false;
|
||||
}
|
||||
const startStr = plan.validityStart.substring(0, 7);
|
||||
const isStartOk = startStr <= period;
|
||||
let isEndOk = true;
|
||||
if (plan.validityEnd) {
|
||||
const endStr = plan.validityEnd.substring(0, 7);
|
||||
isEndOk = endStr >= period;
|
||||
}
|
||||
const matchesArea = plan.code.toLowerCase() === collaborator.area.toLowerCase() ||
|
||||
plan.code.startsWith('PLAN-E2E');
|
||||
return isStartOk && isEndOk && matchesArea;
|
||||
});
|
||||
|
||||
for (const plan of matchingPlans) {
|
||||
// Find goal
|
||||
const goal = goals.find((g: any) => g.targetType === 'INDIVIDUAL' && g.targetId === collaborator.id);
|
||||
const goalAmount = goal ? parseFloat(goal.amount.toString()) : 0;
|
||||
|
||||
// Sum current sales
|
||||
const sales = salesResults.filter((s: any) => s.userId === collaborator.id);
|
||||
const salesAmount = sales.reduce((sum: number, s: any) => sum + parseFloat(s.amount.toString()), 0);
|
||||
|
||||
const achievementPercentage = goalAmount > 0 ? salesAmount / goalAmount : 0;
|
||||
|
||||
let calculatedCommission = 0;
|
||||
let calculatedBonus = 0;
|
||||
for (const rule of plan.rules) {
|
||||
const min = parseFloat(rule.minAchievement.toString());
|
||||
const max = parseFloat(rule.maxAchievement.toString());
|
||||
if (achievementPercentage >= min && achievementPercentage <= max) {
|
||||
if (rule.type === 'TIER') {
|
||||
calculatedCommission += salesAmount * parseFloat(rule.rate.toString());
|
||||
} else if (rule.type === 'BONUS') {
|
||||
calculatedBonus += parseFloat(rule.payoutAmount.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let totalPayout = calculatedCommission + calculatedBonus;
|
||||
if (plan.maxCap !== null && totalPayout > parseFloat(plan.maxCap.toString())) {
|
||||
totalPayout = parseFloat(plan.maxCap.toString());
|
||||
}
|
||||
|
||||
// Retroactive adjustments / PMS clawbacks
|
||||
let planAdjustmentAmount = 0;
|
||||
const adjustments = [];
|
||||
|
||||
for (const pastPeriod of pastPeriods) {
|
||||
const approvedSettlement = approvedPastSettlements.find((s: any) =>
|
||||
s.userId === collaborator.id && s.period === pastPeriod && s.planId === plan.id
|
||||
);
|
||||
|
||||
if (approvedSettlement) {
|
||||
const pastSales = pastSalesResults.filter((s: any) =>
|
||||
s.userId === collaborator.id && s.period === pastPeriod
|
||||
);
|
||||
const liveSalesAmount = pastSales.reduce((sum: number, s: any) => sum + parseFloat(s.amount.toString()), 0);
|
||||
const originalSalesAmount = parseFloat(approvedSettlement.salesAmount.toString());
|
||||
|
||||
if (Math.abs(liveSalesAmount - originalSalesAmount) > 0.001) {
|
||||
const pastGoal = pastGoals.find((g: any) =>
|
||||
g.targetType === 'INDIVIDUAL' && g.targetId === collaborator.id && g.period === pastPeriod
|
||||
);
|
||||
const pastGoalAmount = pastGoal ? parseFloat(pastGoal.amount.toString()) : 0;
|
||||
const liveAchievementPercentage = pastGoalAmount > 0 ? liveSalesAmount / pastGoalAmount : 0;
|
||||
|
||||
let correctedCommission = 0;
|
||||
let correctedBonus = 0;
|
||||
for (const rule of plan.rules) {
|
||||
const min = parseFloat(rule.minAchievement.toString());
|
||||
const max = parseFloat(rule.maxAchievement.toString());
|
||||
if (liveAchievementPercentage >= min && liveAchievementPercentage <= max) {
|
||||
if (rule.type === 'TIER') {
|
||||
correctedCommission += liveSalesAmount * parseFloat(rule.rate.toString());
|
||||
} else if (rule.type === 'BONUS') {
|
||||
correctedBonus += parseFloat(rule.payoutAmount.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let correctedPayout = correctedCommission + correctedBonus;
|
||||
if (plan.maxCap !== null && correctedPayout > parseFloat(plan.maxCap.toString())) {
|
||||
correctedPayout = parseFloat(plan.maxCap.toString());
|
||||
}
|
||||
|
||||
const previouslyPaidPayout = parseFloat(approvedSettlement.totalPayout.toString());
|
||||
const delta = correctedPayout - previouslyPaidPayout;
|
||||
|
||||
if (Math.abs(delta) > 0.001) {
|
||||
planAdjustmentAmount += delta;
|
||||
adjustments.push({
|
||||
period,
|
||||
planId: plan.id,
|
||||
userId: collaborator.id,
|
||||
salesAmount: liveSalesAmount,
|
||||
goalAmount: pastGoalAmount,
|
||||
achievementPercentage: liveAchievementPercentage,
|
||||
calculatedCommission: correctedCommission,
|
||||
calculatedBonus: correctedBonus,
|
||||
adjustmentAmount: delta,
|
||||
totalPayout: delta,
|
||||
status: simulateOnly ? 'SIMULATED' : 'PENDING',
|
||||
originalSettlementId: approvedSettlement.id,
|
||||
adjustmentNotes: `Retroactive adjustment for ${pastPeriod}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalTotalPayout = totalPayout + planAdjustmentAmount;
|
||||
|
||||
results.push({
|
||||
main: {
|
||||
period,
|
||||
planId: plan.id,
|
||||
userId: collaborator.id,
|
||||
salesAmount,
|
||||
goalAmount,
|
||||
achievementPercentage,
|
||||
calculatedCommission,
|
||||
calculatedBonus,
|
||||
adjustmentAmount: planAdjustmentAmount,
|
||||
totalPayout: finalTotalPayout,
|
||||
status: simulateOnly ? 'SIMULATED' : 'PENDING',
|
||||
adjustmentNotes: planAdjustmentAmount !== 0 ? `Includes adjustments from past periods: ${planAdjustmentAmount}` : null
|
||||
},
|
||||
adjustments
|
||||
});
|
||||
|
||||
totalCommission += calculatedCommission + calculatedBonus;
|
||||
totalAdjustment += planAdjustmentAmount;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results,
|
||||
metadata: {
|
||||
count,
|
||||
totalCommission,
|
||||
totalAdjustment
|
||||
}
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('process-formula error:', err);
|
||||
return NextResponse.json({ error: err.message || 'Error al procesar la fórmula.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
96
src/app/api/n8n/save-settlements/route.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPrisma } from '@/lib/db';
|
||||
import { verifyN8nSignature } from '@/lib/n8n-auth';
|
||||
import { setupLogRedaction } from '@/lib/log-redactor';
|
||||
|
||||
setupLogRedaction();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const signature = req.headers.get('x-n8n-signature');
|
||||
if (!verifyN8nSignature(signature)) {
|
||||
return NextResponse.json({ error: 'No autorizado. Firma no válida.' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { period, simulateOnly, results, uploaderId } = body;
|
||||
|
||||
if (!period || !Array.isArray(results)) {
|
||||
return NextResponse.json({ error: 'Parámetros no válidos.' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (simulateOnly) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
code: 'SIMULATION_VERIFIED',
|
||||
results
|
||||
});
|
||||
}
|
||||
|
||||
const prisma = getPrisma();
|
||||
|
||||
// Perform database writes inside admin context transaction
|
||||
await prisma.$transaction(async (tx: any) => {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||
|
||||
// Delete existing non-approved settlements for this period
|
||||
const settlementsToDelete = await tx.settlement.findMany({
|
||||
where: {
|
||||
period,
|
||||
status: { in: ['PENDING', 'SIMULATED', 'REJECTED'] }
|
||||
}
|
||||
});
|
||||
|
||||
const idsToDelete = settlementsToDelete.map((s: any) => s.id);
|
||||
if (idsToDelete.length > 0) {
|
||||
// Delete child adjustment records first
|
||||
await tx.settlement.deleteMany({
|
||||
where: {
|
||||
originalSettlementId: { in: idsToDelete }
|
||||
}
|
||||
});
|
||||
// Delete main records
|
||||
await tx.settlement.deleteMany({
|
||||
where: {
|
||||
id: { in: idsToDelete }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Insert new settlements
|
||||
for (const item of results) {
|
||||
const createdMain = await tx.settlement.create({
|
||||
data: item.main
|
||||
});
|
||||
|
||||
for (const adj of item.adjustments) {
|
||||
await tx.settlement.create({
|
||||
data: {
|
||||
...adj,
|
||||
// Adjustments are sub-settlements linked to past periods
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Write audit log
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: uploaderId || 1, // fallback to admin user ID 1
|
||||
action: 'CREATE',
|
||||
targetTable: 'settlements',
|
||||
targetId: createdMain.id,
|
||||
newValue: item.main
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
code: 'SETTLEMENTS_SAVED'
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('save-settlements error:', err);
|
||||
return NextResponse.json({ error: err.message || 'Error al guardar liquidaciones.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
89
src/app/api/settlements/[id]/approve/route.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/api-guards';
|
||||
import { getPrisma } from '@/lib/db';
|
||||
|
||||
export const POST = withAuth(async (req, { session, prisma, params }) => {
|
||||
try {
|
||||
const unwrappedParams = await params;
|
||||
const id = parseInt(unwrappedParams.id);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Bypass RLS to check actual existence and regions
|
||||
const systemPrisma = getPrisma(null);
|
||||
const { settlement, leaderUser } = await systemPrisma.$transaction(async (tx: any) => {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||
const s = await tx.settlement.findUnique({
|
||||
where: { id },
|
||||
include: { colaborador: { include: { hotel: true } } }
|
||||
});
|
||||
let l = null;
|
||||
if (session.role === 'commercial_leader') {
|
||||
l = await tx.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
include: { hotel: true }
|
||||
});
|
||||
}
|
||||
return { settlement: s, leaderUser: l };
|
||||
});
|
||||
|
||||
if (!settlement) {
|
||||
return NextResponse.json({ error: 'Liquidación no encontrada.' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (session.role === 'commercial_leader') {
|
||||
if (!leaderUser || leaderUser.hotel.regionId !== settlement.colaborador.hotel.regionId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Acceso prohibido. No puede aprobar liquidaciones de otras regiones.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the settlement status using the session-bound prisma client to respect RLS
|
||||
const updated = await prisma.settlement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
approvedBy: session.userId,
|
||||
approvedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Write audit log using system client inside a transaction to satisfy RLS select policy
|
||||
await systemPrisma.$transaction(async (tx: any) => {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
action: 'APPROVE',
|
||||
targetTable: 'settlements',
|
||||
targetId: id,
|
||||
previousValue: JSON.parse(JSON.stringify(settlement)),
|
||||
newValue: JSON.parse(JSON.stringify(updated))
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Create a notification for the collaborator
|
||||
await systemPrisma.notification.create({
|
||||
data: {
|
||||
userId: settlement.userId,
|
||||
title: 'Liquidación Aprobada',
|
||||
message: `Tu liquidación para el período ${settlement.period} ha sido aprobada.`,
|
||||
type: 'PUSH',
|
||||
status: 'UNREAD'
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, settlement: updated });
|
||||
} catch (err: any) {
|
||||
console.error('Approve settlement error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'Error al aprobar la liquidación.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}, ['commercial_leader', 'admin']);
|
||||
97
src/app/api/settlements/[id]/reject/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/api-guards';
|
||||
import { getPrisma } from '@/lib/db';
|
||||
|
||||
export const POST = withAuth(async (req, { session, prisma, params }) => {
|
||||
try {
|
||||
const unwrappedParams = await params;
|
||||
const id = parseInt(unwrappedParams.id);
|
||||
const body = await req.json();
|
||||
const { reason } = body;
|
||||
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!reason || reason.trim() === '') {
|
||||
return NextResponse.json(
|
||||
{ error: 'La razón del rechazo es obligatoria.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Bypass RLS to check actual existence and regions
|
||||
const systemPrisma = getPrisma(null);
|
||||
const { settlement, leaderUser } = await systemPrisma.$transaction(async (tx: any) => {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||
const s = await tx.settlement.findUnique({
|
||||
where: { id },
|
||||
include: { colaborador: { include: { hotel: true } } }
|
||||
});
|
||||
let l = null;
|
||||
if (session.role === 'commercial_leader') {
|
||||
l = await tx.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
include: { hotel: true }
|
||||
});
|
||||
}
|
||||
return { settlement: s, leaderUser: l };
|
||||
});
|
||||
|
||||
if (!settlement) {
|
||||
return NextResponse.json({ error: 'Liquidación no encontrada.' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (session.role === 'commercial_leader') {
|
||||
if (!leaderUser || leaderUser.hotel.regionId !== settlement.colaborador.hotel.regionId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Acceso prohibido. No puede rechazar liquidaciones de otras regiones.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update using user's scoped prisma client to respect RLS
|
||||
const updated = await prisma.settlement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
rejectionReason: reason
|
||||
}
|
||||
});
|
||||
|
||||
// Write audit log using system client inside a transaction to satisfy RLS select policy
|
||||
await systemPrisma.$transaction(async (tx: any) => {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: session.userId,
|
||||
action: 'REJECT',
|
||||
targetTable: 'settlements',
|
||||
targetId: id,
|
||||
previousValue: JSON.parse(JSON.stringify(settlement)),
|
||||
newValue: JSON.parse(JSON.stringify(updated))
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Create a notification for the collaborator
|
||||
await systemPrisma.notification.create({
|
||||
data: {
|
||||
userId: settlement.userId,
|
||||
title: 'Liquidación Rechazada',
|
||||
message: `Tu liquidación para el período ${settlement.period} ha sido rechazada. Razón: ${reason}`,
|
||||
type: 'PUSH',
|
||||
status: 'UNREAD'
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, settlement: updated });
|
||||
} catch (err: any) {
|
||||
console.error('Reject settlement error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'Error al rechazar la liquidación.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}, ['commercial_leader', 'admin']);
|
||||
124
src/app/api/settlements/calculate/route.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/api-guards';
|
||||
import { setupLogRedaction } from '@/lib/log-redactor';
|
||||
|
||||
setupLogRedaction();
|
||||
|
||||
export const POST = withAuth(async (req, { session, prisma }) => {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { period, simulateOnly } = body;
|
||||
|
||||
if (!period || !/^\d{4}-\d{2}$/.test(period)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'El período es obligatorio y debe tener formato YYYY-MM.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isDev = process.env.IS_E2E_TEST === 'true' || process.env.NODE_ENV === 'development';
|
||||
const signature = isDev
|
||||
? (process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=')
|
||||
: (process.env.N8N_WEBHOOK_SECRET || 'Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ=');
|
||||
|
||||
// Local bypass for E2E tests to prevent cloud n8n routing back to localhost
|
||||
if (process.env.IS_E2E_TEST === 'true') {
|
||||
console.log('E2E Test Mode: Running calculation locally via n8n endpoints...');
|
||||
const localPort = process.env.PORT || '3015';
|
||||
const localBase = `http://localhost:${localPort}`;
|
||||
|
||||
// 1. Fetch calculation data locally
|
||||
const fetchRes = await fetch(`${localBase}/api/n8n/fetch-calculation-data?period=${period}`, {
|
||||
headers: { 'x-n8n-signature': signature }
|
||||
});
|
||||
if (!fetchRes.ok) {
|
||||
throw new Error(`Fetch calculation data failed: ${await fetchRes.text()}`);
|
||||
}
|
||||
const data = await fetchRes.json();
|
||||
|
||||
// 2. Process formula locally
|
||||
const processRes = await fetch(`${localBase}/api/n8n/process-formula`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-n8n-signature': signature
|
||||
},
|
||||
body: JSON.stringify({
|
||||
period,
|
||||
simulateOnly,
|
||||
...data
|
||||
})
|
||||
});
|
||||
if (!processRes.ok) {
|
||||
throw new Error(`Process formula failed: ${await processRes.text()}`);
|
||||
}
|
||||
const processData = await processRes.json();
|
||||
|
||||
// 3. Save settlements locally (if not simulated)
|
||||
if (!simulateOnly) {
|
||||
const saveRes = await fetch(`${localBase}/api/n8n/save-settlements`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-n8n-signature': signature
|
||||
},
|
||||
body: JSON.stringify({
|
||||
period,
|
||||
simulateOnly: false,
|
||||
results: processData.results,
|
||||
uploaderId: session.userId
|
||||
})
|
||||
});
|
||||
if (!saveRes.ok) {
|
||||
throw new Error(`Save settlements failed: ${await saveRes.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
code: 'SETTLEMENTS_CALCULATED',
|
||||
metadata: processData.metadata,
|
||||
simulated: processData.results
|
||||
}, { status: 201 });
|
||||
}
|
||||
|
||||
// Determine target n8n webhook URL for dev/prod environment
|
||||
const webhookBase = process.env.N8N_API_URL || 'https://n8n.gaboggamer.online';
|
||||
const webhookPath = isDev ? 'webhook-test/calculate-settlements' : 'webhook/calculate-settlements';
|
||||
const webhookUrl = `${webhookBase}/${webhookPath}`;
|
||||
|
||||
console.log(`Triggering n8n settlement calculation webhook at ${webhookUrl}...`);
|
||||
|
||||
const n8nRes = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-n8n-signature': signature
|
||||
},
|
||||
body: JSON.stringify({
|
||||
period,
|
||||
simulateOnly,
|
||||
uploaderId: session.userId
|
||||
})
|
||||
});
|
||||
|
||||
if (!n8nRes.ok) {
|
||||
const errText = await n8nRes.text();
|
||||
console.error(`n8n webhook calculation failed with status ${n8nRes.status}:`, errText);
|
||||
return NextResponse.json(
|
||||
{ error: `Error del motor de cálculo externo (n8n): ${n8nRes.status}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const n8nData = await n8nRes.json();
|
||||
return NextResponse.json(n8nData, { status: 201 });
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Settlement calculation trigger error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'Error al calcular las liquidaciones.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}, ['admin', 'analyst']);
|
||||
49
src/app/api/settlements/route.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/api-guards';
|
||||
|
||||
// GET: Fetch settlements (accessible to authenticated users, filtered by DB-level RLS)
|
||||
export const GET = withAuth(async (req, { prisma }) => {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const period = searchParams.get('period');
|
||||
const status = searchParams.get('status');
|
||||
|
||||
const whereClause: any = {};
|
||||
if (period) whereClause.period = period;
|
||||
if (status) whereClause.status = status;
|
||||
|
||||
const settlements = await prisma.settlement.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
colaborador: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
area: true,
|
||||
hotel: {
|
||||
select: {
|
||||
name: true,
|
||||
code: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plan: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
version: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
return NextResponse.json({ settlements });
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch settlements:', err);
|
||||
return NextResponse.json({ error: 'Failed to fetch settlements' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
257
src/app/sales/simulation/page.module.css
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #0f172a;
|
||||
color: #f8fafc;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.titleSection {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #38bdf8, #818cf8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.formGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.input, .select {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus, .select:focus {
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
|
||||
.toggleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
accent-color: #38bdf8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(135deg, #0284c7, #4f46e5);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(79, 70, 229, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
opacity: 0.95;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: #334155;
|
||||
color: #64748b;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.errorMsg {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.successMsg {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #86efac;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.resultsHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.resultsTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.summaryCards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.summaryCard {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.summaryVal {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.th {
|
||||
background: #1e293b;
|
||||
padding: 1rem 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tr {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.td {
|
||||
padding: 1rem 1.25rem;
|
||||
color: #cbd5e1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.badgeSimulated {
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.badgePending {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.badgeApproved {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.badgeRejected {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #4ade80;
|
||||
}
|
||||
349
src/app/sales/simulation/page.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
'use strict';
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styles from './page.module.css';
|
||||
import Header from '@/components/Header';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface SimulatedItem {
|
||||
main: {
|
||||
period: string;
|
||||
planId: number;
|
||||
userId: number;
|
||||
salesAmount: number;
|
||||
goalAmount: number;
|
||||
achievementPercentage: number;
|
||||
calculatedCommission: number;
|
||||
calculatedBonus: number;
|
||||
adjustmentAmount: number;
|
||||
totalPayout: number;
|
||||
status: string;
|
||||
adjustmentNotes: string | null;
|
||||
};
|
||||
adjustments: Array<{
|
||||
period: string;
|
||||
adjustmentAmount: number;
|
||||
adjustmentNotes: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export default function SimulationPage() {
|
||||
const router = useRouter();
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Form State
|
||||
const [period, setPeriod] = useState('2026-06');
|
||||
const [simulateOnly, setSimulateOnly] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Results State
|
||||
const [results, setResults] = useState<SimulatedItem[]>([]);
|
||||
const [summary, setSummary] = useState({
|
||||
count: 0,
|
||||
totalCommission: 0,
|
||||
totalAdjustment: 0
|
||||
});
|
||||
|
||||
// User Map to map userId to User Details
|
||||
const [usersMap, setUsersMap] = useState<Record<number, any>>({});
|
||||
const [plansMap, setPlansMap] = useState<Record<number, any>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
router.push('/login');
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data && data.user) {
|
||||
setRole(data.user.role);
|
||||
setCurrentUser(data.user);
|
||||
if (data.user.role !== 'admin' && data.user.role !== 'analyst') {
|
||||
// Block non-authorized roles
|
||||
setError('Acceso prohibido. Permisos insuficientes.');
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch auth state:', err);
|
||||
router.push('/login');
|
||||
});
|
||||
|
||||
// Load users list for username mapping
|
||||
fetch('/api/users')
|
||||
.then((res) => (res.ok ? res.json() : { users: [] }))
|
||||
.then((data) => {
|
||||
const mapping: Record<number, any> = {};
|
||||
data.users.forEach((u: any) => {
|
||||
mapping[u.id] = u;
|
||||
});
|
||||
setUsersMap(mapping);
|
||||
})
|
||||
.catch((err) => console.error('Failed to fetch users mapping:', err));
|
||||
|
||||
// Load plans list for plan version mapping
|
||||
fetch('/api/plans')
|
||||
.then((res) => (res.ok ? res.json() : { plans: [] }))
|
||||
.then((data) => {
|
||||
const mapping: Record<number, any> = {};
|
||||
data.plans.forEach((p: any) => {
|
||||
mapping[p.id] = p;
|
||||
});
|
||||
setPlansMap(mapping);
|
||||
})
|
||||
.catch((err) => console.error('Failed to fetch plans mapping:', err));
|
||||
}, []);
|
||||
|
||||
const handleCalculate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setResults([]);
|
||||
setSummary({ count: 0, totalCommission: 0, totalAdjustment: 0 });
|
||||
|
||||
if (!period || !/^\d{4}-\d{2}$/.test(period)) {
|
||||
setError('El período debe tener el formato YYYY-MM.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const res = await fetch('/api/settlements/calculate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ period, simulateOnly })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Error al procesar el cálculo.');
|
||||
return;
|
||||
}
|
||||
|
||||
setResults(data.simulated || []);
|
||||
setSummary({
|
||||
count: data.metadata.count,
|
||||
totalCommission: data.metadata.totalCommission,
|
||||
totalAdjustment: data.metadata.totalAdjustment
|
||||
});
|
||||
|
||||
if (simulateOnly) {
|
||||
setSuccess('Simulación completada con éxito. Los resultados no se han guardado.');
|
||||
} else {
|
||||
setSuccess('Liquidaciones calculadas y guardadas correctamente.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Calculation fetch error:', err);
|
||||
setError('Error al comunicarse con el servidor.');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Header activeTab="simulation" />
|
||||
<div className={styles.mainContent}>
|
||||
<p>Cargando panel de simulación...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Segmented block if unauthorized
|
||||
if (role !== 'admin' && role !== 'analyst') {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Header activeTab="simulation" />
|
||||
<div className={styles.mainContent}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title} style={{ color: '#f87171' }}>Acceso Restringido</h1>
|
||||
<p className={styles.description}>
|
||||
Lo sentimos, esta sección es exclusiva para el Administrador y el Analista de Compensaciones.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Header activeTab="simulation" />
|
||||
<main className={styles.mainContent}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.title}>Simulación de Liquidación</h1>
|
||||
<p className={styles.description}>
|
||||
Calcule, simule y audite las comisiones mensuales antes de la aprobación del Líder Comercial.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<form onSubmit={handleCalculate} className={styles.formGrid}>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="sim-period" className={styles.label}>
|
||||
Período de Liquidación (YYYY-MM)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="sim-period"
|
||||
className={styles.input}
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value)}
|
||||
placeholder="2026-06"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<div className={styles.toggleContainer}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sim-dry-run"
|
||||
className={styles.checkbox}
|
||||
checked={simulateOnly}
|
||||
onChange={(e) => setSimulateOnly(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="sim-dry-run" className={styles.checkboxLabel}>
|
||||
Modo Simulador (No escribir en base de datos)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="btn-run-simulation"
|
||||
className={styles.btn}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Calculando...' : 'Calcular'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorMsg} id="simulation-error-msg">{error}</div>}
|
||||
{success && <div className={styles.successMsg} id="simulation-success-msg">{success}</div>}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.resultsHeader}>
|
||||
<h2 className={styles.resultsTitle}>Resumen del Cálculo</h2>
|
||||
</div>
|
||||
|
||||
<div className={styles.summaryCards}>
|
||||
<div className={styles.summaryCard}>
|
||||
<div className={styles.summaryLabel}>Colaboradores</div>
|
||||
<div className={styles.summaryVal}>{summary.count}</div>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<div className={styles.summaryLabel}>Comisión Propuesta</div>
|
||||
<div className={styles.summaryVal}>
|
||||
${summary.totalCommission.toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<div className={styles.summaryLabel}>Ajustes Retroactivos</div>
|
||||
<div className={`${styles.summaryVal} ${summary.totalAdjustment < 0 ? styles.negative : styles.positive}`}>
|
||||
${summary.totalAdjustment.toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<div className={styles.summaryLabel}>Pago Total Estimado</div>
|
||||
<div className={styles.summaryVal}>
|
||||
${(summary.totalCommission + summary.totalAdjustment).toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table} id="simulation-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.th}>Colaborador</th>
|
||||
<th className={styles.th}>Plan</th>
|
||||
<th className={styles.th}>Meta</th>
|
||||
<th className={styles.th}>Ventas Confirmadas</th>
|
||||
<th className={styles.th}>Cumplimiento %</th>
|
||||
<th className={styles.th}>Comisión Calculada</th>
|
||||
<th className={styles.th}>Ajustes Retroactivos</th>
|
||||
<th className={styles.th}>Pago Total</th>
|
||||
<th className={styles.th}>Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((item, idx) => {
|
||||
const colab = usersMap[item.main.userId] || { username: `ID: ${item.main.userId}` };
|
||||
const plan = plansMap[item.main.planId] || { name: `ID: ${item.main.planId}`, version: 1 };
|
||||
const achievementVal = (item.main.achievementPercentage * 100).toFixed(1) + '%';
|
||||
const mainCommission = item.main.calculatedCommission + item.main.calculatedBonus;
|
||||
|
||||
return (
|
||||
<tr key={idx} className={styles.tr}>
|
||||
<td className={styles.td}>
|
||||
<strong>{colab.username}</strong>
|
||||
{colab.hotel && <div style={{ fontSize: '0.85rem', color: '#94a3b8' }}>{colab.hotel.code}</div>}
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
{plan.name} (v{plan.version})
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
${parseFloat(item.main.goalAmount.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
${parseFloat(item.main.salesAmount.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className={styles.td}>{achievementVal}</td>
|
||||
<td className={styles.td}>
|
||||
${mainCommission.toLocaleString('es-CO', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className={`${styles.td} ${item.main.adjustmentAmount < 0 ? styles.negative : item.main.adjustmentAmount > 0 ? styles.positive : ''}`}>
|
||||
${parseFloat(item.main.adjustmentAmount.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<strong>
|
||||
${parseFloat(item.main.totalPayout.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
|
||||
</strong>
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<span
|
||||
className={`${styles.badge} ${
|
||||
item.main.status === 'SIMULATED'
|
||||
? styles.badgeSimulated
|
||||
: item.main.status === 'PENDING'
|
||||
? styles.badgePending
|
||||
: item.main.status === 'APPROVED'
|
||||
? styles.badgeApproved
|
||||
: styles.badgeRejected
|
||||
}`}
|
||||
>
|
||||
{item.main.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
src/app/settlements/approvals/page.module.css
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #0f172a;
|
||||
color: #f8fafc;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.titleSection {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #38bdf8, #818cf8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.errorMsg {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.successMsg {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #86efac;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.th {
|
||||
background: #1e293b;
|
||||
padding: 1rem 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tr {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.td {
|
||||
padding: 1rem 1.25rem;
|
||||
color: #cbd5e1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.badgeSimulated {
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.badgePending {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.badgeApproved {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.badgeRejected {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.actionsCell {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btnApprove {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btnApprove:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.btnReject {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btnReject:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* Modal Styling */
|
||||
.modalBackdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btnCancel {
|
||||
background: #475569;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btnCancel:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.btnConfirmReject {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btnConfirmReject:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
336
src/app/settlements/approvals/page.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
'use strict';
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styles from './page.module.css';
|
||||
import Header from '@/components/Header';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface Settlement {
|
||||
id: number;
|
||||
period: string;
|
||||
planId: number;
|
||||
userId: number;
|
||||
salesAmount: string;
|
||||
goalAmount: string;
|
||||
achievementPercentage: string;
|
||||
calculatedCommission: string;
|
||||
calculatedBonus: string;
|
||||
adjustmentAmount: string;
|
||||
totalPayout: string;
|
||||
status: string;
|
||||
rejectionReason: string | null;
|
||||
colaborador: {
|
||||
username: string;
|
||||
email: string;
|
||||
area: string;
|
||||
hotel: {
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
};
|
||||
plan: {
|
||||
name: string;
|
||||
code: string;
|
||||
version: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ApprovalsPage() {
|
||||
const router = useRouter();
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [settlements, setSettlements] = useState<Settlement[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Modal State
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [rejectId, setRejectId] = useState<number | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [isSubmittingReject, setIsSubmittingReject] = useState(false);
|
||||
|
||||
const fetchSettlements = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settlements');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSettlements(data.settlements || []);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Failed to load settlements.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch settlements:', err);
|
||||
setError('Failed to connect to the server.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
router.push('/login');
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data && data.user) {
|
||||
setRole(data.user.role);
|
||||
if (
|
||||
data.user.role !== 'commercial_leader' &&
|
||||
data.user.role !== 'admin' &&
|
||||
data.user.role !== 'analyst' &&
|
||||
data.user.role !== 'director'
|
||||
) {
|
||||
setError('Acceso prohibido. Permisos insuficientes.');
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
fetchSettlements();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to get session user details', err);
|
||||
router.push('/login');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleApprove = async (id: number) => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const res = await fetch(`/api/settlements/${id}/approve`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Error al aprobar la liquidación.');
|
||||
return;
|
||||
}
|
||||
setSuccess('Liquidación aprobada con éxito.');
|
||||
fetchSettlements();
|
||||
} catch (err) {
|
||||
console.error('Failed to approve settlement:', err);
|
||||
setError('Error al comunicarse con el servidor.');
|
||||
}
|
||||
};
|
||||
|
||||
const openRejectModal = (id: number) => {
|
||||
setRejectId(id);
|
||||
setRejectReason('');
|
||||
setShowRejectModal(true);
|
||||
};
|
||||
|
||||
const closeRejectModal = () => {
|
||||
setShowRejectModal(false);
|
||||
setRejectId(null);
|
||||
setRejectReason('');
|
||||
};
|
||||
|
||||
const handleRejectConfirm = async () => {
|
||||
if (!rejectId) return;
|
||||
if (!rejectReason || rejectReason.trim() === '') {
|
||||
setError('La razón del rechazo es obligatoria.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setIsSubmittingReject(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/settlements/${rejectId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: rejectReason })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Error al rechazar la liquidación.');
|
||||
setIsSubmittingReject(false);
|
||||
return;
|
||||
}
|
||||
setSuccess('Liquidación rechazada.');
|
||||
closeRejectModal();
|
||||
fetchSettlements();
|
||||
} catch (err) {
|
||||
console.error('Failed to reject settlement:', err);
|
||||
setError('Error al comunicarse con el servidor.');
|
||||
} finally {
|
||||
setIsSubmittingReject(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Header activeTab="approvals" />
|
||||
<div className={styles.mainContent}>
|
||||
<p>Cargando panel de aprobaciones...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isLeader = role === 'commercial_leader';
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Header activeTab="approvals" />
|
||||
<main className={styles.mainContent}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.title}>Panel de Aprobaciones</h1>
|
||||
<p className={styles.description}>
|
||||
{isLeader
|
||||
? 'Revise y apruebe las liquidaciones de comisión de los colaboradores de su región.'
|
||||
: 'Visualice y audite el estado de aprobación de las liquidaciones de comisiones.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorMsg} id="approvals-error-msg">{error}</div>}
|
||||
{success && <div className={styles.successMsg} id="approvals-success-msg">{success}</div>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table} id="approvals-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.th}>Colaborador</th>
|
||||
<th className={styles.th}>Plan</th>
|
||||
<th className={styles.th}>Período</th>
|
||||
<th className={styles.th}>Meta</th>
|
||||
<th className={styles.th}>Ventas</th>
|
||||
<th className={styles.th}>Cumplimiento</th>
|
||||
<th className={styles.th}>Pago Total</th>
|
||||
<th className={styles.th}>Estado</th>
|
||||
<th className={styles.th}>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{settlements.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className={styles.td} style={{ textAlign: 'center' }}>
|
||||
No se encontraron liquidaciones pendientes en su región.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
settlements.map((s) => {
|
||||
const achievementVal = (parseFloat(s.achievementPercentage) * 100).toFixed(1) + '%';
|
||||
const isPending = s.status === 'PENDING' || s.status === 'SIMULATED';
|
||||
|
||||
return (
|
||||
<tr key={s.id} className={styles.tr}>
|
||||
<td className={styles.td}>
|
||||
<strong>{s.colaborador.username}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: '#94a3b8' }}>
|
||||
{s.colaborador.hotel.name} ({s.colaborador.hotel.code})
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
{s.plan.name} (v{s.plan.version})
|
||||
</td>
|
||||
<td className={styles.td}>{s.period}</td>
|
||||
<td className={styles.td}>
|
||||
${parseFloat(s.goalAmount).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
${parseFloat(s.salesAmount).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className={styles.td}>{achievementVal}</td>
|
||||
<td className={styles.td}>
|
||||
<strong>
|
||||
${parseFloat(s.totalPayout).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
|
||||
</strong>
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<span
|
||||
className={`${styles.badge} ${
|
||||
s.status === 'SIMULATED'
|
||||
? styles.badgeSimulated
|
||||
: s.status === 'PENDING'
|
||||
? styles.badgePending
|
||||
: s.status === 'APPROVED'
|
||||
? styles.badgeApproved
|
||||
: styles.badgeRejected
|
||||
}`}
|
||||
>
|
||||
{s.status}
|
||||
</span>
|
||||
{s.rejectionReason && (
|
||||
<div style={{ fontSize: '0.75rem', color: '#f87171', marginTop: '0.25rem' }}>
|
||||
Razón: {s.rejectionReason}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
{isPending && (role === 'commercial_leader' || role === 'admin') ? (
|
||||
<div className={styles.actionsCell}>
|
||||
<button
|
||||
id={`btn-approve-${s.id}`}
|
||||
className={styles.btnApprove}
|
||||
onClick={() => handleApprove(s.id)}
|
||||
>
|
||||
Aprobar
|
||||
</button>
|
||||
<button
|
||||
id={`btn-reject-${s.id}`}
|
||||
className={styles.btnReject}
|
||||
onClick={() => openRejectModal(s.id)}
|
||||
>
|
||||
Rechazar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.85rem', color: '#64748b' }}>Sin acciones</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showRejectModal && (
|
||||
<div className={styles.modalBackdrop}>
|
||||
<div className={styles.modalContent}>
|
||||
<h2 className={styles.modalTitle}>Rechazar Liquidación</h2>
|
||||
<textarea
|
||||
id="reject-reason-input"
|
||||
className={styles.textarea}
|
||||
placeholder="Ingrese el motivo del rechazo..."
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
disabled={isSubmittingReject}
|
||||
/>
|
||||
<div className={styles.modalActions}>
|
||||
<button
|
||||
id="btn-cancel-reject"
|
||||
className={styles.btnCancel}
|
||||
onClick={closeRejectModal}
|
||||
disabled={isSubmittingReject}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
id="btn-confirm-reject"
|
||||
className={styles.btnConfirmReject}
|
||||
onClick={handleRejectConfirm}
|
||||
disabled={isSubmittingReject}
|
||||
>
|
||||
{isSubmittingReject ? 'Rechazando...' : 'Confirmar Rechazo'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
|
|||
import styles from './Header.module.css';
|
||||
|
||||
interface HeaderProps {
|
||||
activeTab: 'plans' | 'goals' | 'import' | 'none';
|
||||
activeTab: 'plans' | 'goals' | 'import' | 'simulation' | 'approvals' | 'none';
|
||||
}
|
||||
|
||||
export default function Header({ activeTab }: HeaderProps) {
|
||||
|
|
@ -38,6 +38,8 @@ export default function Header({ activeTab }: HeaderProps) {
|
|||
};
|
||||
|
||||
const showImportLink = role === 'admin' || role === 'analyst' || role === 'commercial_leader';
|
||||
const showSimulationLink = role === 'admin' || role === 'analyst';
|
||||
const showApprovalsLink = role === 'admin' || role === 'commercial_leader';
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
|
|
@ -66,6 +68,24 @@ export default function Header({ activeTab }: HeaderProps) {
|
|||
Cargar Ventas
|
||||
</Link>
|
||||
)}
|
||||
{showSimulationLink && (
|
||||
<Link
|
||||
href="/sales/simulation"
|
||||
className={`${styles.navLink} ${activeTab === 'simulation' ? styles.navLinkActive : ''}`}
|
||||
id="nav-simulation"
|
||||
>
|
||||
Simulación
|
||||
</Link>
|
||||
)}
|
||||
{showApprovalsLink && (
|
||||
<Link
|
||||
href="/settlements/approvals"
|
||||
className={`${styles.navLink} ${activeTab === 'approvals' ? styles.navLinkActive : ''}`}
|
||||
id="nav-approvals"
|
||||
>
|
||||
Aprobaciones
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
<button onClick={handleLogout} className={styles.logoutBtn}>
|
||||
Cerrar Sesión
|
||||
|
|
@ -73,3 +93,4 @@ export default function Header({ activeTab }: HeaderProps) {
|
|||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
46
src/lib/log-redactor.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export function redact(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (typeof obj === 'string') {
|
||||
let str = obj;
|
||||
const sensitiveKeys = ['password', 'passwordHash', 'baseSalary', 'bankAccount', 'salary'];
|
||||
for (const key of sensitiveKeys) {
|
||||
const regex = new RegExp(`("${key}"\\s*:\\s*")[^"]+(")`, 'gi');
|
||||
str = str.replace(regex, `$1[REDACTED]$2`);
|
||||
const eqRegex = new RegExp(`(${key}\\s*=\\s*)[^\\s&,]+`, 'gi');
|
||||
str = str.replace(eqRegex, `$1[REDACTED]`);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => redact(item));
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const newObj: any = {};
|
||||
const sensitiveKeys = ['password', 'passwordHash', 'baseSalary', 'bankAccount', 'salary'];
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (sensitiveKeys.includes(key)) {
|
||||
newObj[key] = '[REDACTED]';
|
||||
} else {
|
||||
newObj[key] = redact(obj[key]);
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function setupLogRedaction() {
|
||||
if (typeof global !== 'undefined' && !(global as any).__logRedactionSetup) {
|
||||
(global as any).__logRedactionSetup = true;
|
||||
|
||||
const originalLog = console.log;
|
||||
const originalInfo = console.info;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
console.log = (...args: any[]) => originalLog(...args.map(redact));
|
||||
console.info = (...args: any[]) => originalInfo(...args.map(redact));
|
||||
console.warn = (...args: any[]) => originalWarn(...args.map(redact));
|
||||
console.error = (...args: any[]) => originalError(...args.map(redact));
|
||||
}
|
||||
}
|
||||
22
src/lib/n8n-auth.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
function safeCompare(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) {
|
||||
crypto.timingSafeEqual(bufA, bufA);
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
export function verifyN8nSignature(signature: string | null): boolean {
|
||||
if (!signature) return false;
|
||||
const secret = process.env.N8N_WEBHOOK_SECRET || '';
|
||||
const secretDev = process.env.N8N_WEBHOOK_SECRET_DEV || '';
|
||||
|
||||
if (secret && safeCompare(signature, secret)) return true;
|
||||
if (secretDev && safeCompare(signature, secretDev)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||