feat: import Excel → SQLite + boucle données → UI dans le VWB

- ExcelImporter : import .xlsx → SQLite auto (détection types, batch insert)
- DBIterator : lecture ligne par ligne avec filtre/tri/limite
- VWB actions : "Importer Excel" + "Pour chaque ligne" dans la palette
- DAG executor : pré-exécution import, boucle foreach avec injection
  ${current_row.colonne} dans les étapes dépendantes
- 36 tests unitaires Excel/DB (tous passent)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-16 23:10:51 +01:00
parent 5e3865d328
commit 9da804bb6e
9 changed files with 1832 additions and 4 deletions

View File

@@ -1025,6 +1025,94 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
</>
);
// === BOUCLE DONNÉES (Data Loop) ===
case 'import_excel':
return (
<>
<div className="prop-section-title">
<span className="icon">📥</span> Import Excel
</div>
<div className="prop-field">
<label>Chemin du fichier Excel</label>
<input
type="text"
value={String(params.file_path || '')}
onChange={(e) => updateParam('file_path', e.target.value)}
placeholder="/chemin/vers/fichier.xlsx"
/>
</div>
<div className="prop-field">
<label>Nom de la table (optionnel)</label>
<input
type="text"
value={String(params.table_name || '')}
onChange={(e) => updateParam('table_name', e.target.value)}
placeholder="Auto-detect depuis le nom du fichier"
/>
<small className="field-hint">Si vide, le nom de la table sera derive du nom du fichier Excel</small>
</div>
<div className="prop-field">
<label>Feuille (optionnel)</label>
<input
type="text"
value={String(params.sheet_name || '')}
onChange={(e) => updateParam('sheet_name', e.target.value)}
placeholder="Premiere feuille par defaut"
/>
<small className="field-hint">Nom ou index (0, 1, 2...) de la feuille a importer</small>
</div>
</>
);
case 'db_foreach':
return (
<>
<div className="prop-section-title">
<span className="icon">🔄</span> Boucle sur table
</div>
<div className="prop-field">
<label>Table</label>
<input
type="text"
value={String(params.table_name || '')}
onChange={(e) => updateParam('table_name', e.target.value)}
placeholder="nom_table"
/>
</div>
<div className="prop-field">
<label>Filtre WHERE (optionnel)</label>
<input
type="text"
value={String(params.where_clause || '')}
onChange={(e) => updateParam('where_clause', e.target.value)}
placeholder="Ex: statut = 'actif' AND age > 18"
/>
</div>
<div className="prop-field">
<label>Tri ORDER BY (optionnel)</label>
<input
type="text"
value={String(params.order_by || '')}
onChange={(e) => updateParam('order_by', e.target.value)}
placeholder="Ex: nom ASC, date DESC"
/>
</div>
<div className="prop-field">
<label>Limite (optionnel)</label>
<input
type="number"
value={params.limit !== undefined && params.limit !== null ? Number(params.limit) : ''}
onChange={(e) => updateParam('limit', e.target.value ? Number(e.target.value) : null)}
min="1"
placeholder="Toutes les lignes"
/>
</div>
<div className="prop-info">
Les colonnes sont accessibles via <code>{'${current_row.nom_colonne}'}</code> dans les etapes suivantes.
</div>
</>
);
// === VALIDATION ===
case 'verify_element_exists':
return (

View File

@@ -13,9 +13,11 @@ function StepNode({ data, selected }: StepNodeProps) {
const step = data.step;
const action = ACTIONS.find(a => a.type === step.action_type);
const isConditional = step.action_type === 'visual_condition' || step.action_type === 'loop_visual';
const isDataLoop = step.action_type === 'db_foreach';
const isImport = step.action_type === 'import_excel';
return (
<div className={`step-node ${selected ? 'selected' : ''} ${isConditional ? 'conditional' : ''}`}>
<div className={`step-node ${selected ? 'selected' : ''} ${isConditional ? 'conditional' : ''} ${isDataLoop ? 'data-loop' : ''} ${isImport ? 'data-import' : ''}`}>
{/* Entrée: haut */}
<Handle
type="target"
@@ -62,6 +64,22 @@ function StepNode({ data, selected }: StepNodeProps) {
</div>
)}
{/* Aperçu import Excel */}
{step.action_type === 'import_excel' && typeof step.parameters?.file_path === 'string' && step.parameters.file_path.length > 0 && (
<div className="step-node-params">
{`📄 ${String(step.parameters.file_path).split('/').pop()?.split('\\').pop() || String(step.parameters.file_path)}`}
{step.parameters.table_name ? `${String(step.parameters.table_name)}` : ''}
</div>
)}
{/* Aperçu boucle db_foreach */}
{step.action_type === 'db_foreach' && typeof step.parameters?.table_name === 'string' && step.parameters.table_name.length > 0 && (
<div className="step-node-params">
{`🗃️ ${String(step.parameters.table_name)}`}
{step.parameters.limit ? ` (max ${String(step.parameters.limit)})` : ''}
</div>
)}
{!step.anchor_id && action?.needsAnchor && (
<div className="step-node-warning">
Ancre requise

View File

@@ -47,6 +47,8 @@ export type ActionType =
| 'ai_custom'
| 'db_save_data'
| 'db_read_data'
| 'import_excel'
| 'db_foreach'
| 'verify_element_exists'
| 'verify_text_content'
// === DAG LLM — étapes IA exécutées via le DAGExecutor ===
@@ -104,6 +106,10 @@ export const ACTIONS: ActionDefinition[] = [
{ type: 'db_save_data', label: 'Sauvegarder en BDD', icon: '💿', category: 'data', needsAnchor: false, params: ['table', 'data'] },
{ type: 'db_read_data', label: 'Lire depuis BDD', icon: '📖', category: 'data', needsAnchor: false, params: ['query', 'variable_name'] },
// === BOUCLE DONNÉES (Data Loop) ===
{ type: 'import_excel', label: 'Importer Excel', icon: '📥', category: 'data', needsAnchor: false, params: ['file_path', 'table_name', 'sheet_name'] },
{ type: 'db_foreach', label: 'Pour chaque ligne', icon: '🔄', category: 'data', needsAnchor: false, params: ['table_name', 'where_clause', 'order_by', 'limit'] },
// === DAG LLM — Actions IA via DAGExecutor (parallèle, Ollama) ===
{ type: 'llm_analyze', label: 'Analyser texte', icon: '🔬', category: 'llm', needsAnchor: false, params: ['text', 'instruction', 'model'] },
{ type: 'llm_translate', label: 'Traduire', icon: '🌐', category: 'llm', needsAnchor: false, params: ['text', 'target_lang', 'model'] },