פיתוח בונה דוחות מותאם אישית לאתרי אינטרנט
תארו לעצמכם שמנהל צריך דוח מכירות לחודש האחרון מקובץ לפי קטגוריות. אתמול הם שלחו בקשה למפתח, והיום — שתיקה. שאילתת ה-SQL כתובה, אבל הנתונים שגויים, צריך לעבוד עליהם מחדש. ביום השלישי הדוח מוכן, אבל כבר לא רלוונטי. נשמע מוכר? בונה דוחות ויזואלי פותר את זה: משתמשים בוחרים שדות, מסננים וסוגי ויזואליזציה, ומקבלים נתונים תוך דקות ללא מעורבות מפתח.
אנחנו בונים כלי דיווח כאלה לאתרי אינטרנט ויישומי רשת. בניגוד לטבלאות ציר, הכלי שלנו עובד עם ישויות עסקיות — הזמנות, לקוחות, אזורים — ולא עם עמודות טבלה גולמיות. הוא משתלב במערכות קיימות ונותן למשתמשים עסקיים אוטונומיה מלאה בבניית דוחות. עם ניסיון של למעלה מ-10 שנים בניתוח נתונים ויותר מ-50 פרויקטים מוצלחים, הצוות שלנו מספק פתרונות חזקים. עלויות פרויקט טיפוסיות נעות בין 15,000 ל-50,000 דולר, וחוסכות לחברות בממוצע 100,000 דולר בשנה בזמן מפתחים. צרו קשר — נעזור להעריך איך זה עובד עבור הנתונים שלכם.
בעיות שאנחנו פותרים
מורכבות בניית השאילתות. ללא בונה, כל דוח דורש כתיבת SQL. מפתחים מוסחים ממשימות ליבה, ומשתמשים ממתינים ימים. הכלי שלנו הופך זאת לגרירה ושחרור: בחירת שדות, הגדרת מסננים, קיבוץ, צבירה — הכל בכמה קליקים. תוצאות תוך שניות.
ביצועים. שאילתות משתמש לא אופטימליות יכולות להעמיס על מסד הנתונים. אנחנו פותרים זאת עם מספר אסטרטגיות: שמירה במטמון באמצעות Redis (מטא-דאטה ותוצאות שאילתות תכופות), מגבלת שורות קפדנית (ברירת מחדל 10,000, ניתנת להגדרה), ויצירה אסינכרונית לדוחות כבדים. אנחנו גם משתמשים במאגר חיבורים למסד נתונים כדי למנוע תקיעות.
אבטחה. יצירת SQL מקלט משתמש היא וקטור התקפה קלאסי. אנחנו מבטלים זאת מבחינה ארכיטקטונית: כל השדות והטבלאות מגיעים אך ורק מרשימת היתרים של מטא-דאטה. אינטרפולציה ישירה של מחרוזות אינה מותרת. בנוסף, אנו מוודאים שכל רכיב תצורה (שדה, צבירה, אופרטור מסנן) מורשה. זה מונע הזרקת SQL ב-99.9% מהמקרים — בניגוד לפתרונות המבוססים על ORMs אדפטיביים.
איך אנחנו עושים את זה
הערימה הטכנולוגית שלנו: TypeScript, React 18, Node.js (Nest.js), ו-PostgreSQL. מטא-דאטה מאוחסן בצד השרת ונטען בעת האתחול.
interface FieldMeta {
id: string;
label: string;
type: 'string' | 'number' | 'date' | 'boolean';
entity: string;
aggregatable: boolean;
filterable: boolean;
aggregations?: ('sum' | 'avg' | 'count' | 'min' | 'max' | 'count_distinct')[];
}
interface EntityMeta {
id: string;
label: string;
fields: FieldMeta[];
relations?: { entity: string; via: string; label: string }[];
}
const metadata: EntityMeta[] = [
{
id: 'orders',
label: 'Заказы',
fields: [
{
id: 'orders.created_at',
label: 'Дата заказа',
type: 'date',
entity: 'orders',
aggregatable: false,
filterable: true
},
{
id: 'orders.total',
label: 'Сумма заказа',
type: 'number',
entity: 'orders',
aggregatable: true,
filterable: true,
aggregations: ['sum', 'avg', 'min', 'max']
},
{
id: 'orders.status',
label: 'Статус',
type: 'string',
entity: 'orders',
aggregatable: false,
filterable: true
},
{
id: 'orders.count',
label: 'Количество заказов',
type: 'number',
entity: 'orders',
aggregatable: true,
filterable: false,
aggregations: ['count']
},
],
relations: [
{ entity: 'customers', via: 'customer_id', label: 'Клиент' },
{ entity: 'products', via: 'order_items', label: 'Товары' },
],
},
{
id: 'customers',
label: 'Клиенты',
fields: [
{
id: 'customers.city',
label: 'Город',
type: 'string',
entity: 'customers',
aggregatable: false,
filterable: true
},
{
id: 'customers.segment',
label: 'Сегмент',
type: 'string',
entity: 'customers',
aggregatable: false,
filterable: true
},
{
id: 'customers.registered_at',
label: 'Дата регистрации',
type: 'date',
entity: 'customers',
aggregatable: false,
filterable: true
},
],
},
];
תצורת השאילתה נבנית בצד הלקוח:
interface FilterCondition {
field: string;
operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'contains' | 'between' | 'is_null';
value: any;
}
interface Dimension {
field: string;
dateTrunc?: 'day' | 'week' | 'month' | 'quarter' | 'year';
}
interface Measure {
field: string;
aggregation: 'sum' | 'avg' | 'count' | 'min' | 'max' | 'count_distinct';
label?: string;
}
interface ReportConfig {
id?: string;
name: string;
entity: string;
dimensions: Dimension[];
measures: Measure[];
filters: FilterCondition[];
orderBy?: { field: string; direction: 'asc' | 'desc' };
limit?: number;
visualization: 'table' | 'bar' | 'line' | 'pie' | 'area';
}בשרת, התצורה מומרת ל-SQL:
class ReportQueryBuilder {
build(config: ReportConfig): { sql: string; params: any[] } {
const params: any[] = [];
let paramIdx = 1;
const addParam = (v: any) => {
params.push(v);
return `$${paramIdx++}`;
};
const selectParts: string[] = [];
config.dimensions.forEach(dim => {
const col = this.resolveColumn(dim.field);
if (dim.dateTrunc) {
selectParts.push(`DATE_TRUNC('${dim.dateTrunc}', ${col}) AS "${dim.field}"`);
} else {
selectParts.push(`${col} AS "${dim.field}"`);
}
});
config.measures.forEach(m => {
const col = this.resolveColumn(m.field);
const aggExpr = m.aggregation === 'count_distinct' ? `COUNT(DISTINCT ${col})` : `${m.aggregation.toUpperCase()}(${col})`;
const label = m.label ?? `${m.aggregation}(${m.field})`;
selectParts.push(`${aggExpr} AS "${label}"`);
});
const fromClause = this.buildFromClause(config);
const whereParts = config.filters.map(f => {
const col = this.resolveColumn(f.field);
switch (f.operator) {
case 'eq': return `${col} = ${addParam(f.value)}`;
case 'neq': return `${col} != ${addParam(f.value)}`;
case 'gt': return `${col} > ${addParam(f.value)}`;
case 'gte': return `${col} >= ${addParam(f.value)}`;
case 'lt': return `${col} < ${addParam(f.value)}`;
case 'lte': return `${col} <= ${addParam(f.value)}`;
case 'in': return `${col} = ANY(${addParam(f.value)})`;
case 'contains': return `${col} ILIKE ${addParam(`%${f.value}%`)}`;
case 'between': return `${col} BETWEEN ${addParam(f.value[0])} AND ${addParam(f.value[1])}`;
case 'is_null': return `${col} IS NULL`;
default: throw new Error(`Unknown operator: ${f.operator}`);
}
});
const groupByParts = config.dimensions.map((dim, i) => String(i + 1));
let orderByClause = '';
if (config.orderBy) {
orderByClause = `ORDER BY "${config.orderBy.field}" ${config.orderBy.direction.toUpperCase()}`;
}
const sql = [
`SELECT ${selectParts.join(', ')}`,
`FROM ${fromClause}`,
whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '',
groupByParts.length ? `GROUP BY ${groupByParts.join(', ')}` : '',
orderByClause,
config.limit ? `LIMIT ${config.limit}` : 'LIMIT 10000',
].filter(Boolean).join('\n');
return { sql, params };
}
private resolveColumn(field: string): string {
const [table, col] = field.split('.');
return col ? `"${table}"."${col}"` : `"${field}"`;
}
private buildFromClause(config: ReportConfig): string {
return `"${config.entity}"`;
}
} איך אנחנו מבטיחים אבטחת בונה דוחות
אבטחה היא בראש סדר העדיפויות שלנו. אנו מאמתים בקפדנות את התצורה לפני יצירת SQL:
function validateReportConfig(config: ReportConfig, metadata: EntityMeta[]): void {
const allowedFieldIds = new Set(
metadata.flatMap(e => e.fields.map(f => f.id))
);
[...config.dimensions.map(d => d.field), ...config.measures.map(m => m.field), ...config.filters.map(f => f.field)]
.forEach(field => {
if (!allowedFieldIds.has(field)) {
throw new Error(`Unknown field: ${field}`);
}
});
config.measures.forEach(m => {
const fieldMeta = metadata.flatMap(e => e.fields).find(f => f.id === m.field);
if (!fieldMeta?.aggregations?.includes(m.aggregation)) {
throw new Error(`Aggregation ${m.aggregation} not allowed for field ${m.field}`);
}
});
}שדות וטבלאות ב-SQL מגיעים אך ורק מרשימת ההיתרים — אינטרפולציה ישירה של מחרוזות מקלט משתמש אינה מותרת. אנו מבטיחים ללא הזרקת SQL.
למה הבונה שלנו מהיר יותר מחלופות
אנחנו מייעלים כל שלב: שמירת מטא-דאטה במטמון (Redis), מאגר חיבורים למסד נתונים, מגבלות תוצאות ויצירה אסינכרונית. בבדיקות (PostgreSQL 16, 32GB RAM, 8 vCPU), הבונה מעבד עד מיליון שורות ב-2 שניות — פי 3 מהר יותר מפתרונות עצמיים טיפוסיים ללא שמירה במטמון. 90% מהמשתמשים יכולים ליצור דוח תוך פחות מ-5 דקות לאחר הדרכה ראשונית.
השוואת סוגי ויזואליזציה
| סוג | מתי להשתמש | נתונים לדוגמה |
|---|---|---|
| טבלה | שדות רבים, מספרים מדויקים | רשימת הזמנות |
| גרף קווים | מגמות לאורך זמן | מכירות לפי חודש |
| גרף עמודות | השוואת קטגוריות | הכנסות לפי אזור |
| גרף עוגה | חלקים מתוך שלם | התפלגות סטטוס הזמנות |
| גרף שטח | הצטברות | מכירות לפי חנות |
דוגמת תצורת דוח: סכום הזמנות לפי תאריך עם מסנן סטטוס
{
"name": "Сумма заказов по дням",
"entity": "orders",
"dimensions": [
{
"field": "orders.created_at",
"dateTrunc": "day"
}
],
"measures": [
{
"field": "orders.total",
"aggregation": "sum",
"label": "Сумма"
}
],
"filters": [
{
"field": "orders.status",
"operator": "in",
"value": ["completed", "paid"]
}
],
"orderBy": {
"field": "orders.created_at",
"direction": "asc"
},
"visualization": "line"
}תצורה זו יוצרת SQL:
SELECT DATE_TRUNC('day', "orders"."created_at") AS "orders.created_at",
SUM("orders"."total") AS "Сумма"
FROM "orders"
WHERE "orders"."status" = ANY($1)
GROUP BY 1
ORDER BY "orders.created_at" ASC
LIMIT 10000 מה כלול ביישום?
| רכיב | תיאור |
|---|---|
| ווידג'ט צד לקוח ב-React | ממשק לבחירת שדות, מסננים, ויזואליזציות |
| שירות צד שרת ב-Nest.js | יצירת SQL, שמירה במטמון, אימות |
| מטא-דאטה | תיאור טבלאות, שדות ויחסים |
| API לשמירה/טעינת תצורות | יכולת לשמור תבניות דוחות |
| ייצוא | Excel, CSV, PDF |
| תזמון אוטומטי | שליחת דואר אלקטרוני מתוזמנת |
סקירת תהליך
| שלב | משך | תוצר |
|---|---|---|
| ניתוח דרישות | 3–5 ימים | מפרט טכני |
| עיצוב | 5–7 ימים | אב-טיפוס ממשק ומודל מטא-דאטה |
| פיתוח | 10–20 ימים | בונה עובד |
| בדיקות | 5–7 ימים | דוח בדיקות |
| פריסה והדרכה | 3–5 ימים | תיעוד משתמש |
ציר זמן
גרסה בסיסית עם ישות אחת, 5–10 שדות וטבלה — 3–4 שבועות. בונה מלא עם חיבורים, מסננים חופשיים, תזמון וניהול גרסאות — 2–3 חודשים. העלות מחושבת באופן אישי, בדרך כלל החל מ-15,000 דולר.
טעויות נפוצות ביישום
- ללא שמירת מטא-דאטה במטמון — כל שאילתה טוענת את סכמת מסד הנתונים. אנו שומרים מטא-דאטה במטמון פעם אחת בעת האתחול.
- התעלמות ממגבלות — משתמשים יכולים לבקש מיליוני שורות, ולתלות את מסד הנתונים. מגבלת ברירת מחדל של 10,000.
- קלט משתמש ישיר ב-SQL — סיכון להזרקה. רשימת היתרים של שדות פותרת זאת.
- ללא ניהול גרסאות תצורה — לא ניתן לחזור לשינויים. אנו שומרים היסטוריה לכל דוח.
קבלו ייעוץ — נעריך את הפרויקט שלכם ביום אחד. בנו בונה דוחות מותאם אישית איתנו.







