feat: add FastAPI admin dashboard with sync orchestration and test suite
Replace Flask admin with FastAPI app (api/app/) featuring: - Dashboard with stat cards, sync control, and history - Mappings CRUD for ARTICOLE_TERTI with CSV import/export - Article autocomplete from NOM_ARTICOLE - SKU pre-validation before import - Sync orchestration: read JSONs -> validate -> import -> log to SQLite - APScheduler for periodic sync from UI - File logging to logs/sync_comenzi_YYYYMMDD_HHMMSS.log - Oracle pool None guard (503 vs 500 on unavailable) Test suite: - test_app_basic.py: 30 tests (imports + routes) without Oracle - test_integration.py: 9 integration tests with Oracle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
192
api/app/services/import_service.py
Normal file
192
api/app/services/import_service.py
Normal file
@@ -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 <br> tags
|
||||
for br in ('<br>', '<br/>', '<br />'):
|
||||
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
|
||||
Reference in New Issue
Block a user