modifiche a workflow n8n
This commit is contained in:
parent
5475816d0b
commit
751b7f24d7
10 changed files with 569 additions and 51 deletions
50
src/app/api/articoli/ricerca-prezzi/route.ts
Normal file
50
src/app/api/articoli/ricerca-prezzi/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { requirePermission } from "@/src/lib/auth/requireSession";
|
||||
import { searchPriceOffers } from "@/src/lib/price-search";
|
||||
import type { PriceSearchRequest } from "@/src/types/priceSearch";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await requirePermission(
|
||||
(p) => p.canSearchPrices,
|
||||
"Non hai i permessi per la ricerca prezzi",
|
||||
);
|
||||
if (session instanceof NextResponse) return session;
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as Partial<PriceSearchRequest>;
|
||||
|
||||
const descrizione = String(body.descrizione ?? "").trim();
|
||||
if (!descrizione) {
|
||||
return NextResponse.json(
|
||||
{ error: "La descrizione e obbligatoria per la ricerca" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const payload: PriceSearchRequest = {
|
||||
descrizione,
|
||||
barcode: body.barcode ?? null,
|
||||
codice: body.codice ?? null,
|
||||
quantita:
|
||||
body.quantita !== undefined && body.quantita !== null
|
||||
? Number(body.quantita)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const result = await searchPriceOffers(payload, {
|
||||
userId: session.user.id,
|
||||
userName: session.user.utente || session.user.name || session.user.email || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("[API ricerca-prezzi]", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Errore ricerca prezzi";
|
||||
|
||||
const status = message.includes("non configurata") ? 503 : 502;
|
||||
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import PriceSearchPanel from "@/src/components/PriceSearchPanel";
|
||||
import { usePermissions } from "@/src/contexts/PermissionsContext";
|
||||
import { usePriceSearch } from "@/src/hooks/usePriceSearch";
|
||||
import type { Article, ArticleInput, ArticleFamily } from "@/src/types/article";
|
||||
import type { Stabilimento, Magazzino } from "@/src/types/stabilimento";
|
||||
|
||||
|
|
@ -19,6 +22,9 @@ export default function ArticleModal({
|
|||
}: ArticleModalProps) {
|
||||
const isEdit = article !== null;
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const { permissions } = usePermissions();
|
||||
const { result, error: searchError, search, cancel, reset, isLoading } =
|
||||
usePriceSearch();
|
||||
|
||||
const [formData, setFormData] = useState<ArticleInput>({
|
||||
codice: "",
|
||||
|
|
@ -173,11 +179,40 @@ export default function ArticleModal({
|
|||
setSelectedMagazzino(value);
|
||||
};
|
||||
|
||||
const handlePriceSearch = async () => {
|
||||
const descrizione = formData.descrizione.trim();
|
||||
if (!descrizione) {
|
||||
setError("Inserisci una descrizione prima di cercare i prezzi");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
await search({
|
||||
descrizione,
|
||||
barcode: formData.barcode || null,
|
||||
codice: formData.codice || null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyPrice = (price: number) => {
|
||||
setFormData((prev) => ({ ...prev, prezzo: price }));
|
||||
};
|
||||
|
||||
const showPriceSearchPanel =
|
||||
permissions.canSearchPrices &&
|
||||
(isLoading || searchError !== null || result !== null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => reset();
|
||||
}, [reset]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="w-full max-w-lg bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden max-h-[90vh] flex flex-col"
|
||||
className={`w-full bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden max-h-[90vh] flex flex-col ${
|
||||
showPriceSearchPanel ? "max-w-2xl" : "max-w-lg"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||
|
|
@ -299,6 +334,56 @@ export default function ArticleModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{permissions.canSearchPrices && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePriceSearch}
|
||||
disabled={isLoading || !formData.descrizione.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600/20 border border-blue-500/40 text-blue-400 text-sm font-medium hover:bg-blue-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
Cerca prezzi online
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{permissions.canSearchPrices && (
|
||||
<PriceSearchPanel
|
||||
isLoading={isLoading}
|
||||
error={searchError}
|
||||
result={result}
|
||||
onApplyPrice={handleApplyPrice}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Giacenza iniziale (solo per nuovo articolo) */}
|
||||
{!isEdit && (
|
||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/30">
|
||||
|
|
|
|||
162
src/components/PriceSearchPanel.tsx
Normal file
162
src/components/PriceSearchPanel.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import type { PriceOffer, PriceSearchResponse } from "@/src/types/priceSearch";
|
||||
|
||||
const CONFIDENCE_LABELS: Record<PriceOffer["confidence"], string> = {
|
||||
high: "Alta",
|
||||
medium: "Media",
|
||||
low: "Bassa",
|
||||
};
|
||||
|
||||
const CONFIDENCE_STYLES: Record<PriceOffer["confidence"], string> = {
|
||||
high: "bg-green-500/15 text-green-400 border-green-500/30",
|
||||
medium: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30",
|
||||
low: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
};
|
||||
|
||||
function formatPrice(price: number | null, currency: string) {
|
||||
if (price === null) return "N/D";
|
||||
return new Intl.NumberFormat("it-IT", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
interface PriceSearchPanelProps {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
result: PriceSearchResponse | null;
|
||||
onApplyPrice: (price: number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function PriceSearchPanel({
|
||||
isLoading,
|
||||
error,
|
||||
result,
|
||||
onApplyPrice,
|
||||
onCancel,
|
||||
}: PriceSearchPanelProps) {
|
||||
if (!isLoading && !error && !result) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/30 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-blue-400 flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
Ricerca prezzi online
|
||||
</h3>
|
||||
{isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-3 text-sm text-zinc-400">
|
||||
<svg className="w-4 h-4 animate-spin text-blue-400" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Ricerca in corso… può richiedere fino a 30 secondi
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/50 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Query: <span className="text-zinc-400">{result.query}</span>
|
||||
</p>
|
||||
|
||||
{result.offers.length === 0 ? (
|
||||
<p className="text-sm text-zinc-400">Nessuna offerta trovata.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{result.offers.map((offer, index) => (
|
||||
<li
|
||||
key={`${offer.vendor}-${offer.url}-${index}`}
|
||||
className="p-3 rounded-lg bg-zinc-800/80 border border-zinc-700"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{offer.vendor}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded border ${CONFIDENCE_STYLES[offer.confidence]}`}
|
||||
>
|
||||
{CONFIDENCE_LABELS[offer.confidence]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 mt-0.5 line-clamp-2">{offer.title}</p>
|
||||
{offer.shippingNote && (
|
||||
<p className="text-[11px] text-zinc-500 mt-1">{offer.shippingNote}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-green-400">
|
||||
{formatPrice(offer.price, offer.currency)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={offer.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Apri
|
||||
</a>
|
||||
{offer.price !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApplyPrice(offer.price as number)}
|
||||
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Applica
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-zinc-500 leading-relaxed">{result.disclaimer}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/hooks/usePriceSearch.ts
Normal file
82
src/hooks/usePriceSearch.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch";
|
||||
|
||||
type SearchStatus = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function usePriceSearch() {
|
||||
const [status, setStatus] = useState<SearchStatus>("idle");
|
||||
const [result, setResult] = useState<PriceSearchResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const search = useCallback(async (input: PriceSearchRequest) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/articoli/ricerca-prezzi", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
typeof data.error === "string" ? data.error : "Errore ricerca prezzi",
|
||||
);
|
||||
}
|
||||
|
||||
const response = data as PriceSearchResponse;
|
||||
setResult(response);
|
||||
setStatus("success");
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
setStatus("idle");
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : "Errore sconosciuto";
|
||||
setError(message);
|
||||
setStatus("error");
|
||||
return null;
|
||||
} finally {
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setStatus("idle");
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
cancel();
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}, [cancel]);
|
||||
|
||||
return {
|
||||
status,
|
||||
result,
|
||||
error,
|
||||
search,
|
||||
cancel,
|
||||
reset,
|
||||
isLoading: status === "loading",
|
||||
};
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ export interface AppPermissions {
|
|||
canAssegnaArticoliMacchina: boolean;
|
||||
canEditMacchine: boolean;
|
||||
canRemoveArticoloMacchina: boolean;
|
||||
canSearchPrices: boolean;
|
||||
}
|
||||
|
||||
export function getPermissions(ruolo: UserRole): AppPermissions {
|
||||
|
|
@ -49,6 +50,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
|||
canAssegnaArticoliMacchina: true,
|
||||
canEditMacchine: true,
|
||||
canRemoveArticoloMacchina: true,
|
||||
canSearchPrices: true,
|
||||
};
|
||||
case "tecnico":
|
||||
return {
|
||||
|
|
@ -58,6 +60,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
|||
canAssegnaArticoliMacchina: true,
|
||||
canEditMacchine: true,
|
||||
canRemoveArticoloMacchina: true,
|
||||
canSearchPrices: true,
|
||||
};
|
||||
case "operatore":
|
||||
return {
|
||||
|
|
@ -67,6 +70,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
|
|||
canAssegnaArticoliMacchina: false,
|
||||
canEditMacchine: false,
|
||||
canRemoveArticoloMacchina: false,
|
||||
canSearchPrices: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
src/lib/price-search/index.ts
Normal file
15
src/lib/price-search/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { searchPriceOffersViaN8n } from "@/src/lib/price-search/n8n";
|
||||
import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch";
|
||||
|
||||
export async function searchPriceOffers(
|
||||
input: PriceSearchRequest,
|
||||
meta?: { userId?: string; userName?: string },
|
||||
): Promise<PriceSearchResponse> {
|
||||
const backend = (process.env.PRICE_SEARCH_BACKEND ?? "n8n").toLowerCase();
|
||||
|
||||
if (backend === "n8n") {
|
||||
return searchPriceOffersViaN8n(input, meta);
|
||||
}
|
||||
|
||||
throw new Error(`Backend ricerca prezzi non supportato: ${backend}`);
|
||||
}
|
||||
79
src/lib/price-search/n8n.ts
Normal file
79
src/lib/price-search/n8n.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import {
|
||||
DEFAULT_DISCLAIMER,
|
||||
isPriceSearchResponse,
|
||||
parseN8nResponseBody,
|
||||
} from "@/src/lib/price-search/normalize";
|
||||
import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch";
|
||||
|
||||
function getWebhookConfig() {
|
||||
const url =
|
||||
process.env.N8N_PRICE_SEARCH_WEBHOOK_URL?.trim() ??
|
||||
process.env.N8N_PRICE_WEBHOOK_URL?.trim();
|
||||
const secret = process.env.N8N_PRICE_WEBHOOK_SECRET?.trim();
|
||||
const timeoutMs = Number(process.env.N8N_PRICE_WEBHOOK_TIMEOUT_MS ?? 45_000);
|
||||
|
||||
if (!url || !secret) {
|
||||
throw new Error("Ricerca prezzi n8n non configurata (URL o secret mancanti)");
|
||||
}
|
||||
|
||||
return { url, secret, timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : 45_000 };
|
||||
}
|
||||
|
||||
export async function searchPriceOffersViaN8n(
|
||||
input: PriceSearchRequest,
|
||||
meta?: { userId?: string; userName?: string },
|
||||
): Promise<PriceSearchResponse> {
|
||||
const { url, secret, timeoutMs } = getWebhookConfig();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Secret": secret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...input,
|
||||
meta: {
|
||||
source: "magricambi",
|
||||
userId: meta?.userId ?? null,
|
||||
userName: meta?.userName ?? null,
|
||||
requestedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
const data = parseN8nResponseBody(text, res.status);
|
||||
|
||||
if (!res.ok) {
|
||||
const err =
|
||||
data && typeof data === "object" && "error" in data
|
||||
? String((data as { error: unknown }).error)
|
||||
: `Errore n8n (HTTP ${res.status})`;
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
if (!isPriceSearchResponse(data)) {
|
||||
throw new Error(
|
||||
"Formato risposta n8n non conforme. Verifica il nodo Respond OK del workflow.",
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error("Timeout ricerca prezzi: riprova tra poco");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_DISCLAIMER };
|
||||
48
src/lib/price-search/normalize.ts
Normal file
48
src/lib/price-search/normalize.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { PriceSearchResponse } from "@/src/types/priceSearch";
|
||||
|
||||
const DEFAULT_DISCLAIMER =
|
||||
"Prezzi indicativi da fonti web: verificare su ordine fornitore prima dell'acquisto.";
|
||||
|
||||
export function isPriceSearchResponse(value: unknown): value is PriceSearchResponse {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.query === "string" &&
|
||||
typeof v.searchedAt === "string" &&
|
||||
Array.isArray(v.offers) &&
|
||||
typeof v.disclaimer === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function emptyPriceSearchResponse(query: string, disclaimer?: string): PriceSearchResponse {
|
||||
return {
|
||||
query,
|
||||
searchedAt: new Date().toISOString(),
|
||||
offers: [],
|
||||
disclaimer: disclaimer ?? DEFAULT_DISCLAIMER,
|
||||
source: "tavily",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseN8nResponseBody(text: string, status: number): unknown {
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
if (status === 200) {
|
||||
throw new Error(
|
||||
"Workflow n8n senza risposta. Verifica che il workflow sia attivo e che l'URL sia quello di produzione.",
|
||||
);
|
||||
}
|
||||
throw new Error(`Errore n8n (HTTP ${status})`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Risposta non valida dal workflow n8n (HTTP ${status}). Controlla l'esecuzione su n8n.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_DISCLAIMER };
|
||||
28
src/types/priceSearch.ts
Normal file
28
src/types/priceSearch.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export type PriceOfferConfidence = "high" | "medium" | "low";
|
||||
export type PriceOfferSource = "page" | "snippet" | "estimate";
|
||||
|
||||
export interface PriceOffer {
|
||||
vendor: string;
|
||||
title: string;
|
||||
price: number | null;
|
||||
currency: "EUR";
|
||||
url: string;
|
||||
shippingNote?: string;
|
||||
confidence: PriceOfferConfidence;
|
||||
source: PriceOfferSource;
|
||||
}
|
||||
|
||||
export interface PriceSearchRequest {
|
||||
descrizione: string;
|
||||
barcode?: string | null;
|
||||
codice?: string | null;
|
||||
quantita?: number;
|
||||
}
|
||||
|
||||
export interface PriceSearchResponse {
|
||||
query: string;
|
||||
searchedAt: string;
|
||||
offers: PriceOffer[];
|
||||
disclaimer: string;
|
||||
source?: "tavily" | "llm";
|
||||
}
|
||||
|
|
@ -3,15 +3,15 @@
|
|||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"content": "## MagRicambi — Ricerca prezzi\n\n**Setup:**\n1. Credenziale Header Auth sul Webhook (`X-Webhook-Secret`)\n2. Credenziale Tavily su nodo HTTP\n3. Credenziale OpenAI\n4. Attiva workflow → copia Production URL in `N8N_PRICE_SEARCH_WEBHOOK_URL`\n\n**Chiamata da MagRicambi:**\n`POST /api/articoli/ricerca-prezzi` → proxy verso questo webhook",
|
||||
"height": 320,
|
||||
"width": 420
|
||||
"content": "## MagRicambi — Ricerca prezzi v3 (solo Tavily)\n\n**Affidabile, senza LLM.** Estrae i prezzi dagli snippet web di Tavily.\n\n**Setup:**\n1. Credenziale Header Auth sul Webhook (`X-Webhook-Secret`)\n2. Credenziale Tavily (header `Authorization: Bearer tvly-...`)\n3. **Attiva** il workflow (toggle verde)\n4. Copia **Production URL** in `N8N_PRICE_SEARCH_WEBHOOK_URL`\n\n**Flusso:** Webhook → Tavily → Estrai prezzi → Respond OK\n\nRisponde **sempre** JSON valido per MagRicambi.",
|
||||
"height": 340,
|
||||
"width": 440
|
||||
},
|
||||
"id": "sticky-note-setup",
|
||||
"name": "Note Setup",
|
||||
"type": "n8n-nodes-base.stickyNote",
|
||||
"typeVersion": 1,
|
||||
"position": [-80, 80]
|
||||
"position": [-80, 60]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
|
|
@ -93,6 +93,7 @@
|
|||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [960, 200],
|
||||
"onError": "continueRegularOutput",
|
||||
"credentials": {
|
||||
"httpHeaderAuth": {
|
||||
"id": "REPLACE_WITH_CREDENTIAL_ID",
|
||||
|
|
@ -102,38 +103,13 @@
|
|||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"authentication": "predefinedCredentialType",
|
||||
"nodeCredentialType": "openAiApi",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={\n \"model\": \"gpt-4o-mini\",\n \"temperature\": 0.2,\n \"response_format\": { \"type\": \"json_object\" },\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"Sei un assistente per acquisti industriali. Ricevi risultati di ricerca web. Estrai SOLO offerte con prezzo esplicito nei testi forniti. Se il prezzo non è chiaro usa price null e confidence low. Rispondi SOLO con JSON valido nel formato: {\\\"offers\\\":[{\\\"vendor\\\":\\\"\\\",\\\"title\\\":\\\"\\\",\\\"price\\\":null,\\\"currency\\\":\\\"EUR\\\",\\\"url\\\":\\\"\\\",\\\"shippingNote\\\":\\\"\\\",\\\"confidence\\\":\\\"high|medium|low\\\",\\\"source\\\":\\\"snippet|page|estimate\\\"}]}. Massimo 8 offerte. Valuta EUR. Nessun testo fuori dal JSON.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Query: \" + $('Build Query').first().json.query + \"\\n\\nRisultati ricerca:\\n\" + JSON.stringify($json.results ?? $json)\n }\n ]\n}",
|
||||
"options": {
|
||||
"timeout": 25000
|
||||
}
|
||||
"jsCode": "const query = $('Build Query').first().json.query;\nconst descrizione = $('Build Query').first().json.descrizione;\n\nconst DISCLAIMER =\n \"Prezzi indicativi da fonti web: verificare su ordine fornitore prima dell'acquisto.\";\nconst TAVILY_ERROR_DISCLAIMER =\n DISCLAIMER + \" Ricerca web non disponibile al momento: riprovare tra poco.\";\n\nfunction hostnameFromUrl(url) {\n try {\n return new URL(url).hostname.replace(/^www\\./, '');\n } catch {\n return 'Sconosciuto';\n }\n}\n\nfunction extractPriceFromText(text) {\n const patterns = [\n /(?:€|EUR)\\s*(\\d{1,6}[.,]\\d{2})/i,\n /(\\d{1,6}[.,]\\d{2})\\s*(?:€|EUR)/i,\n ];\n for (const re of patterns) {\n const m = text.match(re);\n if (m) {\n const n = Number(m[1].replace(',', '.'));\n if (Number.isFinite(n) && n > 0 && n < 100000) return n;\n }\n }\n return null;\n}\n\nconst tavilyItem = $input.first().json;\nconst tavilyFailed = Boolean(tavilyItem?.error);\nconst results = Array.isArray(tavilyItem?.results) ? tavilyItem.results : [];\n\nconst offers = [];\nconst seenUrls = new Set();\n\nfor (const r of results) {\n const url = String(r.url ?? '').trim();\n if (!url || url === '#' || seenUrls.has(url)) continue;\n\n const text = `${r.title ?? ''} ${r.content ?? ''}`;\n const price = extractPriceFromText(text);\n if (price === null) continue;\n\n seenUrls.add(url);\n offers.push({\n vendor: hostnameFromUrl(url).slice(0, 120),\n title: String(r.title ?? descrizione).slice(0, 200),\n price,\n currency: 'EUR',\n url,\n confidence: 'medium',\n source: 'snippet',\n });\n\n if (offers.length >= 8) break;\n}\n\nreturn [{\n json: {\n query,\n searchedAt: new Date().toISOString(),\n offers,\n disclaimer: tavilyFailed && offers.length === 0 ? TAVILY_ERROR_DISCLAIMER : DISCLAIMER,\n source: 'tavily',\n },\n}];"
|
||||
},
|
||||
"id": "http-openai-extract",
|
||||
"name": "OpenAI Extract",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1200, 200],
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "REPLACE_WITH_CREDENTIAL_ID",
|
||||
"name": "OpenAI API"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const query = $('Build Query').first().json.query;\nconst descrizione = $('Build Query').first().json.descrizione;\n\nlet parsed = { offers: [] };\n\ntry {\n const item = $input.first().json;\n const raw =\n item.choices?.[0]?.message?.content ??\n item.message?.content ??\n item.text ??\n '{}';\n const clean = String(raw).replace(/```json|```/g, '').trim();\n parsed = JSON.parse(clean);\n} catch (e) {\n parsed = { offers: [] };\n}\n\nconst offers = (parsed.offers ?? [])\n .map((o) => ({\n vendor: String(o.vendor ?? 'Sconosciuto').slice(0, 120),\n title: String(o.title ?? descrizione).slice(0, 200),\n price:\n o.price === null || o.price === undefined || o.price === ''\n ? null\n : Number(o.price),\n currency: 'EUR',\n url: String(o.url ?? ''),\n shippingNote: o.shippingNote ? String(o.shippingNote) : undefined,\n confidence: ['high', 'medium', 'low'].includes(o.confidence)\n ? o.confidence\n : 'low',\n source: ['page', 'snippet', 'estimate'].includes(o.source)\n ? o.source\n : 'snippet',\n }))\n .filter((o) => o.url && o.url !== '#');\n\nreturn [{\n json: {\n query,\n searchedAt: new Date().toISOString(),\n offers,\n disclaimer:\n 'Prezzi indicativi da fonti web: verificare su ordine fornitore prima dell\\'acquisto.',\n },\n}];"
|
||||
},
|
||||
"id": "code-format-response",
|
||||
"name": "Format Response",
|
||||
"id": "code-extract-prices",
|
||||
"name": "Estrai Prezzi",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1440, 200]
|
||||
"position": [1200, 200]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
|
|
@ -147,7 +123,7 @@
|
|||
"name": "Respond OK",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [1680, 200]
|
||||
"position": [1440, 200]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
|
|
@ -209,25 +185,14 @@
|
|||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "OpenAI Extract",
|
||||
"node": "Estrai Prezzi",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"OpenAI Extract": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Format Response",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Format Response": {
|
||||
"Estrai Prezzi": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
|
|
@ -256,11 +221,11 @@
|
|||
}
|
||||
],
|
||||
"triggerCount": 1,
|
||||
"updatedAt": "2026-06-04T12:00:00.000Z",
|
||||
"versionId": "magricambi-ricerca-prezzi-v1",
|
||||
"updatedAt": "2026-06-07T18:00:00.000Z",
|
||||
"versionId": "magricambi-ricerca-prezzi-v3-tavily",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": false,
|
||||
"instanceId": "magricambi-local"
|
||||
},
|
||||
"active": false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue