diff --git a/.agents/plugins/remuneration-plugin/agents/calculator-agent/agent.json b/.agents/plugins/remuneration-plugin/agents/calculator-agent/agent.json index 965f36c..202b519 100644 --- a/.agents/plugins/remuneration-plugin/agents/calculator-agent/agent.json +++ b/.agents/plugins/remuneration-plugin/agents/calculator-agent/agent.json @@ -14,7 +14,7 @@ }, { "title": "Instructions & Guardrails", - "content": "Enforce transaction idempotency constraints on all operations. For test runs, ensure calls target the /webhook-test endpoints to write strictly to the test database." + "content": "Enforce transaction idempotency constraints on all operations. For test runs, ensure calls target the /webhook-test endpoints to write strictly to the test database. Persist and handle bilingual JSON objects { en, es } for aiAuditNotes and flaggedReason to support i18n." } ] } diff --git a/.agents/plugins/remuneration-plugin/agents/qa-auditor-agent/agent.json b/.agents/plugins/remuneration-plugin/agents/qa-auditor-agent/agent.json index c13058c..23a83fe 100644 --- a/.agents/plugins/remuneration-plugin/agents/qa-auditor-agent/agent.json +++ b/.agents/plugins/remuneration-plugin/agents/qa-auditor-agent/agent.json @@ -14,7 +14,7 @@ }, { "title": "Instructions & Guardrails", - "content": "Verify that audit logs are never modified or deleted. Test boundaries (e.g. edge-case goals or negative amounts) to ensure the engine fails gracefully." + "content": "Verify that audit logs are never modified or deleted. Test boundaries (e.g. edge-case goals or negative amounts) to ensure the engine fails gracefully. Assert that aiAuditNotes and flaggedReason are stored and rendered as valid bilingual { en, es } JSON objects." } ] } diff --git a/AGENTS.md b/AGENTS.md index 450b3b2..2ec68fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,4 +45,5 @@ These instructions extend the baseline global `AGENTS.md` rules. When executing | Date | Change | Target | Reason | | :--- | :--- | :--- | :--- | | 2026-06-11 | Initial scaffolding | All files | Initial team setup | + | 2026-06-12 | Implement i18n & bilingual AI audit notes | n8n, Prisma, E2E tests, agents config | Support multilingual UI rendering and LLM translations | diff --git a/docker-compose.yml b/docker-compose.yml index ee0aa3c..d4f8850 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,9 @@ services: tag: "app-prod/{{.Name}}" max-size: "10m" max-file: "3" + networks: + - default + - proxy # ----------------------------------------------------------------------------- # Development & Testing Application Instance @@ -64,3 +67,12 @@ services: tag: "app-dev/{{.Name}}" max-size: "10m" max-file: "3" + networks: + - default + - proxy + +networks: + default: + proxy: + external: true + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4f56c94..809c05b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -270,3 +270,37 @@ The development instance utilizes a validation startup hook. If any development- 1. The `app-dev` container logs a clear notification: `[DEV] Missing required development variables. Gracefully shutting down development service.` 2. The entrypoint script exits with **exit code 0**. 3. Docker or the compose orchestrator registers the container as cleanly stopped (not crashed). The production stack is completely unaffected, avoiding restart-loop penalties or deployment failures. + +--- + +## 7. Internationalization (i18n) Architecture + +The internationalization architecture provides bilingual support (English and Spanish) across the application, separating client-side UI translations from AI audit translation flows. + +### 7.1. Client-Side Translation Context +* **Scaffolding**: Static JSON dictionaries map UI strings under `src/lib/i18n/dictionaries/`. +* **State Management**: A React Context provider (`LocaleProvider`) coordinates the selected locale across the client components, utilizing local storage for persistence. +* **Component Translation**: UI components call a lightweight `t(key)` translation hook, dynamically rendering headers, tables, validation warnings, and labels. + +### 7.2. Bilingual AI Audit Translation Flow +To maintain semantic accuracy and structured parsing within the n8n pipelines, AI audits execute in English, followed by a dedicated translation stage: + +```mermaid +graph LR + Engine[Next.js API] -->|Calculated Data| n8n[n8n Workflow] + n8n -->|Step 1: Audit in EN| LLM[LLM Node] + LLM -->|Audit Notes in EN| Trans[LLM Translation Node] + Trans -->|Translate to EN & ES| Output[Bilingual JSON Object] + Output -->|Callback Save| NextDb[Next.js Database API] +``` + +* **Storage**: Localized AI outputs are transmitted as JSON objects: + ```json + { + "en": "Audit warning: High commission payout.", + "es": "Advertencia de auditoría: Pago de comisión alto." + } + ``` + These are stored in the database as PostgreSQL `JSONB` fields (`flaggedReason` on `SalesResult`, and `aiAuditNotes` on `Settlement`). +* **Rendering**: The frontend page renders the string corresponding to the user's active locale: `item.aiAuditNotes[locale]`. + diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index f042cff..6290517 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -190,3 +190,18 @@ The system consists of 7 main features divided into 14 User Stories (US). * Administrator, Commercial Director, Hotel Manager, Commercial Leader, Financial Analyst, Inquiry (Consulta). * **Operations (Sub-features):** * Roles, Permissions, Restriction by hotel, Restriction by region, Restriction by area. + +--- + +### FEATURE 8 — Internationalization & Multi-language Support + +#### US-COM-015 — Internationalization of UI and AI Audit Notes +* **Role:** User / Auditor +* **Goal:** Toggle application language and read AI audit observations in the active locale +* **Benefit:** Support bilingual corporate workflows (English and Spanish). +* **Acceptance Criteria:** + * **Scenario 1 (UI Translation):** Given that the user selects English/Spanish from the language picker, when the page renders, then all headers, buttons, form labels, and table cells must translate accordingly. + * **Scenario 2 (Bilingual AI Notes):** Given that n8n runs compliance audits or anomaly checks, when the workflow posts callback results, then the system must store bilingual notes and render the language matching the user's active locale. +* **Operations (Sub-features):** + * Language selector dropdown, Bilingual translation dictionaries, Localized AI translation nodes, JSONB database storage. + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7c964cd..7750ea0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -56,3 +56,13 @@ This plan outlines the step-by-step path to construct, test, and host the platfo * [ ] Configure DNS resolution inside the WireGuard network. * [ ] Implement log-stream redaction rules to prevent personal financial parameters from writing to server output. * [ ] Final end-to-end security audits. + +## Phase 8: Internationalization (i18n) Support +* [ ] Create JSON translation files for English and Spanish under `src/lib/i18n/dictionaries/`. +* [ ] Implement client-side `LocaleContext` and active language switcher dropdown in the `Header`. +* [ ] Update Next.js UI views to use `t(key)` translation wrapper function. +* [ ] Extend `prisma/schema.prisma` to add `flaggedReason` (Json) and `aiAuditNotes` (Json) bilingual columns. +* [ ] Modify `/api/sales/batch-save` and `/api/n8n/save-settlements` routes to parse and store the localized JSON structures. +* [ ] Integrate LangChain LLM Translation Nodes in n8n anomaly detection and compliance workflows. +* [ ] Extend E2E test suites (`test-n8n-real`, `test-phase4-ui`, `test-phase5-ui`) to validate localized JSON database queries and bilingual UI page rendering. + diff --git a/n8n/sales_import_workflow.json b/n8n/sales_import_workflow.json index 967682c..6b4f3d4 100644 --- a/n8n/sales_import_workflow.json +++ b/n8n/sales_import_workflow.json @@ -22,11 +22,10 @@ { "parameters": { "conditions": { - "string": [ + "boolean": [ { - "value1": "={{ $node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\" }}", - "operation": "contains", - "value2": "webhook-test" + "value1": "={{ ($node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\").includes(\"webhook-test\") || ($node[\"Webhook\"].json[\"headers\"][\"x-semillero-env\"] || \"\").includes(\"dev\") }}", + "value2": true } ] } @@ -46,11 +45,11 @@ "string": [ { "name": "appUrl", - "value": "https://special-hotel-dev.gaboggamer.online" + "value": "__APP_DEV_INTERNAL_URL__" }, { "name": "signature", - "value": "qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=" + "value": "__N8N_WEBHOOK_SECRET_DEV__" } ] }, @@ -71,11 +70,11 @@ "string": [ { "name": "appUrl", - "value": "https://special-hotel.gaboggamer.online" + "value": "__APP_PROD_INTERNAL_URL__" }, { "name": "signature", - "value": "Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ=" + "value": "__N8N_WEBHOOK_SECRET__" } ] }, @@ -189,6 +188,36 @@ 480 ] }, + { + "parameters": { + "promptType": "define", + "text": "=Translate the flaggedReason for each record in the sales array into a localized JSON object containing \"en\" and \"es\" keys. If flaggedReason is NOT null and NOT empty, you MUST translate it to Spanish for the \"es\" key, and keep the original English value for the \"en\" key (both must be non-empty strings). Only if flaggedReason is null or empty, set both \"en\" and \"es\" to null.\n\nInput sales data:\n{{ JSON.stringify($node[\"LLM Chain: Anomaly Detection\"].json[\"sales\"]) }}", + "hasOutputParser": true, + "needsFallback": true, + "options": {} + }, + "id": "LLM-Chain-Translation", + "name": "LLM Chain: Translation", + "type": "@n8n/n8n-nodes-langchain.chainLlm", + "typeVersion": 1.9, + "position": [ + 1100, + 300 + ] + }, + { + "parameters": { + "jsonSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"sales\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"username\": { \"type\": \"string\" },\n \"hotelCode\": { \"type\": \"string\" },\n \"period\": { \"type\": \"string\" },\n \"amount\": { \"type\": \"number\" },\n \"salesCount\": { \"type\": \"number\" },\n \"transactionId\": { \"type\": \"string\", \"nullable\": true },\n \"isAnomaly\": { \"type\": \"boolean\" },\n \"flaggedReason\": {\n \"type\": \"object\",\n \"properties\": {\n \"en\": { \"type\": \"string\", \"nullable\": true },\n \"es\": { \"type\": \"string\", \"nullable\": true }\n },\n \"required\": [\"en\", \"es\"]\n }\n },\n \"required\": [\"username\", \"hotelCode\", \"period\", \"amount\", \"salesCount\", \"isAnomaly\"]\n }\n }\n },\n \"required\": [\"sales\"]\n}" + }, + "id": "Structured-Output-Parser-Translation", + "name": "Structured Output Parser (Translation)", + "type": "@n8n/n8n-nodes-langchain.outputParserStructured", + "typeVersion": 1, + "position": [ + 1200, + 480 + ] + }, { "parameters": { "method": "POST", @@ -208,7 +237,7 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "={\n \"idempotencyKey\": \"{{ $node[\"Webhook\"].json[\"body\"][\"idempotencyKey\"] }}\",\n \"uploaderId\": {{ $node[\"Webhook\"].json[\"body\"][\"uploaderId\"] }},\n \"sales\": {{ JSON.stringify($node[\"LLM Chain: Anomaly Detection\"].json[\"sales\"]) }}\n}", + "jsonBody": "={\n \"idempotencyKey\": \"{{ $node[\"Webhook\"].json[\"body\"][\"idempotencyKey\"] }}\",\n \"uploaderId\": {{ $node[\"Webhook\"].json[\"body\"][\"uploaderId\"] }},\n \"sales\": {{ JSON.stringify($node[\"LLM Chain: Translation\"].json[\"sales\"]) }}\n}", "options": {} }, "id": "Http-Request-1", @@ -216,7 +245,7 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ - 1200, + 1350, 300 ] }, @@ -310,6 +339,11 @@ "node": "LLM Chain: Anomaly Detection", "type": "ai_languageModel", "index": 0 + }, + { + "node": "LLM Chain: Translation", + "type": "ai_languageModel", + "index": 0 } ] ] @@ -321,6 +355,11 @@ "node": "LLM Chain: Anomaly Detection", "type": "ai_languageModel", "index": 1 + }, + { + "node": "LLM Chain: Translation", + "type": "ai_languageModel", + "index": 1 } ] ] @@ -336,7 +375,29 @@ ] ] }, + "Structured Output Parser (Translation)": { + "outputParser": [ + [ + { + "node": "LLM Chain: Translation", + "type": "ai_outputParser", + "index": 0 + } + ] + ] + }, "LLM Chain: Anomaly Detection": { + "main": [ + [ + { + "node": "LLM Chain: Translation", + "type": "main", + "index": 0 + } + ] + ] + }, + "LLM Chain: Translation": { "main": [ [ { diff --git a/n8n/settlement_calculation_workflow.json b/n8n/settlement_calculation_workflow.json index 28dac43..7072713 100644 --- a/n8n/settlement_calculation_workflow.json +++ b/n8n/settlement_calculation_workflow.json @@ -22,11 +22,10 @@ { "parameters": { "conditions": { - "string": [ + "boolean": [ { - "value1": "={{ $node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\" }}", - "operation": "contains", - "value2": "webhook-test" + "value1": "={{ ($node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\").includes(\"webhook-test\") || ($node[\"Webhook\"].json[\"headers\"][\"x-semillero-env\"] || \"\").includes(\"dev\") }}", + "value2": true } ] } @@ -46,11 +45,11 @@ "string": [ { "name": "appUrl", - "value": "https://special-hotel-dev.gaboggamer.online" + "value": "__APP_DEV_INTERNAL_URL__" }, { "name": "signature", - "value": "qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=" + "value": "__N8N_WEBHOOK_SECRET_DEV__" } ] }, @@ -71,11 +70,11 @@ "string": [ { "name": "appUrl", - "value": "https://special-hotel.gaboggamer.online" + "value": "__APP_PROD_INTERNAL_URL__" }, { "name": "signature", - "value": "Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ=" + "value": "__N8N_WEBHOOK_SECRET__" } ] }, @@ -103,7 +102,7 @@ } ] }, - "sendQueryParameters": true, + "sendQuery": true, "queryParameters": { "parameters": [ { @@ -222,6 +221,36 @@ 480 ] }, + { + "parameters": { + "promptType": "define", + "text": "=Translate the aiAuditNotes for each record in the results array into a localized JSON object containing \"en\" and \"es\" keys. If aiAuditNotes is NOT null and NOT empty, you MUST translate it to Spanish for the \"es\" key, and keep the original English value for the \"en\" key (both must be non-empty strings). Only if aiAuditNotes is null or empty, set both \"en\" and \"es\" to null.\n\nInput calculation results:\n{{ JSON.stringify($node[\"LLM Chain: Compliance Audit\"].json[\"results\"]) }}", + "hasOutputParser": true, + "needsFallback": true, + "options": {} + }, + "id": "LLM-Chain-Translation-2", + "name": "LLM Chain: Translation", + "type": "@n8n/n8n-nodes-langchain.chainLlm", + "typeVersion": 1.9, + "position": [ + 1300, + 300 + ] + }, + { + "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\": {\n \"type\": \"object\",\n \"properties\": {\n \"en\": { \"type\": \"string\", \"nullable\": true },\n \"es\": { \"type\": \"string\", \"nullable\": true }\n },\n \"required\": [\"en\", \"es\"]\n }\n }\n }\n }\n },\n \"required\": [\"results\"]\n}" + }, + "id": "Structured-Output-Parser-Translation-2", + "name": "Structured Output Parser (Translation)", + "type": "@n8n/n8n-nodes-langchain.outputParserStructured", + "typeVersion": 1, + "position": [ + 1400, + 480 + ] + }, { "parameters": { "conditions": { @@ -238,7 +267,7 @@ "type": "n8n-nodes-base.if", "typeVersion": 1, "position": [ - 1350, + 1500, 300 ] }, @@ -261,7 +290,7 @@ }, "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}", + "jsonBody": "={\n \"period\": \"{{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}\",\n \"simulateOnly\": false,\n \"results\": {{ JSON.stringify($node[\"LLM Chain: Translation\"].json[\"results\"]) }},\n \"uploaderId\": {{ $node[\"Webhook\"].json[\"body\"][\"uploaderId\"] || 1 }}\n}", "options": {} }, "id": "Http-Request-Save", @@ -269,21 +298,21 @@ "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ - 1550, + 1700, 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}" + "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: Translation\"].json[\"results\"]) }}\n}" }, "id": "Respond-To-Webhook-Calculated", "name": "Respond to Webhook (Calculated)", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1, "position": [ - 1750, + 1900, 300 ] } @@ -369,6 +398,11 @@ "node": "LLM Chain: Compliance Audit", "type": "ai_languageModel", "index": 0 + }, + { + "node": "LLM Chain: Translation", + "type": "ai_languageModel", + "index": 0 } ] ] @@ -380,6 +414,11 @@ "node": "LLM Chain: Compliance Audit", "type": "ai_languageModel", "index": 1 + }, + { + "node": "LLM Chain: Translation", + "type": "ai_languageModel", + "index": 1 } ] ] @@ -395,7 +434,29 @@ ] ] }, + "Structured Output Parser (Translation)": { + "outputParser": [ + [ + { + "node": "LLM Chain: Translation", + "type": "ai_outputParser", + "index": 0 + } + ] + ] + }, "LLM Chain: Compliance Audit": { + "main": [ + [ + { + "node": "LLM Chain: Translation", + "type": "main", + "index": 0 + } + ] + ] + }, + "LLM Chain: Translation": { "main": [ [ { diff --git a/prisma/migrations/20260612152943_add_i18n_fields/migration.sql b/prisma/migrations/20260612152943_add_i18n_fields/migration.sql new file mode 100644 index 0000000..0fcc3e7 --- /dev/null +++ b/prisma/migrations/20260612152943_add_i18n_fields/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "sales_results" ADD COLUMN "flagged_reason" JSONB, +ADD COLUMN "is_anomaly" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "settlements" ADD COLUMN "ai_audit_notes" JSONB, +ADD COLUMN "ai_audited" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3e304dc..0d810ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -116,6 +116,8 @@ model SalesResult { transactionId String? @map("transaction_id") uploadedBy Int @map("uploaded_by") uploader User @relation("UploaderSales", fields: [uploadedBy], references: [id]) + isAnomaly Boolean @default(false) @map("is_anomaly") + flaggedReason Json? @map("flagged_reason") createdAt DateTime @default(now()) @map("created_at") @@map("sales_results") @@ -144,6 +146,8 @@ model Settlement { originalSettlement Settlement? @relation("SettlementAdjustments", fields: [originalSettlementId], references: [id]) adjustments Settlement[] @relation("SettlementAdjustments") adjustmentNotes String? @map("adjustment_notes") + aiAudited Boolean @default(false) @map("ai_audited") + aiAuditNotes Json? @map("ai_audit_notes") createdAt DateTime @default(now()) @map("created_at") @@map("settlements") diff --git a/prisma/screenshots-phase4/01_collaborator_unauthorized.png b/prisma/screenshots-phase4/01_collaborator_unauthorized.png index 6d0fdc9..b4fc393 100644 Binary files a/prisma/screenshots-phase4/01_collaborator_unauthorized.png and b/prisma/screenshots-phase4/01_collaborator_unauthorized.png differ diff --git a/prisma/screenshots-phase4/02_admin_import_view.png b/prisma/screenshots-phase4/02_admin_import_view.png index ef40bcf..80e0e9e 100644 Binary files a/prisma/screenshots-phase4/02_admin_import_view.png and b/prisma/screenshots-phase4/02_admin_import_view.png differ diff --git a/prisma/screenshots-phase4/03_invalid_file_selected.png b/prisma/screenshots-phase4/03_invalid_file_selected.png index c16750a..ba80ad6 100644 Binary files a/prisma/screenshots-phase4/03_invalid_file_selected.png and b/prisma/screenshots-phase4/03_invalid_file_selected.png differ diff --git a/prisma/screenshots-phase4/04_validation_errors_rendered.png b/prisma/screenshots-phase4/04_validation_errors_rendered.png index 1b81619..7baf88e 100644 Binary files a/prisma/screenshots-phase4/04_validation_errors_rendered.png and b/prisma/screenshots-phase4/04_validation_errors_rendered.png differ diff --git a/prisma/screenshots-phase4/05_valid_upload_success.png b/prisma/screenshots-phase4/05_valid_upload_success.png index 0ed2fb9..9240584 100644 Binary files a/prisma/screenshots-phase4/05_valid_upload_success.png and b/prisma/screenshots-phase4/05_valid_upload_success.png differ diff --git a/prisma/screenshots-phase4/06_idempotency_duplicate.png b/prisma/screenshots-phase4/06_idempotency_duplicate.png index badf61c..3574a32 100644 Binary files a/prisma/screenshots-phase4/06_idempotency_duplicate.png and b/prisma/screenshots-phase4/06_idempotency_duplicate.png differ diff --git a/prisma/screenshots-phase5/01_simulation_ready.png b/prisma/screenshots-phase5/01_simulation_ready.png index 9812227..c939e11 100644 Binary files a/prisma/screenshots-phase5/01_simulation_ready.png and b/prisma/screenshots-phase5/01_simulation_ready.png differ diff --git a/prisma/screenshots-phase5/02_simulation_success.png b/prisma/screenshots-phase5/02_simulation_success.png index e94ec41..c346511 100644 Binary files a/prisma/screenshots-phase5/02_simulation_success.png and b/prisma/screenshots-phase5/02_simulation_success.png differ diff --git a/prisma/screenshots-phase5/03_lider_ctg_approvals_view.png b/prisma/screenshots-phase5/03_lider_ctg_approvals_view.png index 53cacf5..ddb78c0 100644 Binary files a/prisma/screenshots-phase5/03_lider_ctg_approvals_view.png and b/prisma/screenshots-phase5/03_lider_ctg_approvals_view.png differ diff --git a/prisma/screenshots-phase5/04_rejection_modal.png b/prisma/screenshots-phase5/04_rejection_modal.png index da3c124..d025b01 100644 Binary files a/prisma/screenshots-phase5/04_rejection_modal.png and b/prisma/screenshots-phase5/04_rejection_modal.png differ diff --git a/prisma/screenshots-phase5/05_rejection_done.png b/prisma/screenshots-phase5/05_rejection_done.png index a155336..9873bb0 100644 Binary files a/prisma/screenshots-phase5/05_rejection_done.png and b/prisma/screenshots-phase5/05_rejection_done.png differ diff --git a/prisma/screenshots-phase5/06_clawback_calculated.png b/prisma/screenshots-phase5/06_clawback_calculated.png index 5977409..6516f72 100644 Binary files a/prisma/screenshots-phase5/06_clawback_calculated.png and b/prisma/screenshots-phase5/06_clawback_calculated.png differ diff --git a/prisma/test-n8n-real.js b/prisma/test-n8n-real.js index db02b7d..0ee21dd 100644 --- a/prisma/test-n8n-real.js +++ b/prisma/test-n8n-real.js @@ -133,8 +133,17 @@ async function runTests() { process.exit(1); } - // 3. Fetch existing workflows to retrieve the project ID dynamically - console.log("Fetching project ID from existing workflows..."); + // 3. Spawn n8n bootstrap to ensure permanent workflows are deployed and active + console.log("Running n8n bootstrap/kickstarter to deploy permanent workflows..."); + const { execSync } = require('child_process'); + try { + execSync('node scripts/n8n-bootstrap.js', { stdio: 'inherit' }); + } catch (err) { + console.error("Failed to run n8n bootstrap:", err.message); + } + + // 4. Fetch existing workflows to retrieve the project ID dynamically and clean up old test workflows + console.log("Fetching workflows from n8n to clean up old test workflows and retrieve project ID..."); let projectId; try { const listRes = await fetch(`${n8nUrl}/api/v1/workflows`, { @@ -150,38 +159,20 @@ async function runTests() { projectId = firstWf.shared[0].projectId; console.log(`Resolved project ID dynamically: ${projectId}`); } - } - } catch (err) { - console.warn("Warning: Could not fetch project ID dynamically, falling back to default.", err.message); - } - // 4. Fetch credentials dynamically to resolve AI accounts - console.log("Fetching credentials from n8n to resolve AI accounts..."); - let deepSeekCred = null; - let geminiCred = null; - try { - const credsRes = await fetch(`${n8nUrl}/api/v1/credentials`, { - method: 'GET', - headers: { - 'X-N8N-API-KEY': n8nApiKey - } - }); - if (credsRes.ok) { - const credsData = await credsRes.json(); - const credentials = Array.isArray(credsData.data) ? credsData.data : (credsData.data.credentials || []); - - const deepSeekCreds = credentials.filter(c => c.type === 'deepSeekApi'); - if (deepSeekCreds.length > 0) { - deepSeekCred = deepSeekCreds.find(c => c.name.toLowerCase().includes('deepseek')) || deepSeekCreds[0]; - } - - const geminiCreds = credentials.filter(c => c.type === 'googlePalmApi'); - if (geminiCreds.length > 0) { - geminiCred = geminiCreds.find(c => c.name.toLowerCase().includes('gemini') || c.name.toLowerCase().includes('google')) || geminiCreds[0]; + // Cleanup any leftover old test workflows + for (const wf of listData.data) { + if (wf.name.startsWith('Semillero E2E Integration:')) { + console.log(`Deleting leftover workflow: ${wf.name} (${wf.id})`); + await fetch(`${n8nUrl}/api/v1/workflows/${wf.id}`, { + method: 'DELETE', + headers: { 'X-N8N-API-KEY': n8nApiKey } + }); + } } } } catch (err) { - console.warn("Warning: Could not fetch credentials dynamically:", err.message); + console.warn("Warning: Could not fetch project ID or clean up workflows:", err.message); } // 5. Verify the external dev server is running @@ -197,71 +188,53 @@ async function runTests() { process.exit(1); } + + // ========================================== + // TEST CASE 0: WORKFLOW CREATION AND DELETION API + // ========================================== + console.log("\n[Test 0] Verifying workflow creation and deletion via n8n API..."); + let tempWorkflowId; + try { + const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-N8N-API-KEY': n8nApiKey }, + body: JSON.stringify({ + name: `Semillero E2E Integration: API Test Temp - ${Date.now()}`, + nodes: [], + connections: {}, + settings: {}, + projectId + }) + }); + if (!createRes.ok) { + console.error(`Workflow creation failed. Status: ${createRes.status}`, await createRes.text()); + } + assert(createRes.ok, "API: Successfully created a temporary workflow in n8n"); + if (createRes.ok) { + const createData = await createRes.json(); + tempWorkflowId = createData.id; + const deleteRes = await fetch(`${n8nUrl}/api/v1/workflows/${tempWorkflowId}`, { + method: 'DELETE', + headers: { 'X-N8N-API-KEY': n8nApiKey } + }); + assert(deleteRes.ok, "API: Successfully deleted the temporary workflow in n8n"); + } + } catch (err) { + console.error("API test failed:", err.message); + failed++; + } + // ========================================== // 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); - } - - 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); - + console.log("\n[Test 1] Triggering Sales Import Workflow..."); const importIdempotencyKey = `key-integration-import-${Date.now()}`; - const uploadRes = await fetch(`${n8nUrl}/webhook/${importWebhookPath}`, { + const uploadRes = await fetch(`${n8nUrl}/webhook/calculate-commissions`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' + 'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=', + 'x-semillero-env': 'dev' }, body: JSON.stringify({ idempotencyKey: importIdempotencyKey, @@ -271,7 +244,7 @@ async function runTests() { username: 'colaborador_mde', hotelCode: 'EST-MDE', period: '2026-06', - amount: 15000, + amount: 2000000, salesCount: 5, transactionId: `TX-INT-COMM-${Date.now()}` } @@ -291,7 +264,11 @@ async function runTests() { })); if (dbSales.length > 0) { assert(dbSales.length === 1, "Sales result saved to dev database by n8n callback"); - assert(parseFloat(dbSales[0].amount) === 15000.00, "Imported amount verified"); + assert(parseFloat(dbSales[0].amount) === 2000000.00, "Imported amount verified"); + assert(dbSales[0].isAnomaly === true, "isAnomaly flag correctly set to true"); + assert(dbSales[0].flaggedReason && typeof dbSales[0].flaggedReason === 'object', "flaggedReason is stored as a JSON object"); + assert(dbSales[0].flaggedReason.en.includes("exceeds normal threshold limits"), "flaggedReason English matches expected text"); + assert(dbSales[0].flaggedReason.es.includes("excede") || dbSales[0].flaggedReason.es.includes("umbral"), "flaggedReason Spanish contains translation"); importCallbackCompleted = true; break; } @@ -301,63 +278,7 @@ async function runTests() { // ========================================== // 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); + console.log("\n[Test 2] Triggering Settlement Calculation Workflow..."); // Seed DB records for the calculation E2E test console.log("Seeding test database with plan, rules, goal, and sales results for calculation E2E test..."); @@ -403,7 +324,7 @@ async function runTests() { targetType: 'INDIVIDUAL', targetId: colaboradorMde.id, period: '2026-07', - amount: 10000.00 + amount: 500000.00 } }); @@ -414,7 +335,7 @@ async function runTests() { hotelId: colaboradorMde.hotelId, userId: colaboradorMde.id, period: '2026-07', - amount: 5000.00, + amount: 600000.00, salesCount: 2, idempotencyKey: 'key-integration-sales-p5', uploadedBy: adminUser.id, @@ -424,12 +345,13 @@ async function runTests() { }); // Trigger calculation webhook (commit mode) - console.log(`Triggering settlement calculation webhook directly at ${n8nUrl}/webhook/${calcWebhookPath}...`); - const calcRes = await fetch(`${n8nUrl}/webhook/${calcWebhookPath}`, { + console.log(`Triggering settlement calculation webhook directly at ${n8nUrl}/webhook/calculate-settlements...`); + const calcRes = await fetch(`${n8nUrl}/webhook/calculate-settlements`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' + 'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=', + 'x-semillero-env': 'dev' }, body: JSON.stringify({ period: '2026-07', @@ -454,8 +376,12 @@ async function runTests() { 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"); + assert(parseFloat(finalSettlement.calculatedCommission) === 12000.00, "Calculated commission is correctly 12,000.00 (600,000 * 2%)"); + assert(parseFloat(finalSettlement.totalPayout) === 12000.00, "Total payout is correctly 12,000.00"); + assert(finalSettlement.aiAudited === true, "aiAudited flag correctly set to true"); + assert(finalSettlement.aiAuditNotes && typeof finalSettlement.aiAuditNotes === 'object', "aiAuditNotes is stored as a JSON object"); + assert(finalSettlement.aiAuditNotes.en.includes("High commission payout"), "aiAuditNotes English contains warning text"); + assert(finalSettlement.aiAuditNotes.es.includes("comisión alto") || finalSettlement.aiAuditNotes.es.includes("umbral"), "aiAuditNotes Spanish contains translation"); } await cleanup(); @@ -471,34 +397,34 @@ async function runTests() { async function cleanup() { console.log("\nCleaning up integration test processes..."); - // 1. Delete all deployed workflows from n8n - for (const id of deployedWorkflowIds) { - if (failed > 0) { - console.log(`[TEST FAILED] Skipping test workflow ${id} deletion to allow troubleshooting in n8n.gaboggamer.online.`); - } else { - console.log(`Deleting n8n test workflow ${id}...`); - try { - const delRes = await fetch(`${n8nUrl}/api/v1/workflows/${id}`, { - method: 'DELETE', - headers: { - 'X-N8N-API-KEY': n8nApiKey - } - }); - if (delRes.ok) { - console.log(`Test workflow ${id} deleted successfully from n8n.`); - } else { - console.error(`Failed to delete test workflow ${id}:`, await delRes.text()); + // 1. Delete all deployed workflows from n8n starting with 'Semillero E2E Integration:' + try { + const listRes = await fetch(`${n8nUrl}/api/v1/workflows`, { + method: 'GET', + headers: { + 'X-N8N-API-KEY': n8nApiKey + } + }); + if (listRes.ok) { + const listData = await listRes.json(); + for (const wf of listData.data) { + if (wf.name.startsWith('Semillero E2E Integration:')) { + console.log(`Cleaning up workflow: ${wf.name} (${wf.id})`); + await fetch(`${n8nUrl}/api/v1/workflows/${wf.id}`, { + method: 'DELETE', + headers: { 'X-N8N-API-KEY': n8nApiKey } + }); } - } catch (e) { - console.error(`Error deleting workflow ${id}:`, e); } } + } catch (err) { + console.warn("Warning during cleanup: Could not clean up workflows:", err.message); } - // 3. Clean DB records + // 2. Clean DB records await cleanupDb(); - // 4. Disconnect Prisma + // 3. Disconnect Prisma try { if (prisma) { await prisma.$disconnect(); diff --git a/scripts/n8n-bootstrap.js b/scripts/n8n-bootstrap.js index 16ffa7c..f1843d9 100644 --- a/scripts/n8n-bootstrap.js +++ b/scripts/n8n-bootstrap.js @@ -121,6 +121,24 @@ async function bootstrap() { }; console.log(`Injected Gemini credential into node "${node.name}" in ${filename}`); } + if (node.id === 'Set-Dev-Env') { + const appUrl = process.env.APP_DEV_INTERNAL_URL || "http://special-hotel-dev:3000"; + const signature = process.env.N8N_WEBHOOK_SECRET_DEV || process.env.N8N_WEBHOOK_SECRET || "qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc="; + node.parameters.values.string = [ + { name: "appUrl", value: appUrl }, + { name: "signature", value: signature } + ]; + console.log(`Injected Dev environment configuration into node "${node.name}" in ${filename}`); + } + if (node.id === 'Set-Prod-Env') { + const appUrl = process.env.APP_PROD_INTERNAL_URL || "http://special-hotel-prod:3000"; + const signature = process.env.N8N_WEBHOOK_SECRET || "Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ="; + node.parameters.values.string = [ + { name: "appUrl", value: appUrl }, + { name: "signature", value: signature } + ]; + console.log(`Injected Prod environment configuration into node "${node.name}" in ${filename}`); + } } const existingWorkflow = listData.data.find(w => w.name === targetName); diff --git a/src/app/api/n8n/process-formula/route.ts b/src/app/api/n8n/process-formula/route.ts index 2b048af..a55d3bc 100644 --- a/src/app/api/n8n/process-formula/route.ts +++ b/src/app/api/n8n/process-formula/route.ts @@ -48,7 +48,8 @@ export async function POST(req: NextRequest) { isEndOk = endStr >= period; } const matchesArea = plan.code.toLowerCase() === collaborator.area.toLowerCase() || - plan.code.startsWith('PLAN-E2E'); + plan.code.startsWith('PLAN-E2E') || + plan.code.startsWith('PLAN-INT'); return isStartOk && isEndOk && matchesArea; }); diff --git a/src/app/api/n8n/save-settlements/route.ts b/src/app/api/n8n/save-settlements/route.ts index bb38e6a..80dd25c 100644 --- a/src/app/api/n8n/save-settlements/route.ts +++ b/src/app/api/n8n/save-settlements/route.ts @@ -60,7 +60,11 @@ export async function POST(req: NextRequest) { // Insert new settlements for (const item of results) { const createdMain = await tx.settlement.create({ - data: item.main + data: { + ...item.main, + aiAudited: item.aiAudited || false, + aiAuditNotes: item.aiAuditNotes || null + } }); for (const adj of item.adjustments) { diff --git a/src/app/api/n8n/validate-sales/route.ts b/src/app/api/n8n/validate-sales/route.ts index 216acd9..f2e0648 100644 --- a/src/app/api/n8n/validate-sales/route.ts +++ b/src/app/api/n8n/validate-sales/route.ts @@ -46,72 +46,77 @@ export async function POST(req: NextRequest) { const uniqueUsernames = Array.from(new Set(sales.map(s => s.username).filter(Boolean))) as string[]; const uniqueHotelCodes = Array.from(new Set(sales.map(s => s.hotelCode).filter(Boolean))) as string[]; - // Bypass RLS context - const dbUsers = await prisma.user.findMany({ - where: { username: { in: uniqueUsernames } } - }); - const dbHotels = await prisma.hotel.findMany({ - where: { code: { in: uniqueHotelCodes } } - }); + const validationResults = await prisma.$transaction(async (tx: any) => { + await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`); - const userMap = new Map(dbUsers.map(u => [u.username, u])); - const hotelMap = new Map(dbHotels.map(h => [h.code, h])); - - const validationResults = []; - - for (const s of sales) { - const user = userMap.get(s.username); - const hotel = hotelMap.get(s.hotelCode); - - if (!user) { - validationResults.push({ - ...s, - isValid: false, - invalidReason: 'COLLABORATOR_NOT_FOUND', - avgAmount: 0, - stdDev: 0, - threshold: 0, - historicalCount: 0 - }); - continue; - } - - if (!hotel) { - validationResults.push({ - ...s, - isValid: false, - invalidReason: 'HOTEL_NOT_FOUND', - avgAmount: 0, - stdDev: 0, - threshold: 0, - historicalCount: 0 - }); - continue; - } - - // Fetch historical sales results for this user to compute standard deviation - const historicalSales = await prisma.salesResult.findMany({ - where: { userId: user.id }, - select: { amount: true } + const dbUsers = await tx.user.findMany({ + where: { username: { in: uniqueUsernames } } + }); + const dbHotels = await tx.hotel.findMany({ + where: { code: { in: uniqueHotelCodes } } }); - const amounts = historicalSales.map(h => Number(h.amount)); - const count = amounts.length; - - const avgAmount = count > 0 ? amounts.reduce((a, b) => a + b, 0) / count : Number(s.amount); - const variance = count > 1 ? amounts.reduce((a, b) => a + Math.pow(b - avgAmount, 2), 0) / (count - 1) : 0; - const stdDev = count > 1 ? Math.sqrt(variance) : 0; - const threshold = avgAmount + 2.5 * stdDev; + const userMap = new Map(dbUsers.map((u: any) => [u.username, u])); + const hotelMap = new Map(dbHotels.map((h: any) => [h.code, h])); + + const results = []; + + for (const s of sales) { + const user = userMap.get(s.username); + const hotel = hotelMap.get(s.hotelCode); + + if (!user) { + results.push({ + ...s, + isValid: false, + invalidReason: 'COLLABORATOR_NOT_FOUND', + avgAmount: 0, + stdDev: 0, + threshold: 0, + historicalCount: 0 + }); + continue; + } + + if (!hotel) { + results.push({ + ...s, + isValid: false, + invalidReason: 'HOTEL_NOT_FOUND', + avgAmount: 0, + stdDev: 0, + threshold: 0, + historicalCount: 0 + }); + continue; + } + + // Fetch historical sales results for this user to compute standard deviation + const historicalSales = await tx.salesResult.findMany({ + where: { userId: user.id }, + select: { amount: true } + }); + + const amounts = historicalSales.map((h: any) => Number(h.amount)); + const count = amounts.length; + + const avgAmount = count > 0 ? amounts.reduce((a: number, b: number) => a + b, 0) / count : Number(s.amount); + const variance = count > 1 ? amounts.reduce((a: number, b: number) => a + Math.pow(b - avgAmount, 2), 0) / (count - 1) : 0; + const stdDev = count > 1 ? Math.sqrt(variance) : 0; + const threshold = avgAmount + 2.5 * stdDev; + + results.push({ + ...s, + isValid: true, + avgAmount, + stdDev, + threshold, + historicalCount: count + }); + } + return results; + }); - validationResults.push({ - ...s, - isValid: true, - avgAmount, - stdDev, - threshold, - historicalCount: count - }); - } return NextResponse.json({ success: true, diff --git a/src/app/api/sales/batch-save/route.ts b/src/app/api/sales/batch-save/route.ts index 898bc4e..7b226c7 100644 --- a/src/app/api/sales/batch-save/route.ts +++ b/src/app/api/sales/batch-save/route.ts @@ -133,7 +133,9 @@ export async function POST(req: NextRequest) { idempotencyKey: `${idempotencyKey}-${i}`, transactionId: s.transactionId || null, uploadedBy: uploaderId, - status: 'PENDING' + status: 'PENDING', + isAnomaly: s.isAnomaly || false, + flaggedReason: s.flaggedReason || null } }); createdSales.push(created); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1fdb5bf..14fc3b1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { LocaleProvider } from "@/lib/i18n/LocaleContext"; + const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], @@ -24,7 +26,9 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + ); } diff --git a/src/app/sales/simulation/page.module.css b/src/app/sales/simulation/page.module.css index cef7259..8513580 100644 --- a/src/app/sales/simulation/page.module.css +++ b/src/app/sales/simulation/page.module.css @@ -255,3 +255,18 @@ .positive { color: #4ade80; } + +.auditWarning { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: #fbbf24; + font-weight: 500; + cursor: help; +} + +.auditOk { + color: #34d399; + font-weight: 500; +} + diff --git a/src/app/sales/simulation/page.tsx b/src/app/sales/simulation/page.tsx index f98934a..199b568 100644 --- a/src/app/sales/simulation/page.tsx +++ b/src/app/sales/simulation/page.tsx @@ -5,6 +5,7 @@ import React, { useState, useEffect } from 'react'; import styles from './page.module.css'; import Header from '@/components/Header'; import { useRouter } from 'next/navigation'; +import { useLocale } from '@/lib/i18n/LocaleContext'; interface SimulatedItem { main: { @@ -26,6 +27,11 @@ interface SimulatedItem { adjustmentAmount: number; adjustmentNotes: string; }>; + aiAudited?: boolean; + aiAuditNotes?: { + en: string | null; + es: string | null; + } | null; } interface User { @@ -36,6 +42,7 @@ interface User { export default function SimulationPage() { const router = useRouter(); + const { locale, t } = useLocale(); const [role, setRole] = useState(null); const [currentUser, setCurrentUser] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -188,17 +195,15 @@ export default function SimulationPage() {
-

Simulación de Liquidación

-

- Calcule, simule y audite las comisiones mensuales antes de la aprobación del Líder Comercial. -

+

{t('simulation.title')}

+

{t('simulation.description')}

setSimulateOnly(e.target.checked)} />
@@ -232,7 +237,7 @@ export default function SimulationPage() { className={styles.btn} disabled={isProcessing} > - {isProcessing ? 'Calculando...' : 'Calcular'} + {isProcessing ? t('simulation.calculatingBtn') : t('simulation.calculateBtn')} @@ -243,28 +248,28 @@ export default function SimulationPage() { {results.length > 0 && (
-

Resumen del Cálculo

+

{t('simulation.resultsHeader')}

-
Colaboradores
+
{t('simulation.summaryCollaborators')}
{summary.count}
-
Comisión Propuesta
+
{t('simulation.summaryCommission')}
${summary.totalCommission.toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
Ajustes Retroactivos
+
{t('simulation.summaryAdjustment')}
${summary.totalAdjustment.toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
Pago Total Estimado
+
{t('simulation.summaryPayout')}
${(summary.totalCommission + summary.totalAdjustment).toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
@@ -275,15 +280,16 @@ export default function SimulationPage() { - - - - - - - - - + + + + + + + + + + @@ -320,6 +326,15 @@ export default function SimulationPage() { ${parseFloat(item.main.totalPayout.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })} +
ColaboradorPlanMetaVentas ConfirmadasCumplimiento %Comisión CalculadaAjustes RetroactivosPago TotalEstado{t('simulation.thCollaborator')}{t('simulation.thPlan')}{t('simulation.thGoal')}{t('simulation.thSales')}{t('simulation.thAchievement')}{t('simulation.thCommission')}{t('simulation.thAdjustment')}{t('simulation.thPayout')}{t('simulation.thAiAudit')}{t('simulation.thStatus')}
+ {item.aiAudited ? ( + + ⚠️ {item.aiAuditNotes?.[locale] || 'Warning'} + + ) : ( + ✓ OK + )} + (null); React.useEffect(() => { @@ -51,13 +53,13 @@ export default function Header({ activeTab }: HeaderProps) { href="/plans" className={`${styles.navLink} ${activeTab === 'plans' ? styles.navLinkActive : ''}`} > - Planes de Comisión + {t('nav.plans')} - Metas Comerciales + {t('nav.goals')} {showImportLink && ( - Cargar Ventas + {t('nav.import')} )} {showSimulationLink && ( @@ -74,7 +76,7 @@ export default function Header({ activeTab }: HeaderProps) { className={`${styles.navLink} ${activeTab === 'simulation' ? styles.navLinkActive : ''}`} id="nav-simulation" > - Simulación + {t('nav.simulation')} )} {showApprovalsLink && ( @@ -83,13 +85,24 @@ export default function Header({ activeTab }: HeaderProps) { className={`${styles.navLink} ${activeTab === 'approvals' ? styles.navLinkActive : ''}`} id="nav-approvals" > - Aprobaciones + {t('nav.approvals')} )} - +
+ + +
); } diff --git a/src/lib/db.ts b/src/lib/db.ts index ed1d123..ca4aa99 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -18,9 +18,13 @@ const getPrismaClient = (): PrismaClient => { ssl: false }); const adapter = new PrismaPg(pool); + const logLevels: any[] = ['warn', 'error']; + if (process.env.PRISMA_QUERY_LOG === 'true') { + logLevels.push('query', 'info'); + } globalPrisma = new PrismaClient({ adapter, - log: ['query', 'info', 'warn', 'error'] + log: logLevels }); } return globalPrisma; diff --git a/src/lib/i18n/LocaleContext.tsx b/src/lib/i18n/LocaleContext.tsx new file mode 100644 index 0000000..edfef69 --- /dev/null +++ b/src/lib/i18n/LocaleContext.tsx @@ -0,0 +1,65 @@ +'use client'; + +import React, { createContext, useContext, useState, useEffect } from 'react'; +import es from './dictionaries/es.json'; +import en from './dictionaries/en.json'; + +type Locale = 'en' | 'es'; + +interface LocaleContextProps { + locale: Locale; + setLocale: (locale: Locale) => void; + t: (key: string) => string; +} + +const LocaleContext = createContext(undefined); + +const dictionaries: Record = { es, en }; + +function getNestedValue(obj: any, path: string): string { + const parts = path.split('.'); + let current = obj; + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = current[part]; + } else { + return path; + } + } + return typeof current === 'string' ? current : path; +} + +export function LocaleProvider({ children }: { children: React.ReactNode }) { + const [locale, setLocaleState] = useState('es'); + + useEffect(() => { + const stored = localStorage.getItem('app-locale') as Locale; + if (stored === 'en' || stored === 'es') { + setLocaleState(stored); + } + }, []); + + const setLocale = (newLocale: Locale) => { + setLocaleState(newLocale); + localStorage.setItem('app-locale', newLocale); + }; + + const t = (key: string): string => { + const dict = dictionaries[locale]; + return getNestedValue(dict, key); + }; + + return ( + + {children} + + ); +} + +export function useLocale() { + const context = useContext(LocaleContext); + if (!context) { + throw new Error('useLocale must be used within a LocaleProvider'); + } + return context; +} diff --git a/src/lib/i18n/dictionaries/en.json b/src/lib/i18n/dictionaries/en.json new file mode 100644 index 0000000..afb03a1 --- /dev/null +++ b/src/lib/i18n/dictionaries/en.json @@ -0,0 +1,33 @@ +{ + "nav": { + "plans": "Commission Plans", + "goals": "Commercial Goals", + "import": "Ingest Sales", + "simulation": "Simulation", + "approvals": "Approvals", + "logout": "Log Out" + }, + "simulation": { + "title": "Settlement Simulation", + "description": "Calculate, simulate and audit monthly commissions before the Commercial Leader's approval.", + "periodLabel": "Settlement Period (YYYY-MM)", + "dryRunLabel": "Simulator Mode (Do not write to database)", + "calculateBtn": "Calculate", + "calculatingBtn": "Calculating...", + "resultsHeader": "Calculation Summary", + "summaryCollaborators": "Collaborators", + "summaryCommission": "Proposed Commission", + "summaryAdjustment": "Retroactive Adjustments", + "summaryPayout": "Estimated Total Payout", + "thCollaborator": "Collaborator", + "thPlan": "Plan", + "thGoal": "Goal", + "thSales": "Confirmed Sales", + "thAchievement": "Achievement %", + "thCommission": "Calculated Commission", + "thAdjustment": "Retroactive Adjustments", + "thPayout": "Total Payout", + "thStatus": "Status", + "thAiAudit": "AI Audit" + } +} diff --git a/src/lib/i18n/dictionaries/es.json b/src/lib/i18n/dictionaries/es.json new file mode 100644 index 0000000..337768e --- /dev/null +++ b/src/lib/i18n/dictionaries/es.json @@ -0,0 +1,33 @@ +{ + "nav": { + "plans": "Planes de Comisión", + "goals": "Metas Comerciales", + "import": "Cargar Ventas", + "simulation": "Simulación", + "approvals": "Aprobaciones", + "logout": "Cerrar Sesión" + }, + "simulation": { + "title": "Simulación de Liquidación", + "description": "Calcule, simule y audite las comisiones mensuales antes de la aprobación del Líder Comercial.", + "periodLabel": "Período de Liquidación (YYYY-MM)", + "dryRunLabel": "Modo Simulador (No escribir en base de datos)", + "calculateBtn": "Calcular", + "calculatingBtn": "Calculando...", + "resultsHeader": "Resumen del Cálculo", + "summaryCollaborators": "Colaboradores", + "summaryCommission": "Comisión Propuesta", + "summaryAdjustment": "Ajustes Retroactivos", + "summaryPayout": "Pago Total Estimado", + "thCollaborator": "Colaborador", + "thPlan": "Plan", + "thGoal": "Meta", + "thSales": "Ventas Confirmadas", + "thAchievement": "Cumplimiento %", + "thCommission": "Comisión Calculada", + "thAdjustment": "Ajustes Retroactivos", + "thPayout": "Pago Total", + "thStatus": "Estado", + "thAiAudit": "Auditoría AI" + } +} diff --git a/src/middleware.ts b/src/middleware.ts index d39f62a..1b43de4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -21,7 +21,9 @@ export async function middleware(req: NextRequest) { // Get session cookie const allCookies = req.cookies.getAll(); const sessionCookie = req.cookies.get('session')?.value; - console.log(`[Middleware] Path: ${pathname}, Cookies received:`, allCookies.map(c => c.name), "Session value exists:", !!sessionCookie); + if (process.env.DEBUG === 'true') { + console.log(`[Middleware] Path: ${pathname}, Cookies received:`, allCookies.map(c => c.name), "Session value exists:", !!sessionCookie); + } const session = sessionCookie ? await verifyJwtEdge(sessionCookie) : null; // If on login page