diff --git a/CLAUDE.md b/CLAUDE.md
index eb37931..8f3cd6a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,13 +6,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**System:** Import Comenzi Web โ Sistem ROA Oracle
-This is a multi-tier system that automatically imports orders from web platforms (GoMag, etc.) into the ROA Oracle ERP system. The project combines Oracle PL/SQL packages, Visual FoxPro orchestration, and a Flask web admin interface for SKU mapping management.
+This is a multi-tier system that automatically imports orders from web platforms (GoMag, etc.) into the ROA Oracle ERP system. The project combines Oracle PL/SQL packages, Visual FoxPro orchestration, and a FastAPI web admin/dashboard interface.
-**Current Status:** Phase 1 (Database Foundation) - 75% Complete
-- โ
P1-001: ARTICOLE_TERTI table created
-- โ
P1-002: IMPORT_PARTENERI package complete
-- โ
P1-003: IMPORT_COMENZI package complete
-- ๐ P1-004: Manual testing packages (NEXT UP)
+**Current Status:** Phase 4 Complete, Phase 5 In Progress
+- โ
Phase 1: Database Foundation (ARTICOLE_TERTI, IMPORT_PARTENERI, IMPORT_COMENZI)
+- โ
Phase 2: VFP Integration (gomag-vending.prg, sync-comenzi-web.prg)
+- โ
Phase 3-4: FastAPI Admin + Dashboard (mappings CRUD, sync orchestration, pre-validation)
+- ๐ Phase 5: Production (file logging done, auth + notifications pending)
## Architecture
@@ -25,8 +25,8 @@ This is a multi-tier system that automatically imports orders from web platforms
### Tech Stack
- **Backend:** Oracle PL/SQL packages
- **Integration:** Visual FoxPro 9
-- **Admin Interface:** Flask + Oracle connection pool
-- **Data:** Oracle 11g/12c (ROA system)
+- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite
+- **Data:** Oracle 11g/12c (ROA system), SQLite (local tracking)
## Core Components
@@ -87,15 +87,18 @@ CREATE TABLE ARTICOLE_TERTI (
);
```
-### Flask Admin Interface
+### FastAPI Admin/Dashboard
-#### admin.py
-**Location:** `api/admin.py`
+#### app/main.py
+**Location:** `api/app/main.py`
**Features:**
-- Oracle connection pool management
-- SKU mappings CRUD operations
-- Web interface for configuration
-- Real-time validation
+- FastAPI with lifespan (Oracle pool + SQLite init)
+- File logging to `logs/sync_comenzi_YYYYMMDD_HHMMSS.log`
+- Routers: health, dashboard, mappings, articles, validation, sync
+- Services: mapping, article, import, sync, validation, order_reader, sqlite, scheduler
+- Templates: Jinja2 (dashboard, mappings, sync_detail, missing_skus)
+- Dual database: Oracle (ERP data) + SQLite (tracking)
+- APScheduler for periodic sync
## Development Commands
@@ -115,10 +118,17 @@ sqlplus CONTAFIN_ORACLE/password@ROA_ROMFAST @03_import_comenzi.sql
DO vfp/gomag-vending.prg
```
-### Flask Admin Interface
+### FastAPI Admin/Dashboard
```bash
cd api
-python admin.py
+pip install -r requirements.txt
+uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
+```
+
+### Testare
+```bash
+python api/test_app_basic.py # Test A - fara Oracle
+python api/test_integration.py # Test C - cu Oracle
```
## Project Structure
diff --git a/README.md b/README.md
index c3bc679..e40757f 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,128 @@
-# gomag-vending
+# GoMag Vending - Import Comenzi Web โ ROA Oracle
+System automat de import comenzi din platforma GoMag in sistemul ERP ROA Oracle.
+
+## Arhitectura
+
+```
+[GoMag API] โ [VFP Orchestrator] โ [Oracle PL/SQL] โ [FastAPI Admin]
+ โ โ โ โ
+ JSON Orders Process & Log Store/Update Dashboard + Config
+```
+
+### Stack Tehnologic
+- **Database:** Oracle PL/SQL packages (PACK_IMPORT_PARTENERI, PACK_IMPORT_COMENZI)
+- **Integrare:** Visual FoxPro 9 (gomag-vending.prg, sync-comenzi-web.prg)
+- **Admin/Dashboard:** FastAPI + Jinja2 + Oracle pool + SQLite
+- **Date:** Oracle 11g/12c (schema ROA), SQLite (tracking local)
+
+## Quick Start
+
+### Prerequisite
+- Python 3.10+
+- Oracle Instant Client (optional - suporta si thin mode)
+
+### Instalare si pornire
+```bash
+cd api
+pip install -r requirements.txt
+# Configureaza .env (vezi api/.env.example)
+uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
+```
+
+Deschide `http://localhost:5003` in browser.
+
+### Testare
+
+**Test A - Basic (fara Oracle):**
+```bash
+cd api
+python test_app_basic.py
+```
+Verifica 17 importuri de module + 13 rute GET. Asteptat: 30/30 PASS.
+
+**Test C - Integrare Oracle:**
+```bash
+python api/test_integration.py
+```
+Necesita Oracle activ. Verifica health, mappings CRUD, article search, validation, sync. Asteptat: 9/9 PASS.
+
+## Structura Proiect
+
+```
+/
+โโโ api/ # FastAPI Admin + Database
+โ โโโ app/ # Aplicatia FastAPI
+โ โ โโโ main.py # Entry point, lifespan, logging
+โ โ โโโ config.py # Settings (pydantic-settings, .env)
+โ โ โโโ database.py # Oracle pool + SQLite init
+โ โ โโโ routers/ # Endpoint-uri HTTP
+โ โ โ โโโ health.py # /health, /api/health
+โ โ โ โโโ dashboard.py # / (dashboard HTML)
+โ โ โ โโโ mappings.py # /mappings, /api/mappings
+โ โ โ โโโ articles.py # /api/articles/search
+โ โ โ โโโ validation.py # /api/validate/*
+โ โ โ โโโ sync.py # /api/sync/*
+โ โ โโโ services/ # Business logic
+โ โ โ โโโ mapping_service # CRUD ARTICOLE_TERTI
+โ โ โ โโโ article_service # Cautare NOM_ARTICOLE
+โ โ โ โโโ import_service # Import comanda in Oracle
+โ โ โ โโโ sync_service # Orchestrare: JSONโvalidateโimport
+โ โ โ โโโ validation_service # Validare SKU-uri
+โ โ โ โโโ order_reader # Citire JSON-uri din vfp/output/
+โ โ โ โโโ sqlite_service # Tracking runs/orders/missing SKUs
+โ โ โ โโโ scheduler_service # APScheduler timer
+โ โ โโโ templates/ # Jinja2 HTML (dashboard, mappings, etc.)
+โ โ โโโ static/ # CSS + JS
+โ โโโ database-scripts/ # Oracle SQL scripts
+โ โโโ test_app_basic.py # Test A - fara Oracle
+โ โโโ test_integration.py # Test C - cu Oracle
+โ โโโ requirements.txt # Python dependencies
+โโโ vfp/ # VFP Integration
+โ โโโ gomag-vending.prg # Client GoMag API
+โ โโโ sync-comenzi-web.prg # Orchestrator VFP
+โ โโโ utils.prg # Utilitare VFP
+โโโ docs/ # Documentatie
+โ โโโ PRD.md # Product Requirements
+โ โโโ stories/ # User Stories
+โโโ logs/ # Log-uri aplicatie
+```
+
+## Configurare (.env)
+
+```env
+ORACLE_USER=MARIUSM_AUTO
+ORACLE_PASSWORD=********
+ORACLE_DSN=ROA_CENTRAL
+FORCE_THIN_MODE=true # sau INSTANTCLIENTPATH=C:\oracle\instantclient
+SQLITE_DB_PATH=data/import.db
+APP_PORT=5003
+LOG_LEVEL=INFO
+JSON_OUTPUT_DIR=../vfp/output
+```
+
+## Status Implementare
+
+### Phase 1: Database Foundation - COMPLET
+- ARTICOLE_TERTI table + Docker setup
+- PACK_IMPORT_PARTENERI package
+- PACK_IMPORT_COMENZI package
+
+### Phase 2: VFP Integration - COMPLET
+- gomag-vending.prg (GoMag API client)
+- sync-comenzi-web.prg (orchestrator cu logging)
+
+### Phase 3-4: FastAPI Admin + Dashboard - COMPLET
+- Mappings CRUD + CSV import/export
+- Article autocomplete (NOM_ARTICOLE)
+- Pre-validation SKU-uri
+- Import orchestration (JSONโOracle)
+- Dashboard cu stat cards, sync control, history
+- Missing SKUs management page
+- File logging (logs/sync_comenzi_*.log)
+
+### Phase 5: Production - IN PROGRESS
+- [x] File logging
+- [ ] Email notifications (SMTP)
+- [ ] HTTP Basic Auth
+- [ ] NSSM Windows service
diff --git a/api/README.md b/api/README.md
index 660bf8a..c9cb107 100644
--- a/api/README.md
+++ b/api/README.md
@@ -1,41 +1,57 @@
-# API Directory - Phase 1 Complete
+# GoMag Import Manager - FastAPI Application
-## Core Files
+Admin interface si orchestrator pentru importul comenzilor GoMag in Oracle ROA.
-### ๐๏ธ `admin.py`
-**Purpose:** Flask web admin interface pentru mapฤri SKU
-- Oracle connection pool management
-- CRUD operations pentru ARTICOLE_TERTI
-- Web interface pentru configurare mapฤri
-- **Port:** 5000 (configurable)
+## Componente
-### ๐งช `tests/`
-**Purpose:** Directory cu toate testele ศi utilitฤศile validation
-- `final_validation.py` - Ultimate P1-004 validation script
-- `test_final_success.py` - Complete end-to-end test
-- `test_syntax.py` - Package compilation checker
-- `check_packages.py` - Package status utility
-- `check_table_structure.py` - Schema validation utility
-- `README.md` - Documentation pentru toate testele
+### Core
+- **main.py** - Entry point FastAPI, lifespan (Oracle pool + SQLite init), file logging
+- **config.py** - Settings via pydantic-settings (citeste .env)
+- **database.py** - Oracle connection pool + SQLite schema + helpers
-## Configuration Files
+### Routers (HTTP Endpoints)
+| Router | Prefix | Descriere |
+|--------|--------|-----------|
+| health | /health, /api/health | Status Oracle + SQLite |
+| dashboard | / | Dashboard HTML cu stat cards |
+| mappings | /mappings, /api/mappings | CRUD ARTICOLE_TERTI + CSV |
+| articles | /api/articles | Cautare NOM_ARTICOLE |
+| validation | /api/validate | Scanare + validare SKU-uri |
+| sync | /sync, /api/sync | Import orchestration + scheduler |
-### ๐ `database-scripts/`
-- `01_create_table.sql` - ARTICOLE_TERTI table
-- `02_import_parteneri.sql` - PACK_IMPORT_PARTENERI package
-- `04_import_comenzi.sql` - PACK_IMPORT_COMENZI package
+### Services (Business Logic)
+| Service | Rol |
+|---------|-----|
+| mapping_service | CRUD pe ARTICOLE_TERTI (Oracle) |
+| article_service | Cautare in NOM_ARTICOLE (Oracle) |
+| import_service | Port din VFP: partner/address/order creation |
+| sync_service | Orchestrare: read JSONs โ validate โ import โ log |
+| validation_service | Batch-validare SKU-uri (chunks of 500) |
+| order_reader | Citire gomag_orders_page*.json din vfp/output/ |
+| sqlite_service | CRUD pe SQLite (sync_runs, import_orders, missing_skus) |
+| scheduler_service | APScheduler - sync periodic configurabil din UI |
-### ๐ณ `docker-compose.yaml`
-Oracle container orchestration
+## Rulare
-### ๐ง `.env`
-Environment variables pentru MARIUSM_AUTO schema
+```bash
+pip install -r requirements.txt
+uvicorn app.main:app --host 0.0.0.0 --port 5003 --reload
+```
-### ๐ `requirements.txt`
-Python dependencies (oracledb, flask, etc.)
+## Testare
----
+```bash
+# Test A - fara Oracle (verifica importuri + rute)
+python test_app_basic.py
-**Phase 1 Status:** โ
100% COMPLETE
-**Ready for:** Phase 2 VFP Integration
-**Cleanup Date:** 10 septembrie 2025, 12:57
\ No newline at end of file
+# Test C - cu Oracle (integrare completa)
+python test_integration.py
+```
+
+## Dual Database
+- **Oracle** - date ERP (ARTICOLE_TERTI, NOM_ARTICOLE, COMENZI)
+- **SQLite** - tracking local (sync_runs, import_orders, missing_skus, scheduler_config)
+
+## Logging
+Log files in `../logs/sync_comenzi_YYYYMMDD_HHMMSS.log`
+Format: `2026-03-11 14:30:25 | INFO | app.services.sync_service | mesaj`
diff --git a/api/app/__init__.py b/api/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/api/app/config.py b/api/app/config.py
new file mode 100644
index 0000000..cba1372
--- /dev/null
+++ b/api/app/config.py
@@ -0,0 +1,35 @@
+from pydantic_settings import BaseSettings
+from pathlib import Path
+import os
+
+class Settings(BaseSettings):
+ # Oracle
+ ORACLE_USER: str = "MARIUSM_AUTO"
+ ORACLE_PASSWORD: str = "ROMFASTSOFT"
+ ORACLE_DSN: str = "ROA_CENTRAL"
+ INSTANTCLIENTPATH: str = ""
+ FORCE_THIN_MODE: bool = False
+ TNS_ADMIN: str = ""
+
+ # SQLite
+ SQLITE_DB_PATH: str = str(Path(__file__).parent.parent / "data" / "import.db")
+
+ # App
+ APP_PORT: int = 5003
+ LOG_LEVEL: str = "INFO"
+ JSON_OUTPUT_DIR: str = ""
+
+ # SMTP (optional)
+ SMTP_HOST: str = ""
+ SMTP_PORT: int = 587
+ SMTP_USER: str = ""
+ SMTP_PASSWORD: str = ""
+ SMTP_TO: str = ""
+
+ # Auth (optional)
+ API_USERNAME: str = ""
+ API_PASSWORD: str = ""
+
+ model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
+
+settings = Settings()
diff --git a/api/app/database.py b/api/app/database.py
new file mode 100644
index 0000000..d108631
--- /dev/null
+++ b/api/app/database.py
@@ -0,0 +1,135 @@
+import oracledb
+import aiosqlite
+import sqlite3
+import logging
+import os
+from pathlib import Path
+from .config import settings
+
+logger = logging.getLogger(__name__)
+
+# ---- Oracle Pool ----
+pool = None
+
+def init_oracle():
+ """Initialize Oracle client mode and create connection pool."""
+ global pool
+
+ force_thin = settings.FORCE_THIN_MODE
+ instantclient_path = settings.INSTANTCLIENTPATH
+ dsn = settings.ORACLE_DSN
+
+ if force_thin:
+ logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}")
+ elif instantclient_path:
+ try:
+ oracledb.init_oracle_client(lib_dir=instantclient_path)
+ logger.info(f"Thick mode activated for {dsn}")
+ except Exception as e:
+ logger.error(f"Thick mode error: {e}")
+ logger.info("Fallback to thin mode")
+ else:
+ logger.info(f"Thin mode (default) for {dsn}")
+
+ pool = oracledb.create_pool(
+ user=settings.ORACLE_USER,
+ password=settings.ORACLE_PASSWORD,
+ dsn=settings.ORACLE_DSN,
+ min=2,
+ max=4,
+ increment=1
+ )
+ logger.info(f"Oracle pool created for {dsn}")
+ return pool
+
+def get_oracle_connection():
+ """Get a connection from the Oracle pool."""
+ if pool is None:
+ raise RuntimeError("Oracle pool not initialized")
+ return pool.acquire()
+
+def close_oracle():
+ """Close the Oracle connection pool."""
+ global pool
+ if pool:
+ pool.close()
+ pool = None
+ logger.info("Oracle pool closed")
+
+# ---- SQLite ----
+SQLITE_SCHEMA = """
+CREATE TABLE IF NOT EXISTS sync_runs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ run_id TEXT UNIQUE,
+ started_at TEXT,
+ finished_at TEXT,
+ status TEXT,
+ total_orders INTEGER DEFAULT 0,
+ imported INTEGER DEFAULT 0,
+ skipped INTEGER DEFAULT 0,
+ errors INTEGER DEFAULT 0,
+ json_files INTEGER DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS import_orders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sync_run_id TEXT REFERENCES sync_runs(run_id),
+ order_number TEXT,
+ order_date TEXT,
+ customer_name TEXT,
+ status TEXT,
+ id_comanda INTEGER,
+ id_partener INTEGER,
+ error_message TEXT,
+ missing_skus TEXT,
+ items_count INTEGER,
+ created_at TEXT DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS missing_skus (
+ sku TEXT PRIMARY KEY,
+ product_name TEXT,
+ first_seen TEXT DEFAULT (datetime('now')),
+ resolved INTEGER DEFAULT 0,
+ resolved_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS scheduler_config (
+ key TEXT PRIMARY KEY,
+ value TEXT
+);
+"""
+
+_sqlite_db_path = None
+
+def init_sqlite():
+ """Initialize SQLite database with schema."""
+ global _sqlite_db_path
+ _sqlite_db_path = settings.SQLITE_DB_PATH
+
+ # Ensure directory exists
+ db_dir = os.path.dirname(_sqlite_db_path)
+ if db_dir:
+ os.makedirs(db_dir, exist_ok=True)
+
+ # Create tables synchronously
+ conn = sqlite3.connect(_sqlite_db_path)
+ conn.executescript(SQLITE_SCHEMA)
+ conn.close()
+ logger.info(f"SQLite initialized: {_sqlite_db_path}")
+
+async def get_sqlite():
+ """Get async SQLite connection."""
+ if _sqlite_db_path is None:
+ raise RuntimeError("SQLite not initialized")
+ db = await aiosqlite.connect(_sqlite_db_path)
+ db.row_factory = aiosqlite.Row
+ return db
+
+def get_sqlite_sync():
+ """Get synchronous SQLite connection."""
+ if _sqlite_db_path is None:
+ raise RuntimeError("SQLite not initialized")
+ conn = sqlite3.connect(_sqlite_db_path)
+ conn.row_factory = sqlite3.Row
+ return conn
diff --git a/api/app/main.py b/api/app/main.py
new file mode 100644
index 0000000..611cad6
--- /dev/null
+++ b/api/app/main.py
@@ -0,0 +1,91 @@
+from contextlib import asynccontextmanager
+from datetime import datetime
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from pathlib import Path
+import logging
+import os
+
+from .config import settings
+from .database import init_oracle, close_oracle, init_sqlite
+
+# Configure logging with both stream and file handlers
+_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
+_log_format = '%(asctime)s | %(levelname)s | %(name)s | %(message)s'
+_formatter = logging.Formatter(_log_format)
+
+_stream_handler = logging.StreamHandler()
+_stream_handler.setFormatter(_formatter)
+
+_log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs')
+os.makedirs(_log_dir, exist_ok=True)
+_log_filename = f"sync_comenzi_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
+_file_handler = logging.FileHandler(os.path.join(_log_dir, _log_filename), encoding='utf-8')
+_file_handler.setFormatter(_formatter)
+
+_root_logger = logging.getLogger()
+_root_logger.setLevel(_log_level)
+_root_logger.addHandler(_stream_handler)
+_root_logger.addHandler(_file_handler)
+
+logger = logging.getLogger(__name__)
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """Startup and shutdown events."""
+ logger.info("Starting GoMag Import Manager...")
+
+ # Initialize Oracle pool
+ try:
+ init_oracle()
+ except Exception as e:
+ logger.error(f"Oracle init failed: {e}")
+ # Allow app to start even without Oracle for development
+
+ # Initialize SQLite
+ init_sqlite()
+
+ # Initialize scheduler (restore saved config)
+ from .services import scheduler_service, sqlite_service
+ scheduler_service.init_scheduler()
+ try:
+ config = await sqlite_service.get_scheduler_config()
+ if config.get("enabled") == "True":
+ interval = int(config.get("interval_minutes", "5"))
+ scheduler_service.start_scheduler(interval)
+ except Exception:
+ pass
+
+ logger.info("GoMag Import Manager started")
+ yield
+
+ # Shutdown
+ scheduler_service.shutdown_scheduler()
+ close_oracle()
+ logger.info("GoMag Import Manager stopped")
+
+app = FastAPI(
+ title="GoMag Import Manager",
+ description="Import comenzi web GoMag โ ROA Oracle",
+ version="1.0.0",
+ lifespan=lifespan
+)
+
+# Static files and templates
+static_dir = Path(__file__).parent / "static"
+templates_dir = Path(__file__).parent / "templates"
+static_dir.mkdir(parents=True, exist_ok=True)
+(static_dir / "css").mkdir(exist_ok=True)
+(static_dir / "js").mkdir(exist_ok=True)
+templates_dir.mkdir(parents=True, exist_ok=True)
+
+app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+
+# Include routers
+from .routers import health, dashboard, mappings, articles, validation, sync
+app.include_router(health.router)
+app.include_router(dashboard.router)
+app.include_router(mappings.router)
+app.include_router(articles.router)
+app.include_router(validation.router)
+app.include_router(sync.router)
diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/api/app/routers/articles.py b/api/app/routers/articles.py
new file mode 100644
index 0000000..b040331
--- /dev/null
+++ b/api/app/routers/articles.py
@@ -0,0 +1,10 @@
+from fastapi import APIRouter, Query
+
+from ..services import article_service
+
+router = APIRouter(prefix="/api/articles", tags=["articles"])
+
+@router.get("/search")
+def search_articles(q: str = Query("", min_length=2)):
+ results = article_service.search_articles(q)
+ return {"results": results}
diff --git a/api/app/routers/dashboard.py b/api/app/routers/dashboard.py
new file mode 100644
index 0000000..be5dc97
--- /dev/null
+++ b/api/app/routers/dashboard.py
@@ -0,0 +1,17 @@
+from fastapi import APIRouter, Request
+from fastapi.templating import Jinja2Templates
+from fastapi.responses import HTMLResponse
+from pathlib import Path
+
+from ..services import sqlite_service
+
+router = APIRouter()
+templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
+
+@router.get("/", response_class=HTMLResponse)
+async def dashboard(request: Request):
+ return templates.TemplateResponse("dashboard.html", {"request": request})
+
+@router.get("/missing-skus", response_class=HTMLResponse)
+async def missing_skus_page(request: Request):
+ return templates.TemplateResponse("missing_skus.html", {"request": request})
diff --git a/api/app/routers/health.py b/api/app/routers/health.py
new file mode 100644
index 0000000..7d2f94b
--- /dev/null
+++ b/api/app/routers/health.py
@@ -0,0 +1,30 @@
+from fastapi import APIRouter
+from .. import database
+
+router = APIRouter()
+
+@router.get("/health")
+async def health_check():
+ result = {"oracle": "error", "sqlite": "error"}
+
+ # Check Oracle
+ try:
+ if database.pool:
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ cur.execute("SELECT SYSDATE FROM DUAL")
+ cur.fetchone()
+ result["oracle"] = "ok"
+ except Exception as e:
+ result["oracle"] = str(e)
+
+ # Check SQLite
+ try:
+ db = await database.get_sqlite()
+ await db.execute("SELECT 1")
+ await db.close()
+ result["sqlite"] = "ok"
+ except Exception as e:
+ result["sqlite"] = str(e)
+
+ return result
diff --git a/api/app/routers/mappings.py b/api/app/routers/mappings.py
new file mode 100644
index 0000000..13be8aa
--- /dev/null
+++ b/api/app/routers/mappings.py
@@ -0,0 +1,84 @@
+from fastapi import APIRouter, Query, Request, UploadFile, File
+from fastapi.responses import StreamingResponse, HTMLResponse
+from fastapi.templating import Jinja2Templates
+from pydantic import BaseModel
+from pathlib import Path
+from typing import Optional
+import io
+
+from ..services import mapping_service, sqlite_service
+
+router = APIRouter(tags=["mappings"])
+templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
+
+class MappingCreate(BaseModel):
+ sku: str
+ codmat: str
+ cantitate_roa: float = 1
+ procent_pret: float = 100
+
+class MappingUpdate(BaseModel):
+ cantitate_roa: Optional[float] = None
+ procent_pret: Optional[float] = None
+ activ: Optional[int] = None
+
+# HTML page
+@router.get("/mappings", response_class=HTMLResponse)
+async def mappings_page(request: Request):
+ return templates.TemplateResponse("mappings.html", {"request": request})
+
+# API endpoints
+@router.get("/api/mappings")
+def list_mappings(search: str = "", page: int = 1, per_page: int = 50):
+ return mapping_service.get_mappings(search=search, page=page, per_page=per_page)
+
+@router.post("/api/mappings")
+async def create_mapping(data: MappingCreate):
+ try:
+ result = mapping_service.create_mapping(data.sku, data.codmat, data.cantitate_roa, data.procent_pret)
+ # Mark SKU as resolved in missing_skus tracking
+ await sqlite_service.resolve_missing_sku(data.sku)
+ return {"success": True, **result}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+@router.put("/api/mappings/{sku}/{codmat}")
+def update_mapping(sku: str, codmat: str, data: MappingUpdate):
+ try:
+ updated = mapping_service.update_mapping(sku, codmat, data.cantitate_roa, data.procent_pret, data.activ)
+ return {"success": updated}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+@router.delete("/api/mappings/{sku}/{codmat}")
+def delete_mapping(sku: str, codmat: str):
+ try:
+ deleted = mapping_service.delete_mapping(sku, codmat)
+ return {"success": deleted}
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+@router.post("/api/mappings/import-csv")
+async def import_csv(file: UploadFile = File(...)):
+ content = await file.read()
+ text = content.decode("utf-8-sig")
+ result = mapping_service.import_csv(text)
+ return result
+
+@router.get("/api/mappings/export-csv")
+def export_csv():
+ csv_content = mapping_service.export_csv()
+ return StreamingResponse(
+ io.BytesIO(csv_content.encode("utf-8-sig")),
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=mappings.csv"}
+ )
+
+@router.get("/api/mappings/csv-template")
+def csv_template():
+ content = mapping_service.get_csv_template()
+ return StreamingResponse(
+ io.BytesIO(content.encode("utf-8-sig")),
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=mappings_template.csv"}
+ )
diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py
new file mode 100644
index 0000000..5cad174
--- /dev/null
+++ b/api/app/routers/sync.py
@@ -0,0 +1,90 @@
+from fastapi import APIRouter, Request, BackgroundTasks
+from fastapi.templating import Jinja2Templates
+from fastapi.responses import HTMLResponse
+from pydantic import BaseModel
+from pathlib import Path
+from typing import Optional
+
+from ..services import sync_service, scheduler_service, sqlite_service
+
+router = APIRouter(tags=["sync"])
+templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
+
+
+class ScheduleConfig(BaseModel):
+ enabled: bool
+ interval_minutes: int = 5
+
+
+# HTML pages
+@router.get("/sync", response_class=HTMLResponse)
+async def sync_page(request: Request):
+ return templates.TemplateResponse("dashboard.html", {"request": request})
+
+
+@router.get("/sync/run/{run_id}", response_class=HTMLResponse)
+async def sync_detail_page(request: Request, run_id: str):
+ return templates.TemplateResponse("sync_detail.html", {"request": request, "run_id": run_id})
+
+
+# API endpoints
+@router.post("/api/sync/start")
+async def start_sync(background_tasks: BackgroundTasks):
+ """Trigger a sync run in the background."""
+ status = await sync_service.get_sync_status()
+ if status.get("status") == "running":
+ return {"error": "Sync already running", "run_id": status.get("run_id")}
+
+ background_tasks.add_task(sync_service.run_sync)
+ return {"message": "Sync started"}
+
+
+@router.post("/api/sync/stop")
+async def stop_sync():
+ """Stop a running sync."""
+ sync_service.stop_sync()
+ return {"message": "Stop signal sent"}
+
+
+@router.get("/api/sync/status")
+async def sync_status():
+ """Get current sync status."""
+ status = await sync_service.get_sync_status()
+ stats = await sqlite_service.get_dashboard_stats()
+ return {**status, "stats": stats}
+
+
+@router.get("/api/sync/history")
+async def sync_history(page: int = 1, per_page: int = 20):
+ """Get sync run history."""
+ return await sqlite_service.get_sync_runs(page, per_page)
+
+
+@router.get("/api/sync/run/{run_id}")
+async def sync_run_detail(run_id: str):
+ """Get details for a specific sync run."""
+ detail = await sqlite_service.get_sync_run_detail(run_id)
+ if not detail:
+ return {"error": "Run not found"}
+ return detail
+
+
+@router.put("/api/sync/schedule")
+async def update_schedule(config: ScheduleConfig):
+ """Update scheduler configuration."""
+ if config.enabled:
+ scheduler_service.start_scheduler(config.interval_minutes)
+ else:
+ scheduler_service.stop_scheduler()
+
+ # Persist config
+ await sqlite_service.set_scheduler_config("enabled", str(config.enabled))
+ await sqlite_service.set_scheduler_config("interval_minutes", str(config.interval_minutes))
+
+ return scheduler_service.get_scheduler_status()
+
+
+@router.get("/api/sync/schedule")
+async def get_schedule():
+ """Get current scheduler status."""
+ return scheduler_service.get_scheduler_status()
diff --git a/api/app/routers/validation.py b/api/app/routers/validation.py
new file mode 100644
index 0000000..a81a70a
--- /dev/null
+++ b/api/app/routers/validation.py
@@ -0,0 +1,111 @@
+import csv
+import io
+from fastapi import APIRouter
+from fastapi.responses import StreamingResponse
+
+from ..services import order_reader, validation_service
+from ..database import get_sqlite
+
+router = APIRouter(prefix="/api/validate", tags=["validation"])
+
+@router.post("/scan")
+async def scan_and_validate():
+ """Scan JSON files and validate all SKUs."""
+ orders, json_count = order_reader.read_json_orders()
+
+ if not orders:
+ return {"orders": 0, "json_files": json_count, "skus": {}, "message": "No orders found"}
+
+ all_skus = order_reader.get_all_skus(orders)
+ result = validation_service.validate_skus(all_skus)
+ importable, skipped = validation_service.classify_orders(orders, result)
+
+ # Track missing SKUs in SQLite
+ db = await get_sqlite()
+ try:
+ for sku in result["missing"]:
+ # Find product name from orders
+ product_name = ""
+ for order in orders:
+ for item in order.items:
+ if item.sku == sku:
+ product_name = item.name
+ break
+ if product_name:
+ break
+
+ await db.execute("""
+ INSERT OR IGNORE INTO missing_skus (sku, product_name)
+ VALUES (?, ?)
+ """, (sku, product_name))
+ await db.commit()
+ finally:
+ await db.close()
+
+ return {
+ "json_files": json_count,
+ "total_orders": len(orders),
+ "total_skus": len(all_skus),
+ "importable": len(importable),
+ "skipped": len(skipped),
+ "skus": {
+ "mapped": len(result["mapped"]),
+ "direct": len(result["direct"]),
+ "missing": len(result["missing"]),
+ "missing_list": sorted(result["missing"])
+ },
+ "skipped_orders": [
+ {
+ "number": order.number,
+ "customer": order.billing.company_name or f"{order.billing.firstname} {order.billing.lastname}",
+ "items_count": len(order.items),
+ "missing_skus": missing
+ }
+ for order, missing in skipped[:50] # limit to 50
+ ]
+ }
+
+@router.get("/missing-skus")
+async def get_missing_skus():
+ """Get all tracked missing SKUs."""
+ db = await get_sqlite()
+ try:
+ cursor = await db.execute("""
+ SELECT sku, product_name, first_seen, resolved, resolved_at
+ FROM missing_skus
+ ORDER BY resolved ASC, first_seen DESC
+ """)
+ rows = await cursor.fetchall()
+ return {
+ "missing_skus": [dict(row) for row in rows],
+ "total": len(rows),
+ "unresolved": sum(1 for r in rows if not r["resolved"])
+ }
+ finally:
+ await db.close()
+
+@router.get("/missing-skus-csv")
+async def export_missing_skus_csv():
+ """Export missing SKUs as CSV."""
+ db = await get_sqlite()
+ try:
+ cursor = await db.execute("""
+ SELECT sku, product_name, first_seen, resolved
+ FROM missing_skus WHERE resolved = 0
+ ORDER BY first_seen DESC
+ """)
+ rows = await cursor.fetchall()
+ finally:
+ await db.close()
+
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerow(["sku", "product_name", "first_seen"])
+ for row in rows:
+ writer.writerow([row["sku"], row["product_name"], row["first_seen"]])
+
+ return StreamingResponse(
+ io.BytesIO(output.getvalue().encode("utf-8-sig")),
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=missing_skus.csv"}
+ )
diff --git a/api/app/services/__init__.py b/api/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/api/app/services/article_service.py b/api/app/services/article_service.py
new file mode 100644
index 0000000..f6dc8d9
--- /dev/null
+++ b/api/app/services/article_service.py
@@ -0,0 +1,27 @@
+import logging
+from fastapi import HTTPException
+from .. import database
+
+logger = logging.getLogger(__name__)
+
+def search_articles(query: str, limit: int = 20):
+ """Search articles in NOM_ARTICOLE by codmat or denumire."""
+ if database.pool is None:
+ raise HTTPException(status_code=503, detail="Oracle unavailable")
+
+ if not query or len(query) < 2:
+ return []
+
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ cur.execute("""
+ SELECT id_articol, codmat, denumire
+ FROM nom_articole
+ WHERE (UPPER(codmat) LIKE UPPER(:q) || '%'
+ OR UPPER(denumire) LIKE '%' || UPPER(:q) || '%')
+ AND ROWNUM <= :lim
+ ORDER BY CASE WHEN UPPER(codmat) LIKE UPPER(:q) || '%' THEN 0 ELSE 1 END, codmat
+ """, {"q": query, "lim": limit})
+
+ columns = [col[0].lower() for col in cur.description]
+ return [dict(zip(columns, row)) for row in cur.fetchall()]
diff --git a/api/app/services/import_service.py b/api/app/services/import_service.py
new file mode 100644
index 0000000..f24f7ae
--- /dev/null
+++ b/api/app/services/import_service.py
@@ -0,0 +1,192 @@
+import html
+import json
+import logging
+import oracledb
+from datetime import datetime, timedelta
+from .. import database
+
+logger = logging.getLogger(__name__)
+
+# Diacritics to ASCII mapping (Romanian)
+_DIACRITICS = str.maketrans({
+ '\u0103': 'a', # ฤ
+ '\u00e2': 'a', # รข
+ '\u00ee': 'i', # รฎ
+ '\u0219': 's', # ศ
+ '\u021b': 't', # ศ
+ '\u0102': 'A', # ฤ
+ '\u00c2': 'A', # ร
+ '\u00ce': 'I', # ร
+ '\u0218': 'S', # ศ
+ '\u021a': 'T', # ศ
+ # Older Unicode variants
+ '\u015f': 's', # ล (cedilla)
+ '\u0163': 't', # ลฃ (cedilla)
+ '\u015e': 'S', # ล
+ '\u0162': 'T', # ลข
+})
+
+
+def clean_web_text(text: str) -> str:
+ """Port of VFP CleanWebText: unescape HTML entities + diacritics to ASCII."""
+ if not text:
+ return ""
+ result = html.unescape(text)
+ result = result.translate(_DIACRITICS)
+ # Remove any remaining
tags
+ for br in ('
', '
', '
'):
+ result = result.replace(br, ' ')
+ return result.strip()
+
+
+def convert_web_date(date_str: str) -> datetime:
+ """Port of VFP ConvertWebDate: parse web date to datetime."""
+ if not date_str:
+ return datetime.now()
+ try:
+ return datetime.strptime(date_str[:10], '%Y-%m-%d')
+ except ValueError:
+ return datetime.now()
+
+
+def format_address_for_oracle(address: str, city: str, region: str) -> str:
+ """Port of VFP FormatAddressForOracle."""
+ region_clean = clean_web_text(region)
+ city_clean = clean_web_text(city)
+ address_clean = clean_web_text(address)
+ return f"JUD:{region_clean};{city_clean};{address_clean}"
+
+
+def build_articles_json(items) -> str:
+ """Build JSON string for Oracle PACK_IMPORT_COMENZI.importa_comanda."""
+ articles = []
+ for item in items:
+ articles.append({
+ "sku": item.sku,
+ "quantity": str(item.quantity),
+ "price": str(item.price),
+ "vat": str(item.vat),
+ "name": clean_web_text(item.name)
+ })
+ return json.dumps(articles)
+
+
+def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dict:
+ """Import a single order into Oracle ROA.
+
+ Returns dict with:
+ success: bool
+ id_comanda: int or None
+ id_partener: int or None
+ error: str or None
+ """
+ result = {
+ "success": False,
+ "id_comanda": None,
+ "id_partener": None,
+ "error": None
+ }
+
+ try:
+ order_number = clean_web_text(order.number)
+ order_date = convert_web_date(order.date)
+
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ # Step 1: Process partner
+ id_partener = cur.var(oracledb.DB_TYPE_NUMBER)
+
+ if order.billing.is_company:
+ denumire = clean_web_text(order.billing.company_name)
+ cod_fiscal = clean_web_text(order.billing.company_code) or None
+ registru = clean_web_text(order.billing.company_reg) or None
+ is_pj = 1
+ else:
+ denumire = clean_web_text(
+ f"{order.billing.firstname} {order.billing.lastname}"
+ )
+ cod_fiscal = None
+ registru = None
+ is_pj = 0
+
+ cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_partener", [
+ cod_fiscal, denumire, registru, is_pj, id_partener
+ ])
+
+ partner_id = id_partener.getvalue()
+ if not partner_id or partner_id <= 0:
+ result["error"] = f"Partner creation failed for {denumire}"
+ return result
+
+ result["id_partener"] = int(partner_id)
+
+ # Step 2: Process billing address
+ id_adresa_fact = cur.var(oracledb.DB_TYPE_NUMBER)
+ billing_addr = format_address_for_oracle(
+ order.billing.address, order.billing.city, order.billing.region
+ )
+ cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
+ partner_id, billing_addr,
+ order.billing.phone or "",
+ order.billing.email or "",
+ id_adresa_fact
+ ])
+ addr_fact_id = id_adresa_fact.getvalue()
+
+ # Step 3: Process shipping address (if different)
+ addr_livr_id = None
+ if order.shipping:
+ id_adresa_livr = cur.var(oracledb.DB_TYPE_NUMBER)
+ shipping_addr = format_address_for_oracle(
+ order.shipping.address, order.shipping.city,
+ order.shipping.region
+ )
+ cur.callproc("PACK_IMPORT_PARTENERI.cauta_sau_creeaza_adresa", [
+ partner_id, shipping_addr,
+ order.shipping.phone or "",
+ order.shipping.email or "",
+ id_adresa_livr
+ ])
+ addr_livr_id = id_adresa_livr.getvalue()
+
+ # Step 4: Build articles JSON and import order
+ articles_json = build_articles_json(order.items)
+
+ # Use CLOB for the JSON
+ clob_var = cur.var(oracledb.DB_TYPE_CLOB)
+ clob_var.setvalue(0, articles_json)
+
+ id_comanda = cur.var(oracledb.DB_TYPE_NUMBER)
+
+ cur.callproc("PACK_IMPORT_COMENZI.importa_comanda", [
+ order_number, # p_nr_comanda_ext
+ order_date, # p_data_comanda
+ partner_id, # p_id_partener
+ clob_var, # p_json_articole (CLOB)
+ addr_livr_id, # p_id_adresa_livrare
+ addr_fact_id, # p_id_adresa_facturare
+ id_pol, # p_id_pol
+ id_sectie, # p_id_sectie
+ id_comanda # v_id_comanda (OUT)
+ ])
+
+ comanda_id = id_comanda.getvalue()
+
+ if comanda_id and comanda_id > 0:
+ conn.commit()
+ result["success"] = True
+ result["id_comanda"] = int(comanda_id)
+ logger.info(f"Order {order_number} imported: ID={comanda_id}")
+ else:
+ conn.rollback()
+ result["error"] = "importa_comanda returned invalid ID"
+
+ except oracledb.DatabaseError as e:
+ error_msg = str(e)
+ result["error"] = error_msg
+ logger.error(f"Oracle error importing order {order.number}: {error_msg}")
+ except Exception as e:
+ result["error"] = str(e)
+ logger.error(f"Error importing order {order.number}: {e}")
+
+ return result
diff --git a/api/app/services/mapping_service.py b/api/app/services/mapping_service.py
new file mode 100644
index 0000000..ebae0d7
--- /dev/null
+++ b/api/app/services/mapping_service.py
@@ -0,0 +1,188 @@
+import oracledb
+import csv
+import io
+import logging
+from fastapi import HTTPException
+from .. import database
+
+logger = logging.getLogger(__name__)
+
+def get_mappings(search: str = "", page: int = 1, per_page: int = 50):
+ """Get paginated mappings with optional search."""
+ if database.pool is None:
+ raise HTTPException(status_code=503, detail="Oracle unavailable")
+
+ offset = (page - 1) * per_page
+
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ # Build WHERE clause
+ where = ""
+ params = {}
+ if search:
+ where = """WHERE (UPPER(at.sku) LIKE '%' || UPPER(:search) || '%'
+ OR UPPER(at.codmat) LIKE '%' || UPPER(:search) || '%'
+ OR UPPER(na.denumire) LIKE '%' || UPPER(:search) || '%')"""
+ params["search"] = search
+
+ # Count total
+ count_sql = f"""
+ SELECT COUNT(*) FROM ARTICOLE_TERTI at
+ LEFT JOIN nom_articole na ON na.codmat = at.codmat
+ {where}
+ """
+ cur.execute(count_sql, params)
+ total = cur.fetchone()[0]
+
+ # Get page
+ data_sql = f"""
+ SELECT at.sku, at.codmat, na.denumire, at.cantitate_roa,
+ at.procent_pret, at.activ,
+ TO_CHAR(at.data_creare, 'YYYY-MM-DD HH24:MI') as data_creare
+ FROM ARTICOLE_TERTI at
+ LEFT JOIN nom_articole na ON na.codmat = at.codmat
+ {where}
+ ORDER BY at.sku, at.codmat
+ OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY
+ """
+ params["offset"] = offset
+ params["per_page"] = per_page
+ cur.execute(data_sql, params)
+
+ columns = [col[0].lower() for col in cur.description]
+ rows = [dict(zip(columns, row)) for row in cur.fetchall()]
+
+ return {
+ "mappings": rows,
+ "total": total,
+ "page": page,
+ "per_page": per_page,
+ "pages": (total + per_page - 1) // per_page
+ }
+
+def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100):
+ """Create a new mapping."""
+ if database.pool is None:
+ raise HTTPException(status_code=503, detail="Oracle unavailable")
+
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ cur.execute("""
+ INSERT INTO ARTICOLE_TERTI (sku, codmat, cantitate_roa, procent_pret, activ, data_creare, id_util_creare)
+ VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, SYSDATE, -3)
+ """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate_roa, "procent_pret": procent_pret})
+ conn.commit()
+ return {"sku": sku, "codmat": codmat}
+
+def update_mapping(sku: str, codmat: str, cantitate_roa: float = None, procent_pret: float = None, activ: int = None):
+ """Update an existing mapping."""
+ if database.pool is None:
+ raise HTTPException(status_code=503, detail="Oracle unavailable")
+
+ sets = []
+ params = {"sku": sku, "codmat": codmat}
+
+ if cantitate_roa is not None:
+ sets.append("cantitate_roa = :cantitate_roa")
+ params["cantitate_roa"] = cantitate_roa
+ if procent_pret is not None:
+ sets.append("procent_pret = :procent_pret")
+ params["procent_pret"] = procent_pret
+ if activ is not None:
+ sets.append("activ = :activ")
+ params["activ"] = activ
+
+ if not sets:
+ return False
+
+ sets.append("data_modif = SYSDATE")
+ set_clause = ", ".join(sets)
+
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ cur.execute(f"""
+ UPDATE ARTICOLE_TERTI SET {set_clause}
+ WHERE sku = :sku AND codmat = :codmat
+ """, params)
+ conn.commit()
+ return cur.rowcount > 0
+
+def delete_mapping(sku: str, codmat: str):
+ """Soft delete (set activ=0)."""
+ return update_mapping(sku, codmat, activ=0)
+
+def import_csv(file_content: str):
+ """Import mappings from CSV content. Returns summary."""
+ if database.pool is None:
+ raise HTTPException(status_code=503, detail="Oracle unavailable")
+
+ reader = csv.DictReader(io.StringIO(file_content))
+ created = 0
+ updated = 0
+ errors = []
+
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ for i, row in enumerate(reader, 1):
+ try:
+ sku = row.get("sku", "").strip()
+ codmat = row.get("codmat", "").strip()
+ cantitate = float(row.get("cantitate_roa", "1") or "1")
+ procent = float(row.get("procent_pret", "100") or "100")
+
+ if not sku or not codmat:
+ errors.append(f"Row {i}: missing sku or codmat")
+ continue
+
+ # Try update first, insert if not exists (MERGE)
+ cur.execute("""
+ MERGE INTO ARTICOLE_TERTI t
+ USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s
+ ON (t.sku = s.sku AND t.codmat = s.codmat)
+ WHEN MATCHED THEN UPDATE SET
+ cantitate_roa = :cantitate_roa,
+ procent_pret = :procent_pret,
+ activ = 1,
+ data_modif = SYSDATE
+ WHEN NOT MATCHED THEN INSERT
+ (sku, codmat, cantitate_roa, procent_pret, activ, data_creare, id_util_creare)
+ VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, SYSDATE, -3)
+ """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent})
+
+ # Check if it was insert or update by rowcount
+ created += 1 # We count total processed
+
+ except Exception as e:
+ errors.append(f"Row {i}: {str(e)}")
+
+ conn.commit()
+
+ return {"processed": created, "errors": errors}
+
+def export_csv():
+ """Export all mappings as CSV string."""
+ if database.pool is None:
+ raise HTTPException(status_code=503, detail="Oracle unavailable")
+
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret", "activ"])
+
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ cur.execute("""
+ SELECT sku, codmat, cantitate_roa, procent_pret, activ
+ FROM ARTICOLE_TERTI ORDER BY sku, codmat
+ """)
+ for row in cur:
+ writer.writerow(row)
+
+ return output.getvalue()
+
+def get_csv_template():
+ """Return empty CSV template."""
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerow(["sku", "codmat", "cantitate_roa", "procent_pret"])
+ writer.writerow(["EXAMPLE_SKU", "EXAMPLE_CODMAT", "1", "100"])
+ return output.getvalue()
diff --git a/api/app/services/order_reader.py b/api/app/services/order_reader.py
new file mode 100644
index 0000000..b45a406
--- /dev/null
+++ b/api/app/services/order_reader.py
@@ -0,0 +1,178 @@
+import json
+import glob
+import os
+import logging
+from pathlib import Path
+from dataclasses import dataclass, field
+from typing import Optional
+
+from ..config import settings
+
+logger = logging.getLogger(__name__)
+
+@dataclass
+class OrderItem:
+ sku: str
+ name: str
+ price: float
+ quantity: float
+ vat: float
+
+@dataclass
+class OrderBilling:
+ firstname: str = ""
+ lastname: str = ""
+ phone: str = ""
+ email: str = ""
+ address: str = ""
+ city: str = ""
+ region: str = ""
+ country: str = ""
+ company_name: str = ""
+ company_code: str = ""
+ company_reg: str = ""
+ is_company: bool = False
+
+@dataclass
+class OrderShipping:
+ firstname: str = ""
+ lastname: str = ""
+ phone: str = ""
+ email: str = ""
+ address: str = ""
+ city: str = ""
+ region: str = ""
+ country: str = ""
+
+@dataclass
+class OrderData:
+ id: str
+ number: str
+ date: str
+ status: str = ""
+ status_id: str = ""
+ items: list = field(default_factory=list) # list of OrderItem
+ billing: OrderBilling = field(default_factory=OrderBilling)
+ shipping: Optional[OrderShipping] = None
+ payment_name: str = ""
+ delivery_name: str = ""
+ source_file: str = ""
+
+def read_json_orders(json_dir: str = None) -> tuple[list[OrderData], int]:
+ """Read all GoMag order JSON files from the output directory.
+ Returns (list of OrderData, number of JSON files read).
+ """
+ if json_dir is None:
+ json_dir = settings.JSON_OUTPUT_DIR
+
+ if not json_dir or not os.path.isdir(json_dir):
+ logger.warning(f"JSON output directory not found: {json_dir}")
+ return [], 0
+
+ # Find all gomag_orders*.json files
+ pattern = os.path.join(json_dir, "gomag_orders*.json")
+ json_files = sorted(glob.glob(pattern))
+
+ if not json_files:
+ logger.info(f"No JSON files found in {json_dir}")
+ return [], 0
+
+ orders = []
+ for filepath in json_files:
+ try:
+ with open(filepath, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+
+ raw_orders = data.get("orders", {})
+ if not isinstance(raw_orders, dict):
+ continue
+
+ for order_id, order_data in raw_orders.items():
+ try:
+ order = _parse_order(order_id, order_data, os.path.basename(filepath))
+ orders.append(order)
+ except Exception as e:
+ logger.warning(f"Error parsing order {order_id} from {filepath}: {e}")
+ except Exception as e:
+ logger.error(f"Error reading {filepath}: {e}")
+
+ logger.info(f"Read {len(orders)} orders from {len(json_files)} JSON files")
+ return orders, len(json_files)
+
+def _parse_order(order_id: str, data: dict, source_file: str) -> OrderData:
+ """Parse a single order from JSON data."""
+ # Parse items
+ items = []
+ raw_items = data.get("items", [])
+ if isinstance(raw_items, list):
+ for item in raw_items:
+ if isinstance(item, dict) and item.get("sku"):
+ items.append(OrderItem(
+ sku=str(item.get("sku", "")).strip(),
+ name=str(item.get("name", "")),
+ price=float(item.get("price", 0) or 0),
+ quantity=float(item.get("quantity", 0) or 0),
+ vat=float(item.get("vat", 0) or 0)
+ ))
+
+ # Parse billing
+ billing_data = data.get("billing", {}) or {}
+ company = billing_data.get("company")
+ is_company = isinstance(company, dict) and bool(company.get("name"))
+
+ billing = OrderBilling(
+ firstname=str(billing_data.get("firstname", "")),
+ lastname=str(billing_data.get("lastname", "")),
+ phone=str(billing_data.get("phone", "")),
+ email=str(billing_data.get("email", "")),
+ address=str(billing_data.get("address", "")),
+ city=str(billing_data.get("city", "")),
+ region=str(billing_data.get("region", "")),
+ country=str(billing_data.get("country", "")),
+ company_name=str(company.get("name", "")) if is_company else "",
+ company_code=str(company.get("code", "")) if is_company else "",
+ company_reg=str(company.get("registrationNo", "")) if is_company else "",
+ is_company=is_company
+ )
+
+ # Parse shipping
+ shipping_data = data.get("shipping")
+ shipping = None
+ if isinstance(shipping_data, dict):
+ shipping = OrderShipping(
+ firstname=str(shipping_data.get("firstname", "")),
+ lastname=str(shipping_data.get("lastname", "")),
+ phone=str(shipping_data.get("phone", "")),
+ email=str(shipping_data.get("email", "")),
+ address=str(shipping_data.get("address", "")),
+ city=str(shipping_data.get("city", "")),
+ region=str(shipping_data.get("region", "")),
+ country=str(shipping_data.get("country", ""))
+ )
+
+ # Payment/delivery
+ payment = data.get("payment", {}) or {}
+ delivery = data.get("delivery", {}) or {}
+
+ return OrderData(
+ id=str(data.get("id", order_id)),
+ number=str(data.get("number", "")),
+ date=str(data.get("date", "")),
+ status=str(data.get("status", "")),
+ status_id=str(data.get("statusId", "")),
+ items=items,
+ billing=billing,
+ shipping=shipping,
+ payment_name=str(payment.get("name", "")) if isinstance(payment, dict) else "",
+ delivery_name=str(delivery.get("name", "")) if isinstance(delivery, dict) else "",
+ source_file=source_file
+ )
+
+def get_all_skus(orders: list[OrderData]) -> set[str]:
+ """Extract unique SKUs from all orders."""
+ skus = set()
+ for order in orders:
+ for item in order.items:
+ if item.sku:
+ skus.add(item.sku)
+ return skus
diff --git a/api/app/services/scheduler_service.py b/api/app/services/scheduler_service.py
new file mode 100644
index 0000000..4a9e8cc
--- /dev/null
+++ b/api/app/services/scheduler_service.py
@@ -0,0 +1,71 @@
+import logging
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from apscheduler.triggers.interval import IntervalTrigger
+
+logger = logging.getLogger(__name__)
+
+_scheduler = None
+_is_running = False
+
+
+def init_scheduler():
+ """Initialize the APScheduler instance."""
+ global _scheduler
+ _scheduler = AsyncIOScheduler()
+ logger.info("Scheduler initialized")
+
+
+def start_scheduler(interval_minutes: int = 5):
+ """Start the scheduler with the given interval."""
+ global _is_running
+ if _scheduler is None:
+ init_scheduler()
+
+ # Remove existing job if any
+ if _scheduler.get_job("sync_job"):
+ _scheduler.remove_job("sync_job")
+
+ from . import sync_service
+
+ _scheduler.add_job(
+ sync_service.run_sync,
+ trigger=IntervalTrigger(minutes=interval_minutes),
+ id="sync_job",
+ name="GoMag Sync",
+ replace_existing=True
+ )
+
+ if not _scheduler.running:
+ _scheduler.start()
+
+ _is_running = True
+ logger.info(f"Scheduler started with interval {interval_minutes}min")
+
+
+def stop_scheduler():
+ """Stop the scheduler."""
+ global _is_running
+ if _scheduler and _scheduler.running:
+ if _scheduler.get_job("sync_job"):
+ _scheduler.remove_job("sync_job")
+ _is_running = False
+ logger.info("Scheduler stopped")
+
+
+def shutdown_scheduler():
+ """Shutdown the scheduler completely."""
+ global _scheduler, _is_running
+ if _scheduler and _scheduler.running:
+ _scheduler.shutdown(wait=False)
+ _scheduler = None
+ _is_running = False
+
+
+def get_scheduler_status():
+ """Get current scheduler status."""
+ job = _scheduler.get_job("sync_job") if _scheduler else None
+ return {
+ "enabled": _is_running,
+ "next_run": job.next_run_time.isoformat() if job and job.next_run_time else None,
+ "interval_minutes": int(job.trigger.interval.total_seconds() / 60) if job else None
+ }
diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py
new file mode 100644
index 0000000..6177581
--- /dev/null
+++ b/api/app/services/sqlite_service.py
@@ -0,0 +1,206 @@
+import json
+import logging
+from datetime import datetime
+from ..database import get_sqlite, get_sqlite_sync
+
+logger = logging.getLogger(__name__)
+
+
+async def create_sync_run(run_id: str, json_files: int = 0):
+ """Create a new sync run record."""
+ db = await get_sqlite()
+ try:
+ await db.execute("""
+ INSERT INTO sync_runs (run_id, started_at, status, json_files)
+ VALUES (?, datetime('now'), 'running', ?)
+ """, (run_id, json_files))
+ await db.commit()
+ finally:
+ await db.close()
+
+
+async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
+ imported: int = 0, skipped: int = 0, errors: int = 0):
+ """Update sync run with results."""
+ db = await get_sqlite()
+ try:
+ await db.execute("""
+ UPDATE sync_runs SET
+ finished_at = datetime('now'),
+ status = ?,
+ total_orders = ?,
+ imported = ?,
+ skipped = ?,
+ errors = ?
+ WHERE run_id = ?
+ """, (status, total_orders, imported, skipped, errors, run_id))
+ await db.commit()
+ finally:
+ await db.close()
+
+
+async def add_import_order(sync_run_id: str, order_number: str, order_date: str,
+ customer_name: str, status: str, id_comanda: int = None,
+ id_partener: int = None, error_message: str = None,
+ missing_skus: list = None, items_count: int = 0):
+ """Record an individual order import result."""
+ db = await get_sqlite()
+ try:
+ await db.execute("""
+ INSERT INTO import_orders
+ (sync_run_id, order_number, order_date, customer_name, status,
+ id_comanda, id_partener, error_message, missing_skus, items_count)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, (sync_run_id, order_number, order_date, customer_name, status,
+ id_comanda, id_partener, error_message,
+ json.dumps(missing_skus) if missing_skus else None, items_count))
+ await db.commit()
+ finally:
+ await db.close()
+
+
+async def track_missing_sku(sku: str, product_name: str = ""):
+ """Track a missing SKU."""
+ db = await get_sqlite()
+ try:
+ await db.execute("""
+ INSERT OR IGNORE INTO missing_skus (sku, product_name)
+ VALUES (?, ?)
+ """, (sku, product_name))
+ await db.commit()
+ finally:
+ await db.close()
+
+
+async def resolve_missing_sku(sku: str):
+ """Mark a missing SKU as resolved."""
+ db = await get_sqlite()
+ try:
+ await db.execute("""
+ UPDATE missing_skus SET resolved = 1, resolved_at = datetime('now')
+ WHERE sku = ?
+ """, (sku,))
+ await db.commit()
+ finally:
+ await db.close()
+
+
+async def get_sync_runs(page: int = 1, per_page: int = 20):
+ """Get paginated sync run history."""
+ db = await get_sqlite()
+ try:
+ offset = (page - 1) * per_page
+
+ cursor = await db.execute("SELECT COUNT(*) FROM sync_runs")
+ total = (await cursor.fetchone())[0]
+
+ cursor = await db.execute("""
+ SELECT * FROM sync_runs
+ ORDER BY started_at DESC
+ LIMIT ? OFFSET ?
+ """, (per_page, offset))
+ rows = await cursor.fetchall()
+
+ return {
+ "runs": [dict(row) for row in rows],
+ "total": total,
+ "page": page,
+ "pages": (total + per_page - 1) // per_page if total > 0 else 0
+ }
+ finally:
+ await db.close()
+
+
+async def get_sync_run_detail(run_id: str):
+ """Get details for a specific sync run including its orders."""
+ db = await get_sqlite()
+ try:
+ cursor = await db.execute(
+ "SELECT * FROM sync_runs WHERE run_id = ?", (run_id,)
+ )
+ run = await cursor.fetchone()
+ if not run:
+ return None
+
+ cursor = await db.execute("""
+ SELECT * FROM import_orders
+ WHERE sync_run_id = ?
+ ORDER BY created_at
+ """, (run_id,))
+ orders = await cursor.fetchall()
+
+ return {
+ "run": dict(run),
+ "orders": [dict(o) for o in orders]
+ }
+ finally:
+ await db.close()
+
+
+async def get_dashboard_stats():
+ """Get stats for the dashboard."""
+ db = await get_sqlite()
+ try:
+ # Total imported
+ cursor = await db.execute(
+ "SELECT COUNT(*) FROM import_orders WHERE status = 'IMPORTED'"
+ )
+ imported = (await cursor.fetchone())[0]
+
+ # Total skipped
+ cursor = await db.execute(
+ "SELECT COUNT(*) FROM import_orders WHERE status = 'SKIPPED'"
+ )
+ skipped = (await cursor.fetchone())[0]
+
+ # Total errors
+ cursor = await db.execute(
+ "SELECT COUNT(*) FROM import_orders WHERE status = 'ERROR'"
+ )
+ errors = (await cursor.fetchone())[0]
+
+ # Missing SKUs (unresolved)
+ cursor = await db.execute(
+ "SELECT COUNT(*) FROM missing_skus WHERE resolved = 0"
+ )
+ missing = (await cursor.fetchone())[0]
+
+ # Last sync run
+ cursor = await db.execute("""
+ SELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 1
+ """)
+ last_run = await cursor.fetchone()
+
+ return {
+ "imported": imported,
+ "skipped": skipped,
+ "errors": errors,
+ "missing_skus": missing,
+ "last_run": dict(last_run) if last_run else None
+ }
+ finally:
+ await db.close()
+
+
+async def get_scheduler_config():
+ """Get scheduler configuration from SQLite."""
+ db = await get_sqlite()
+ try:
+ cursor = await db.execute("SELECT key, value FROM scheduler_config")
+ rows = await cursor.fetchall()
+ return {row["key"]: row["value"] for row in rows}
+ finally:
+ await db.close()
+
+
+async def set_scheduler_config(key: str, value: str):
+ """Set a scheduler configuration value."""
+ db = await get_sqlite()
+ try:
+ await db.execute("""
+ INSERT OR REPLACE INTO scheduler_config (key, value)
+ VALUES (?, ?)
+ """, (key, value))
+ await db.commit()
+ finally:
+ await db.close()
diff --git a/api/app/services/sync_service.py b/api/app/services/sync_service.py
new file mode 100644
index 0000000..0842f72
--- /dev/null
+++ b/api/app/services/sync_service.py
@@ -0,0 +1,165 @@
+import asyncio
+import logging
+import uuid
+from datetime import datetime
+
+from . import order_reader, validation_service, import_service, sqlite_service
+
+logger = logging.getLogger(__name__)
+
+# Sync state
+_sync_lock = asyncio.Lock()
+_current_sync = None # dict with run_id, status, progress info
+
+
+async def get_sync_status():
+ """Get current sync status."""
+ if _current_sync:
+ return {**_current_sync}
+ return {"status": "idle"}
+
+
+async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
+ """Run a full sync cycle. Returns summary dict."""
+ global _current_sync
+
+ if _sync_lock.locked():
+ return {"error": "Sync already running"}
+
+ async with _sync_lock:
+ run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
+ _current_sync = {
+ "run_id": run_id,
+ "status": "running",
+ "started_at": datetime.now().isoformat(),
+ "progress": "Reading JSON files..."
+ }
+
+ try:
+ # Step 1: Read orders
+ orders, json_count = order_reader.read_json_orders()
+ await sqlite_service.create_sync_run(run_id, json_count)
+
+ if not orders:
+ await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
+ _current_sync = None
+ return {
+ "run_id": run_id,
+ "status": "completed",
+ "message": "No orders found",
+ "json_files": json_count
+ }
+
+ _current_sync["progress"] = f"Validating {len(orders)} orders..."
+
+ # Step 2: Validate SKUs (blocking Oracle call -> run in thread)
+ all_skus = order_reader.get_all_skus(orders)
+ validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
+ importable, skipped = validation_service.classify_orders(orders, validation)
+
+ # Track missing SKUs
+ for sku in validation["missing"]:
+ product_name = ""
+ for order in orders:
+ for item in order.items:
+ if item.sku == sku:
+ product_name = item.name
+ break
+ if product_name:
+ break
+ await sqlite_service.track_missing_sku(sku, product_name)
+
+ # Step 3: Record skipped orders
+ for order, missing_skus in skipped:
+ customer = order.billing.company_name or \
+ f"{order.billing.firstname} {order.billing.lastname}"
+ await sqlite_service.add_import_order(
+ sync_run_id=run_id,
+ order_number=order.number,
+ order_date=order.date,
+ customer_name=customer,
+ status="SKIPPED",
+ missing_skus=missing_skus,
+ items_count=len(order.items)
+ )
+
+ # Step 4: Import valid orders
+ imported_count = 0
+ error_count = 0
+
+ for i, order in enumerate(importable):
+ _current_sync["progress"] = f"Importing {i+1}/{len(importable)}: #{order.number}"
+
+ result = await asyncio.to_thread(
+ import_service.import_single_order,
+ order, id_pol=id_pol, id_sectie=id_sectie
+ )
+ customer = order.billing.company_name or \
+ f"{order.billing.firstname} {order.billing.lastname}"
+
+ if result["success"]:
+ imported_count += 1
+ await sqlite_service.add_import_order(
+ sync_run_id=run_id,
+ order_number=order.number,
+ order_date=order.date,
+ customer_name=customer,
+ status="IMPORTED",
+ id_comanda=result["id_comanda"],
+ id_partener=result["id_partener"],
+ items_count=len(order.items)
+ )
+ else:
+ error_count += 1
+ await sqlite_service.add_import_order(
+ sync_run_id=run_id,
+ order_number=order.number,
+ order_date=order.date,
+ customer_name=customer,
+ status="ERROR",
+ id_partener=result.get("id_partener"),
+ error_message=result["error"],
+ items_count=len(order.items)
+ )
+
+ # Safety: stop if too many errors
+ if error_count > 10:
+ logger.warning("Too many errors, stopping sync")
+ break
+
+ # Step 5: Update sync run
+ status = "completed" if error_count <= 10 else "failed"
+ await sqlite_service.update_sync_run(
+ run_id, status, len(orders), imported_count, len(skipped), error_count
+ )
+
+ summary = {
+ "run_id": run_id,
+ "status": status,
+ "json_files": json_count,
+ "total_orders": len(orders),
+ "imported": imported_count,
+ "skipped": len(skipped),
+ "errors": error_count,
+ "missing_skus": len(validation["missing"])
+ }
+
+ logger.info(
+ f"Sync {run_id} completed: {imported_count} imported, "
+ f"{len(skipped)} skipped, {error_count} errors"
+ )
+ return summary
+
+ except Exception as e:
+ logger.error(f"Sync {run_id} failed: {e}")
+ await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1)
+ return {"run_id": run_id, "status": "failed", "error": str(e)}
+ finally:
+ _current_sync = None
+
+
+def stop_sync():
+ """Signal sync to stop. Currently sync runs to completion."""
+ # For now, sync runs are not cancellable mid-flight.
+ # Future: use an asyncio.Event for cooperative cancellation.
+ pass
diff --git a/api/app/services/validation_service.py b/api/app/services/validation_service.py
new file mode 100644
index 0000000..753d072
--- /dev/null
+++ b/api/app/services/validation_service.py
@@ -0,0 +1,71 @@
+import logging
+from .. import database
+
+logger = logging.getLogger(__name__)
+
+def validate_skus(skus: set[str]) -> dict:
+ """Validate a set of SKUs against Oracle.
+ Returns: {mapped: set, direct: set, missing: set}
+ - mapped: found in ARTICOLE_TERTI (active)
+ - direct: found in NOM_ARTICOLE by codmat (not in ARTICOLE_TERTI)
+ - missing: not found anywhere
+ """
+ if not skus:
+ return {"mapped": set(), "direct": set(), "missing": set()}
+
+ mapped = set()
+ direct = set()
+ sku_list = list(skus)
+
+ with database.pool.acquire() as conn:
+ with conn.cursor() as cur:
+ # Check in batches of 500
+ for i in range(0, len(sku_list), 500):
+ batch = sku_list[i:i+500]
+ placeholders = ",".join([f":s{j}" for j in range(len(batch))])
+ params = {f"s{j}": sku for j, sku in enumerate(batch)}
+
+ # Check ARTICOLE_TERTI
+ cur.execute(f"""
+ SELECT DISTINCT sku FROM ARTICOLE_TERTI
+ WHERE sku IN ({placeholders}) AND activ = 1
+ """, params)
+ for row in cur:
+ mapped.add(row[0])
+
+ # Check NOM_ARTICOLE for remaining
+ remaining = [s for s in batch if s not in mapped]
+ if remaining:
+ placeholders2 = ",".join([f":n{j}" for j in range(len(remaining))])
+ params2 = {f"n{j}": sku for j, sku in enumerate(remaining)}
+ cur.execute(f"""
+ SELECT DISTINCT codmat FROM NOM_ARTICOLE
+ WHERE codmat IN ({placeholders2})
+ """, params2)
+ for row in cur:
+ direct.add(row[0])
+
+ missing = skus - mapped - direct
+
+ logger.info(f"SKU validation: {len(mapped)} mapped, {len(direct)} direct, {len(missing)} missing")
+ return {"mapped": mapped, "direct": direct, "missing": missing}
+
+def classify_orders(orders, validation_result):
+ """Classify orders as importable or skipped based on SKU validation.
+ Returns: (importable_orders, skipped_orders)
+ Each skipped entry is a tuple of (order, list_of_missing_skus).
+ """
+ ok_skus = validation_result["mapped"] | validation_result["direct"]
+ importable = []
+ skipped = []
+
+ for order in orders:
+ order_skus = {item.sku for item in order.items if item.sku}
+ order_missing = order_skus - ok_skus
+
+ if order_missing:
+ skipped.append((order, list(order_missing)))
+ else:
+ importable.append(order)
+
+ return importable, skipped
diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css
new file mode 100644
index 0000000..fbda7d5
--- /dev/null
+++ b/api/app/static/css/style.css
@@ -0,0 +1,214 @@
+:root {
+ --sidebar-width: 220px;
+ --sidebar-bg: #1e293b;
+ --sidebar-text: #94a3b8;
+ --sidebar-active: #ffffff;
+ --sidebar-hover-bg: #334155;
+ --body-bg: #f1f5f9;
+ --card-shadow: 0 1px 3px rgba(0,0,0,0.08);
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ background-color: var(--body-bg);
+ margin: 0;
+ padding: 0;
+}
+
+/* Sidebar */
+.sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: var(--sidebar-width);
+ height: 100vh;
+ background-color: var(--sidebar-bg);
+ padding: 0;
+ z-index: 1000;
+ overflow-y: auto;
+ transition: transform 0.3s ease;
+}
+
+.sidebar-header {
+ padding: 1.25rem 1rem;
+ border-bottom: 1px solid #334155;
+}
+
+.sidebar-header h5 {
+ color: #fff;
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+.sidebar .nav-link {
+ color: var(--sidebar-text);
+ padding: 0.65rem 1rem;
+ font-size: 0.9rem;
+ border-left: 3px solid transparent;
+ transition: all 0.15s ease;
+}
+
+.sidebar .nav-link:hover {
+ color: var(--sidebar-active);
+ background-color: var(--sidebar-hover-bg);
+}
+
+.sidebar .nav-link.active {
+ color: var(--sidebar-active);
+ background-color: var(--sidebar-hover-bg);
+ border-left-color: #3b82f6;
+}
+
+.sidebar .nav-link i {
+ margin-right: 0.5rem;
+ width: 1.2rem;
+ text-align: center;
+}
+
+.sidebar-footer {
+ position: absolute;
+ bottom: 0;
+ padding: 0.75rem 1rem;
+ border-top: 1px solid #334155;
+ width: 100%;
+}
+
+/* Main content */
+.main-content {
+ margin-left: var(--sidebar-width);
+ padding: 1.5rem;
+ min-height: 100vh;
+}
+
+/* Sidebar toggle button for mobile */
+.sidebar-toggle {
+ position: fixed;
+ top: 0.5rem;
+ left: 0.5rem;
+ z-index: 1100;
+ border-radius: 0.375rem;
+}
+
+/* Cards */
+.card {
+ border: none;
+ box-shadow: var(--card-shadow);
+ border-radius: 0.5rem;
+}
+
+.card-header {
+ background-color: #fff;
+ border-bottom: 1px solid #e2e8f0;
+ font-weight: 600;
+ font-size: 0.9rem;
+}
+
+/* Status badges */
+.badge-imported { background-color: #22c55e; }
+.badge-skipped { background-color: #eab308; color: #000; }
+.badge-error { background-color: #ef4444; }
+.badge-pending { background-color: #94a3b8; }
+.badge-ready { background-color: #3b82f6; }
+
+/* Stat cards */
+.stat-card {
+ text-align: center;
+ padding: 1rem;
+}
+
+.stat-card .stat-value {
+ font-size: 1.75rem;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.stat-card .stat-label {
+ font-size: 0.8rem;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* Tables */
+.table {
+ font-size: 0.875rem;
+}
+
+.table th {
+ font-weight: 600;
+ color: #475569;
+ border-top: none;
+}
+
+/* Forms */
+.form-control:focus, .form-select:focus {
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.15);
+}
+
+/* Responsive */
+@media (max-width: 767.98px) {
+ .sidebar {
+ transform: translateX(-100%);
+ }
+ .sidebar.show {
+ transform: translateX(0);
+ }
+ .main-content {
+ margin-left: 0;
+ }
+ .sidebar-toggle {
+ display: block !important;
+ }
+}
+
+/* Autocomplete dropdown */
+.autocomplete-dropdown {
+ position: absolute;
+ z-index: 1050;
+ background: #fff;
+ border: 1px solid #dee2e6;
+ border-radius: 0.375rem;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ max-height: 300px;
+ overflow-y: auto;
+ width: 100%;
+}
+
+.autocomplete-item {
+ padding: 0.5rem 0.75rem;
+ cursor: pointer;
+ font-size: 0.875rem;
+ border-bottom: 1px solid #f1f5f9;
+}
+
+.autocomplete-item:hover, .autocomplete-item.active {
+ background-color: #f1f5f9;
+}
+
+.autocomplete-item .codmat {
+ font-weight: 600;
+ color: #1e293b;
+}
+
+.autocomplete-item .denumire {
+ color: #64748b;
+ font-size: 0.8rem;
+}
+
+/* Pagination */
+.pagination .page-link {
+ font-size: 0.875rem;
+}
+
+/* Loading spinner */
+.spinner-overlay {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(255,255,255,0.7);
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js
new file mode 100644
index 0000000..efa1787
--- /dev/null
+++ b/api/app/static/js/dashboard.js
@@ -0,0 +1,215 @@
+let refreshInterval = null;
+
+document.addEventListener('DOMContentLoaded', () => {
+ loadDashboard();
+ // Auto-refresh every 10 seconds
+ refreshInterval = setInterval(loadDashboard, 10000);
+});
+
+async function loadDashboard() {
+ await Promise.all([
+ loadSyncStatus(),
+ loadSyncHistory(),
+ loadMissingSkus(),
+ loadSchedulerStatus()
+ ]);
+}
+
+async function loadSyncStatus() {
+ try {
+ const res = await fetch('/api/sync/status');
+ const data = await res.json();
+
+ // Update stats
+ const stats = data.stats || {};
+ document.getElementById('stat-imported').textContent = stats.imported || 0;
+ document.getElementById('stat-skipped').textContent = stats.skipped || 0;
+ document.getElementById('stat-missing').textContent = stats.missing_skus || 0;
+
+ // Update sync status badge
+ const badge = document.getElementById('syncStatusBadge');
+ const status = data.status || 'idle';
+ badge.textContent = status;
+ badge.className = 'badge ' + (status === 'running' ? 'bg-primary' : status === 'failed' ? 'bg-danger' : 'bg-secondary');
+
+ // Show/hide start/stop buttons
+ if (status === 'running') {
+ document.getElementById('btnStartSync').classList.add('d-none');
+ document.getElementById('btnStopSync').classList.remove('d-none');
+ document.getElementById('syncProgressText').textContent = data.progress || 'Running...';
+ } else {
+ document.getElementById('btnStartSync').classList.remove('d-none');
+ document.getElementById('btnStopSync').classList.add('d-none');
+
+ // Show last run info
+ if (stats.last_run) {
+ const lr = stats.last_run;
+ const started = lr.started_at ? new Date(lr.started_at).toLocaleString('ro-RO') : '';
+ document.getElementById('syncProgressText').textContent =
+ `Ultimul: ${started} | ${lr.imported || 0} ok, ${lr.skipped || 0} skip, ${lr.errors || 0} err`;
+ } else {
+ document.getElementById('syncProgressText').textContent = '';
+ }
+ }
+ } catch (err) {
+ console.error('loadSyncStatus error:', err);
+ }
+}
+
+async function loadSyncHistory() {
+ try {
+ const res = await fetch('/api/sync/history?per_page=10');
+ const data = await res.json();
+ const tbody = document.getElementById('syncRunsBody');
+
+ if (!data.runs || data.runs.length === 0) {
+ tbody.innerHTML = '
| Niciun sync run |
';
+ return;
+ }
+
+ tbody.innerHTML = data.runs.map(r => {
+ const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
+ let duration = '-';
+ if (r.started_at && r.finished_at) {
+ const sec = Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000);
+ duration = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
+ }
+ const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
+
+ return `
+ | ${started} |
+ ${esc(r.status)} |
+ ${r.total_orders || 0} |
+ ${r.imported || 0} |
+ ${r.skipped || 0} |
+ ${r.errors || 0} |
+ ${duration} |
+
`;
+ }).join('');
+ } catch (err) {
+ console.error('loadSyncHistory error:', err);
+ }
+}
+
+async function loadMissingSkus() {
+ try {
+ const res = await fetch('/api/validate/missing-skus');
+ const data = await res.json();
+ const tbody = document.getElementById('missingSkusBody');
+
+ // Update stat card
+ document.getElementById('stat-missing').textContent = data.unresolved || 0;
+
+ const unresolved = (data.missing_skus || []).filter(s => !s.resolved);
+
+ if (unresolved.length === 0) {
+ tbody.innerHTML = '| Toate SKU-urile sunt mapate |
';
+ return;
+ }
+
+ tbody.innerHTML = unresolved.slice(0, 10).map(s => `
+
+ ${esc(s.sku)} |
+ ${esc(s.product_name || '-')} |
+ ${s.first_seen ? new Date(s.first_seen).toLocaleDateString('ro-RO') : '-'} |
+
+
+
+
+ |
+
+ `).join('');
+ } catch (err) {
+ console.error('loadMissingSkus error:', err);
+ }
+}
+
+async function loadSchedulerStatus() {
+ try {
+ const res = await fetch('/api/sync/schedule');
+ const data = await res.json();
+
+ document.getElementById('schedulerToggle').checked = data.enabled || false;
+ if (data.interval_minutes) {
+ document.getElementById('schedulerInterval').value = data.interval_minutes;
+ }
+ } catch (err) {
+ console.error('loadSchedulerStatus error:', err);
+ }
+}
+
+async function startSync() {
+ try {
+ const res = await fetch('/api/sync/start', { method: 'POST' });
+ const data = await res.json();
+ if (data.error) {
+ alert(data.error);
+ }
+ loadDashboard();
+ } catch (err) {
+ alert('Eroare: ' + err.message);
+ }
+}
+
+async function stopSync() {
+ try {
+ await fetch('/api/sync/stop', { method: 'POST' });
+ loadDashboard();
+ } catch (err) {
+ alert('Eroare: ' + err.message);
+ }
+}
+
+async function scanOrders() {
+ const btn = document.getElementById('btnScan');
+ btn.disabled = true;
+ btn.innerHTML = ' Scanning...';
+
+ try {
+ const res = await fetch('/api/validate/scan', { method: 'POST' });
+ const data = await res.json();
+
+ // Update pending/ready stats
+ document.getElementById('stat-pending').textContent = data.total_orders || 0;
+ document.getElementById('stat-ready').textContent = data.importable || 0;
+
+ let msg = `Scan complet: ${data.total_orders || 0} comenzi, ${data.importable || 0} ready, ${data.skipped || 0} skipped`;
+ if (data.skus && data.skus.missing > 0) {
+ msg += `, ${data.skus.missing} SKU-uri lipsa`;
+ }
+ alert(msg);
+ loadDashboard();
+ } catch (err) {
+ alert('Eroare scan: ' + err.message);
+ } finally {
+ btn.disabled = false;
+ btn.innerHTML = ' Scan';
+ }
+}
+
+async function toggleScheduler() {
+ const enabled = document.getElementById('schedulerToggle').checked;
+ const interval = parseInt(document.getElementById('schedulerInterval').value) || 5;
+
+ try {
+ await fetch('/api/sync/schedule', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ enabled, interval_minutes: interval })
+ });
+ } catch (err) {
+ alert('Eroare scheduler: ' + err.message);
+ }
+}
+
+async function updateSchedulerInterval() {
+ const enabled = document.getElementById('schedulerToggle').checked;
+ if (enabled) {
+ await toggleScheduler();
+ }
+}
+
+function esc(s) {
+ if (s == null) return '';
+ return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
+}
diff --git a/api/app/static/js/mappings.js b/api/app/static/js/mappings.js
new file mode 100644
index 0000000..71a147c
--- /dev/null
+++ b/api/app/static/js/mappings.js
@@ -0,0 +1,299 @@
+let currentPage = 1;
+let currentSearch = '';
+let searchTimeout = null;
+
+// Load on page ready
+document.addEventListener('DOMContentLoaded', loadMappings);
+
+function debounceSearch() {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(() => {
+ currentSearch = document.getElementById('searchInput').value;
+ currentPage = 1;
+ loadMappings();
+ }, 300);
+}
+
+async function loadMappings() {
+ const params = new URLSearchParams({
+ search: currentSearch,
+ page: currentPage,
+ per_page: 50
+ });
+
+ try {
+ const res = await fetch(`/api/mappings?${params}`);
+ const data = await res.json();
+ renderTable(data.mappings);
+ renderPagination(data);
+ } catch (err) {
+ document.getElementById('mappingsBody').innerHTML =
+ `| Eroare: ${err.message} |
`;
+ }
+}
+
+function renderTable(mappings) {
+ const tbody = document.getElementById('mappingsBody');
+
+ if (!mappings || mappings.length === 0) {
+ tbody.innerHTML = '| Nu exista mapari |
';
+ return;
+ }
+
+ tbody.innerHTML = mappings.map(m => `
+
+ | ${esc(m.sku)} |
+ ${esc(m.codmat)} |
+ ${esc(m.denumire || '-')} |
+ ${m.cantitate_roa} |
+ ${m.procent_pret}% |
+
+
+ ${m.activ ? 'Activ' : 'Inactiv'}
+
+ |
+
+
+ |
+
+ `).join('');
+}
+
+function renderPagination(data) {
+ const info = document.getElementById('pageInfo');
+ info.textContent = `${data.total} mapari | Pagina ${data.page} din ${data.pages || 1}`;
+
+ const ul = document.getElementById('pagination');
+ if (data.pages <= 1) { ul.innerHTML = ''; return; }
+
+ let html = '';
+ // Previous
+ html += `
+ «`;
+
+ // Pages (show max 7)
+ let start = Math.max(1, data.page - 3);
+ let end = Math.min(data.pages, start + 6);
+ start = Math.max(1, end - 6);
+
+ for (let i = start; i <= end; i++) {
+ html += `
+ ${i}`;
+ }
+
+ // Next
+ html += `
+ »`;
+
+ ul.innerHTML = html;
+}
+
+function goPage(p) {
+ currentPage = p;
+ loadMappings();
+}
+
+// Autocomplete for CODMAT
+let acTimeout = null;
+document.addEventListener('DOMContentLoaded', () => {
+ const input = document.getElementById('inputCodmat');
+ if (!input) return;
+
+ input.addEventListener('input', () => {
+ clearTimeout(acTimeout);
+ acTimeout = setTimeout(() => autocomplete(input.value), 250);
+ });
+
+ input.addEventListener('blur', () => {
+ setTimeout(() => document.getElementById('autocompleteDropdown').classList.add('d-none'), 200);
+ });
+});
+
+async function autocomplete(q) {
+ const dropdown = document.getElementById('autocompleteDropdown');
+ if (q.length < 2) { dropdown.classList.add('d-none'); return; }
+
+ try {
+ const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
+ const data = await res.json();
+
+ if (!data.results || data.results.length === 0) {
+ dropdown.classList.add('d-none');
+ return;
+ }
+
+ dropdown.innerHTML = data.results.map(r => `
+
+ ${esc(r.codmat)}
+
${esc(r.denumire)}
+
+ `).join('');
+ dropdown.classList.remove('d-none');
+ } catch (err) {
+ dropdown.classList.add('d-none');
+ }
+}
+
+function selectArticle(codmat, denumire) {
+ document.getElementById('inputCodmat').value = codmat;
+ document.getElementById('selectedArticle').textContent = denumire;
+ document.getElementById('autocompleteDropdown').classList.add('d-none');
+}
+
+// Save mapping (create)
+async function saveMapping() {
+ const sku = document.getElementById('inputSku').value.trim();
+ const codmat = document.getElementById('inputCodmat').value.trim();
+ const cantitate = parseFloat(document.getElementById('inputCantitate').value) || 1;
+ const procent = parseFloat(document.getElementById('inputProcent').value) || 100;
+
+ if (!sku || !codmat) { alert('SKU si CODMAT sunt obligatorii'); return; }
+
+ try {
+ const res = await fetch('/api/mappings', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ sku, codmat, cantitate_roa: cantitate, procent_pret: procent })
+ });
+ const data = await res.json();
+
+ if (data.success) {
+ bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
+ clearForm();
+ loadMappings();
+ } else {
+ alert('Eroare: ' + (data.error || 'Unknown'));
+ }
+ } catch (err) {
+ alert('Eroare: ' + err.message);
+ }
+}
+
+function clearForm() {
+ document.getElementById('inputSku').value = '';
+ document.getElementById('inputCodmat').value = '';
+ document.getElementById('inputCantitate').value = '1';
+ document.getElementById('inputProcent').value = '100';
+ document.getElementById('selectedArticle').textContent = '';
+}
+
+// Inline edit
+function editCell(td, sku, codmat, field, currentValue) {
+ if (td.querySelector('input')) return; // Already editing
+
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.className = 'form-control form-control-sm';
+ input.value = currentValue;
+ input.step = field === 'cantitate_roa' ? '0.001' : '0.01';
+ input.style.width = '80px';
+
+ const originalText = td.textContent;
+ td.textContent = '';
+ td.appendChild(input);
+ input.focus();
+ input.select();
+
+ const save = async () => {
+ const newValue = parseFloat(input.value);
+ if (isNaN(newValue) || newValue === currentValue) {
+ td.textContent = originalText;
+ return;
+ }
+
+ try {
+ const body = {};
+ body[field] = newValue;
+ const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
+ method: 'PUT',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(body)
+ });
+ const data = await res.json();
+ if (data.success) {
+ loadMappings();
+ } else {
+ td.textContent = originalText;
+ alert('Eroare: ' + (data.error || 'Update failed'));
+ }
+ } catch (err) {
+ td.textContent = originalText;
+ }
+ };
+
+ input.addEventListener('blur', save);
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') save();
+ if (e.key === 'Escape') { td.textContent = originalText; }
+ });
+}
+
+// Toggle active
+async function toggleActive(sku, codmat, currentActive) {
+ try {
+ const res = await fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
+ method: 'PUT',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ activ: currentActive ? 0 : 1 })
+ });
+ const data = await res.json();
+ if (data.success) loadMappings();
+ } catch (err) {
+ alert('Eroare: ' + err.message);
+ }
+}
+
+// Delete (soft)
+function deleteMappingConfirm(sku, codmat) {
+ if (confirm(`Dezactivezi maparea ${sku} -> ${codmat}?`)) {
+ fetch(`/api/mappings/${encodeURIComponent(sku)}/${encodeURIComponent(codmat)}`, {
+ method: 'DELETE'
+ }).then(r => r.json()).then(d => {
+ if (d.success) loadMappings();
+ else alert('Eroare: ' + (d.error || 'Delete failed'));
+ });
+ }
+}
+
+// CSV import
+async function importCsv() {
+ const fileInput = document.getElementById('csvFile');
+ if (!fileInput.files.length) { alert('Selecteaza un fisier CSV'); return; }
+
+ const formData = new FormData();
+ formData.append('file', fileInput.files[0]);
+
+ try {
+ const res = await fetch('/api/mappings/import-csv', {
+ method: 'POST',
+ body: formData
+ });
+ const data = await res.json();
+
+ let html = `Procesate: ${data.processed}
`;
+ if (data.errors && data.errors.length > 0) {
+ html += `Erori:
${data.errors.map(e => `- ${esc(e)}
`).join('')}
`;
+ }
+ document.getElementById('importResult').innerHTML = html;
+ loadMappings();
+ } catch (err) {
+ document.getElementById('importResult').innerHTML = `${err.message}
`;
+ }
+}
+
+function exportCsv() {
+ window.location.href = '/api/mappings/export-csv';
+}
+
+function downloadTemplate() {
+ window.location.href = '/api/mappings/csv-template';
+}
+
+// Escape HTML
+function esc(s) {
+ if (s == null) return '';
+ return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
+}
diff --git a/api/app/templates/base.html b/api/app/templates/base.html
new file mode 100644
index 0000000..4cc6083
--- /dev/null
+++ b/api/app/templates/base.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+ {% block title %}GoMag Import Manager{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+ {% block scripts %}{% endblock %}
+
+
diff --git a/api/app/templates/dashboard.html b/api/app/templates/dashboard.html
new file mode 100644
index 0000000..9eda31f
--- /dev/null
+++ b/api/app/templates/dashboard.html
@@ -0,0 +1,135 @@
+{% extends "base.html" %}
+{% block title %}Dashboard - GoMag Import{% endblock %}
+{% block nav_dashboard %}active{% endblock %}
+
+{% block content %}
+Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Data |
+ Status |
+ Total |
+ OK |
+ Skip |
+ Err |
+ Durata |
+
+
+
+ | Se incarca... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | SKU |
+ Produs |
+ Data |
+ Actiune |
+
+
+
+ | Se incarca... |
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/api/app/templates/mappings.html b/api/app/templates/mappings.html
new file mode 100644
index 0000000..cb8f66a
--- /dev/null
+++ b/api/app/templates/mappings.html
@@ -0,0 +1,118 @@
+{% extends "base.html" %}
+{% block title %}Mapari SKU - GoMag Import{% endblock %}
+{% block nav_mappings %}active{% endblock %}
+
+{% block content %}
+
+
Mapari SKU
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | SKU |
+ CODMAT |
+ Denumire |
+ Cantitate ROA |
+ Procent Pret |
+ Activ |
+ Actiuni |
+
+
+
+ | Se incarca... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Format CSV: sku, codmat, cantitate_roa, procent_pret
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/api/app/templates/missing_skus.html b/api/app/templates/missing_skus.html
new file mode 100644
index 0000000..d3ad2f1
--- /dev/null
+++ b/api/app/templates/missing_skus.html
@@ -0,0 +1,223 @@
+{% extends "base.html" %}
+{% block title %}SKU-uri Lipsa - GoMag Import{% endblock %}
+{% block nav_missing %}active{% endblock %}
+
+{% block content %}
+
+
SKU-uri Lipsa
+
+
+
+
+
+
+
+
+
+
+
+
+ | SKU |
+ Produs |
+ First Seen |
+ Status |
+ Actiune |
+
+
+
+ | Se incarca... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/api/app/templates/sync_detail.html b/api/app/templates/sync_detail.html
new file mode 100644
index 0000000..4d4737f
--- /dev/null
+++ b/api/app/templates/sync_detail.html
@@ -0,0 +1,158 @@
+{% extends "base.html" %}
+{% block title %}Sync Run - GoMag Import{% endblock %}
+{% block nav_sync %}active{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Nr Comanda |
+ Data |
+ Client |
+ Articole |
+ Status |
+ Detalii |
+
+
+
+ | Se incarca... |
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/api/data/.gitkeep b/api/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/api/requirements.txt b/api/requirements.txt
index 849d80e..3d50356 100644
--- a/api/requirements.txt
+++ b/api/requirements.txt
@@ -1,5 +1,10 @@
-Flask==2.3.2
-Flask-CORS==4.0.0
-oracledb==1.4.2
-python-dotenv==1.0.0
-gunicorn==21.2.0
\ No newline at end of file
+fastapi==0.115.6
+uvicorn[standard]==0.34.0
+jinja2==3.1.4
+python-multipart==0.0.18
+oracledb==2.5.1
+aiosqlite==0.20.0
+apscheduler==3.10.4
+python-dotenv==1.0.1
+pydantic-settings==2.7.1
+httpx==0.28.1
diff --git a/api/test_app_basic.py b/api/test_app_basic.py
new file mode 100644
index 0000000..3f94bd2
--- /dev/null
+++ b/api/test_app_basic.py
@@ -0,0 +1,147 @@
+"""
+Test A: Basic App Import and Route Tests
+=========================================
+Tests module imports and all GET routes without requiring Oracle.
+Run: python test_app_basic.py
+
+Expected results:
+- All 17 module imports: PASS
+- HTML routes (/ /missing-skus /mappings /sync): PASS (templates exist)
+- /health: PASS (returns Oracle=error, sqlite=ok)
+- /api/sync/status, /api/sync/history, /api/validate/missing-skus: PASS (SQLite-only)
+- /api/mappings, /api/mappings/export-csv, /api/articles/search: FAIL (require Oracle pool)
+ These are KNOWN FAILURES when Oracle is unavailable - documented as bugs requiring guards.
+"""
+
+import os
+import sys
+import tempfile
+
+# --- Set env vars BEFORE any app import ---
+_tmpdir = tempfile.mkdtemp()
+_sqlite_path = os.path.join(_tmpdir, "test_import.db")
+
+os.environ["FORCE_THIN_MODE"] = "true"
+os.environ["SQLITE_DB_PATH"] = _sqlite_path
+os.environ["ORACLE_DSN"] = "dummy"
+os.environ["ORACLE_USER"] = "dummy"
+os.environ["ORACLE_PASSWORD"] = "dummy"
+
+# Add api/ to path so we can import app
+_api_dir = os.path.dirname(os.path.abspath(__file__))
+if _api_dir not in sys.path:
+ sys.path.insert(0, _api_dir)
+
+# -------------------------------------------------------
+# Section 1: Module Import Checks
+# -------------------------------------------------------
+
+MODULES = [
+ "app.config",
+ "app.database",
+ "app.main",
+ "app.routers.health",
+ "app.routers.dashboard",
+ "app.routers.mappings",
+ "app.routers.sync",
+ "app.routers.validation",
+ "app.routers.articles",
+ "app.services.sqlite_service",
+ "app.services.scheduler_service",
+ "app.services.mapping_service",
+ "app.services.article_service",
+ "app.services.validation_service",
+ "app.services.import_service",
+ "app.services.sync_service",
+ "app.services.order_reader",
+]
+
+passed = 0
+failed = 0
+results = []
+
+print("\n=== Test A: GoMag Import Manager Basic Tests ===\n")
+print("--- Section 1: Module Imports ---\n")
+
+for mod in MODULES:
+ try:
+ __import__(mod)
+ print(f" [PASS] import {mod}")
+ passed += 1
+ results.append((f"import:{mod}", True, None, False))
+ except Exception as e:
+ print(f" [FAIL] import {mod} -> {e}")
+ failed += 1
+ results.append((f"import:{mod}", False, str(e), False))
+
+# -------------------------------------------------------
+# Section 2: Route Tests via TestClient
+# -------------------------------------------------------
+
+print("\n--- Section 2: GET Route Tests ---\n")
+
+# Routes: (description, path, expected_ok_codes, known_oracle_failure)
+# known_oracle_failure=True means the route needs Oracle pool and will 500 without it.
+# These are flagged as bugs, not test infrastructure failures.
+GET_ROUTES = [
+ ("GET /health", "/health", [200], False),
+ ("GET / (dashboard HTML)", "/", [200, 500], False),
+ ("GET /missing-skus (HTML)", "/missing-skus", [200, 500], False),
+ ("GET /mappings (HTML)", "/mappings", [200, 500], False),
+ ("GET /sync (HTML)", "/sync", [200, 500], False),
+ ("GET /api/mappings", "/api/mappings", [200, 503], True),
+ ("GET /api/mappings/export-csv", "/api/mappings/export-csv", [200, 503], True),
+ ("GET /api/mappings/csv-template", "/api/mappings/csv-template", [200], False),
+ ("GET /api/sync/status", "/api/sync/status", [200], False),
+ ("GET /api/sync/history", "/api/sync/history", [200], False),
+ ("GET /api/sync/schedule", "/api/sync/schedule", [200], False),
+ ("GET /api/validate/missing-skus", "/api/validate/missing-skus", [200], False),
+ ("GET /api/articles/search?q=ab", "/api/articles/search?q=ab", [200, 503], True),
+]
+
+try:
+ from fastapi.testclient import TestClient
+ from app.main import app
+
+ # Use context manager so lifespan (startup/shutdown) runs properly.
+ # Without 'with', init_sqlite() never fires and SQLite-only routes return 500.
+ with TestClient(app, raise_server_exceptions=False) as client:
+ for name, path, expected, is_oracle_route in GET_ROUTES:
+ try:
+ resp = client.get(path)
+ if resp.status_code in expected:
+ print(f" [PASS] {name} -> HTTP {resp.status_code}")
+ passed += 1
+ results.append((name, True, None, is_oracle_route))
+ else:
+ body_snippet = resp.text[:300].replace("\n", " ")
+ print(f" [FAIL] {name} -> HTTP {resp.status_code} (expected {expected})")
+ print(f" Body: {body_snippet}")
+ failed += 1
+ results.append((name, False, f"HTTP {resp.status_code}", is_oracle_route))
+ except Exception as e:
+ print(f" [FAIL] {name} -> Exception: {e}")
+ failed += 1
+ results.append((name, False, str(e), is_oracle_route))
+
+except ImportError as e:
+ print(f" [FAIL] Cannot create TestClient: {e}")
+ print(" Make sure 'httpx' is installed: pip install httpx")
+ for name, path, _, _ in GET_ROUTES:
+ failed += 1
+ results.append((name, False, "TestClient unavailable", False))
+
+# -------------------------------------------------------
+# Summary
+# -------------------------------------------------------
+
+total = passed + failed
+print(f"\n=== Summary: {passed}/{total} tests passed ===")
+
+if failed > 0:
+ print("\nFailed tests:")
+ for name, ok, err, _ in results:
+ if not ok:
+ print(f" - {name}: {err}")
+
+sys.exit(0 if failed == 0 else 1)
diff --git a/api/test_integration.py b/api/test_integration.py
new file mode 100644
index 0000000..c6abf68
--- /dev/null
+++ b/api/test_integration.py
@@ -0,0 +1,252 @@
+"""
+Oracle Integration Tests for GoMag Import Manager
+==================================================
+Requires Oracle connectivity and valid .env configuration.
+
+Usage:
+ cd /mnt/e/proiecte/vending/gomag
+ python api/test_integration.py
+
+Note: Run from the project root so that relative paths in .env resolve correctly.
+ The .env file is read from the api/ directory.
+"""
+
+import os
+import sys
+
+# Set working directory to project root so relative paths in .env work
+_script_dir = os.path.dirname(os.path.abspath(__file__))
+_project_root = os.path.dirname(_script_dir)
+os.chdir(_project_root)
+
+# Load .env from api/ before importing app modules
+from dotenv import load_dotenv
+_env_path = os.path.join(_script_dir, ".env")
+load_dotenv(_env_path, override=True)
+
+# Add api/ to path so app package is importable
+sys.path.insert(0, _script_dir)
+
+from fastapi.testclient import TestClient
+
+# Import the app (triggers lifespan on first TestClient use)
+from app.main import app
+
+results = []
+
+
+def record(name: str, passed: bool, detail: str = ""):
+ status = "PASS" if passed else "FAIL"
+ msg = f"[{status}] {name}"
+ if detail:
+ msg += f" -- {detail}"
+ print(msg)
+ results.append(passed)
+
+
+# ---------------------------------------------------------------------------
+# Test A: GET /health โ Oracle must show as connected
+# ---------------------------------------------------------------------------
+def test_health(client: TestClient):
+ test_name = "GET /health - Oracle connected"
+ try:
+ resp = client.get("/health")
+ assert resp.status_code == 200, f"HTTP {resp.status_code}"
+ body = resp.json()
+ oracle_status = body.get("oracle", "")
+ sqlite_status = body.get("sqlite", "")
+ assert oracle_status == "ok", f"oracle={oracle_status!r}"
+ assert sqlite_status == "ok", f"sqlite={sqlite_status!r}"
+ record(test_name, True, f"oracle={oracle_status}, sqlite={sqlite_status}")
+ except Exception as exc:
+ record(test_name, False, str(exc))
+
+
+# ---------------------------------------------------------------------------
+# Test B: Mappings CRUD cycle
+# POST create -> GET list (verify present) -> PUT update -> DELETE -> verify
+# ---------------------------------------------------------------------------
+def test_mappings_crud(client: TestClient):
+ test_sku = "TEST_INTEG_SKU_001"
+ test_codmat = "TEST_CODMAT_001"
+
+ # -- CREATE --
+ try:
+ resp = client.post("/api/mappings", json={
+ "sku": test_sku,
+ "codmat": test_codmat,
+ "cantitate_roa": 2.5,
+ "procent_pret": 80.0
+ })
+ assert resp.status_code == 200, f"HTTP {resp.status_code}"
+ body = resp.json()
+ assert body.get("success") is True, f"create returned: {body}"
+ record("POST /api/mappings - create mapping", True,
+ f"sku={test_sku}, codmat={test_codmat}")
+ except Exception as exc:
+ record("POST /api/mappings - create mapping", False, str(exc))
+ # Skip the rest of CRUD if creation failed
+ return
+
+ # -- LIST (verify present) --
+ try:
+ resp = client.get("/api/mappings", params={"search": test_sku})
+ assert resp.status_code == 200, f"HTTP {resp.status_code}"
+ body = resp.json()
+ mappings = body.get("mappings", [])
+ found = any(
+ m["sku"] == test_sku and m["codmat"] == test_codmat
+ for m in mappings
+ )
+ assert found, f"mapping not found in list; got {mappings}"
+ record("GET /api/mappings - mapping visible after create", True,
+ f"total={body.get('total')}")
+ except Exception as exc:
+ record("GET /api/mappings - mapping visible after create", False, str(exc))
+
+ # -- UPDATE --
+ try:
+ resp = client.put(f"/api/mappings/{test_sku}/{test_codmat}", json={
+ "cantitate_roa": 3.0,
+ "procent_pret": 90.0
+ })
+ assert resp.status_code == 200, f"HTTP {resp.status_code}"
+ body = resp.json()
+ assert body.get("success") is True, f"update returned: {body}"
+ record("PUT /api/mappings/{sku}/{codmat} - update mapping", True,
+ "cantitate_roa=3.0, procent_pret=90.0")
+ except Exception as exc:
+ record("PUT /api/mappings/{sku}/{codmat} - update mapping", False, str(exc))
+
+ # -- DELETE (soft: sets activ=0) --
+ try:
+ resp = client.delete(f"/api/mappings/{test_sku}/{test_codmat}")
+ assert resp.status_code == 200, f"HTTP {resp.status_code}"
+ body = resp.json()
+ assert body.get("success") is True, f"delete returned: {body}"
+ record("DELETE /api/mappings/{sku}/{codmat} - soft delete", True)
+ except Exception as exc:
+ record("DELETE /api/mappings/{sku}/{codmat} - soft delete", False, str(exc))
+
+ # -- VERIFY: after soft-delete activ=0, listing without search filter should
+ # show it as activ=0 (it is still in DB). Search for it and confirm activ=0. --
+ try:
+ resp = client.get("/api/mappings", params={"search": test_sku})
+ assert resp.status_code == 200, f"HTTP {resp.status_code}"
+ body = resp.json()
+ mappings = body.get("mappings", [])
+ deleted = any(
+ m["sku"] == test_sku and m["codmat"] == test_codmat and m.get("activ") == 0
+ for m in mappings
+ )
+ assert deleted, (
+ f"expected activ=0 for deleted mapping, got: "
+ f"{[m for m in mappings if m['sku'] == test_sku]}"
+ )
+ record("GET /api/mappings - mapping has activ=0 after delete", True)
+ except Exception as exc:
+ record("GET /api/mappings - mapping has activ=0 after delete", False, str(exc))
+
+
+# ---------------------------------------------------------------------------
+# Test C: GET /api/articles/search?q= โ must return results
+# ---------------------------------------------------------------------------
+def test_articles_search(client: TestClient):
+ # Use a short generic term that should exist in most ROA databases
+ search_terms = ["01", "A", "PH"]
+ test_name = "GET /api/articles/search - returns results"
+ try:
+ found_results = False
+ last_body = {}
+ for term in search_terms:
+ resp = client.get("/api/articles/search", params={"q": term})
+ assert resp.status_code == 200, f"HTTP {resp.status_code}"
+ body = resp.json()
+ last_body = body
+ results_list = body.get("results", [])
+ if results_list:
+ found_results = True
+ record(test_name, True,
+ f"q={term!r} returned {len(results_list)} results; "
+ f"first={results_list[0].get('codmat')!r}")
+ break
+ if not found_results:
+ # Search returned empty โ not necessarily a failure if DB is empty,
+ # but we flag it as a warning.
+ record(test_name, False,
+ f"all search terms returned empty; last response: {last_body}")
+ except Exception as exc:
+ record(test_name, False, str(exc))
+
+
+# ---------------------------------------------------------------------------
+# Test D: POST /api/validate/scan โ triggers scan of JSON folder
+# ---------------------------------------------------------------------------
+def test_validate_scan(client: TestClient):
+ test_name = "POST /api/validate/scan - returns valid response"
+ try:
+ resp = client.post("/api/validate/scan")
+ assert resp.status_code == 200, f"HTTP {resp.status_code}"
+ body = resp.json()
+ # Must have at least these keys
+ for key in ("json_files", "orders", "skus"):
+ # "orders" may be "total_orders" if orders exist; "orders" key only
+ # present in the "No orders found" path.
+ pass
+ # Accept both shapes: no-orders path has "orders" key, full path has "total_orders"
+ has_shape = "json_files" in body and ("orders" in body or "total_orders" in body)
+ assert has_shape, f"unexpected response shape: {body}"
+ record(test_name, True, f"json_files={body.get('json_files')}, "
+ f"orders={body.get('total_orders', body.get('orders'))}")
+ except Exception as exc:
+ record(test_name, False, str(exc))
+
+
+# ---------------------------------------------------------------------------
+# Test E: GET /api/sync/history โ must return a list structure
+# ---------------------------------------------------------------------------
+def test_sync_history(client: TestClient):
+ test_name = "GET /api/sync/history - returns list structure"
+ try:
+ resp = client.get("/api/sync/history")
+ assert resp.status_code == 200, f"HTTP {resp.status_code}"
+ body = resp.json()
+ assert "runs" in body, f"missing 'runs' key; got keys: {list(body.keys())}"
+ assert isinstance(body["runs"], list), f"'runs' is not a list: {type(body['runs'])}"
+ assert "total" in body, f"missing 'total' key"
+ record(test_name, True,
+ f"total={body.get('total')}, page={body.get('page')}, pages={body.get('pages')}")
+ except Exception as exc:
+ record(test_name, False, str(exc))
+
+
+# ---------------------------------------------------------------------------
+# Main runner
+# ---------------------------------------------------------------------------
+def main():
+ print("=" * 60)
+ print("GoMag Import Manager - Oracle Integration Tests")
+ print(f"Env file: {_env_path}")
+ print(f"Oracle DSN: {os.environ.get('ORACLE_DSN', '(not set)')}")
+ print("=" * 60)
+
+ with TestClient(app) as client:
+ test_health(client)
+ test_mappings_crud(client)
+ test_articles_search(client)
+ test_validate_scan(client)
+ test_sync_history(client)
+
+ passed = sum(results)
+ total = len(results)
+ print("=" * 60)
+ print(f"Summary: {passed}/{total} tests passed")
+ if passed < total:
+ print("Some tests FAILED โ review output above for details.")
+ sys.exit(1)
+ else:
+ print("All tests PASSED.")
+
+
+if __name__ == "__main__":
+ main()