"""Service for syncing nomenclatures from Oracle to SQLite.""" import sys from pathlib import Path from typing import Optional, List, Tuple from datetime import datetime import logging from sqlmodel import select from sqlalchemy.ext.asyncio import AsyncSession # Path setup handled by main.py - this is redundant # project_root = Path(__file__).parent.parent.parent.parent.parent # sys.path.insert(0, str(project_root / "shared")) from shared.database.oracle_pool import oracle_pool from backend.modules.data_entry.db.models.nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister logger = logging.getLogger(__name__) # Cache for schema lookups (populated dynamically from Oracle) # Key format: (server_id, company_id) for multi-server support _schema_cache: dict[tuple, str] = {} class SyncService: """Service for syncing nomenclatures from Oracle.""" @staticmethod async def get_schema_for_company(company_id: int, server_id: Optional[str] = None) -> Optional[str]: """ Get Oracle schema for company ID from V_NOM_FIRME view. Results are cached in memory for performance. Args: company_id: The company ID to look up server_id: Optional Oracle server ID for multi-server mode """ # Check cache first - use (server_id, company_id) as key for multi-server support cache_key = (server_id, company_id) if cache_key in _schema_cache: return _schema_cache[cache_key] try: async with oracle_pool.get_connection(server_id) as connection: with connection.cursor() as cursor: cursor.execute(""" SELECT SCHEMA FROM CONTAFIN_ORACLE.V_NOM_FIRME WHERE ID_FIRMA = :company_id """, {'company_id': company_id}) result = cursor.fetchone() if result: schema = result[0] _schema_cache[cache_key] = schema logger.info(f"Resolved schema for company {company_id} on server {server_id}: {schema}") return schema else: logger.warning(f"No schema found for company {company_id} on server {server_id}") return None except Exception as e: logger.error(f"Error fetching schema for company {company_id} on server {server_id}: {e}") return None @staticmethod async def sync_suppliers(session: AsyncSession, company_id: int, server_id: Optional[str] = None) -> Tuple[int, int]: """ Sync suppliers (furnizori, id_tip_part=17) from Oracle to SQLite. Uses CORESP_TIP_PART joined with VNOM_PARTENERI view. Returns (synced_count, error_count). Args: session: SQLAlchemy async session for SQLite company_id: The company ID to sync suppliers for server_id: Optional Oracle server ID for multi-server mode """ schema = await SyncService.get_schema_for_company(company_id, server_id) if not schema: logger.warning(f"No schema mapping for company {company_id} on server {server_id}") return 0, 0 synced = 0 errors = 0 try: async with oracle_pool.get_connection(server_id) as connection: with connection.cursor() as cursor: # Fetch active suppliers from Oracle # id_tip_part = 17 means "furnizori" (suppliers) # Using CORESP_TIP_PART to filter by partner type cursor.execute(f""" SELECT B.ID_PART, B.DENUMIRE, B.COD_FISCAL, B.ADRESA FROM {schema}.CORESP_TIP_PART A INNER JOIN {schema}.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART WHERE A.ID_TIP_PART = 17 AND (B.INACTIV = 0 OR B.INACTIV IS NULL) AND B.ID_PART IS NOT NULL ORDER BY B.DENUMIRE """) rows = cursor.fetchall() for row in rows: try: oracle_id, name, fiscal_code, address = row # Check if already exists stmt = select(SyncedSupplier).where( SyncedSupplier.oracle_id == oracle_id, SyncedSupplier.company_id == company_id ) result = await session.execute(stmt) existing = result.scalar_one_or_none() if existing: # Update existing record existing.name = name or "" existing.fiscal_code = fiscal_code existing.address = address existing.synced_at = datetime.utcnow() logger.debug(f"Updated supplier {oracle_id}: {name}") else: # Create new record supplier = SyncedSupplier( oracle_id=oracle_id, company_id=company_id, name=name or "", fiscal_code=fiscal_code, address=address, ) session.add(supplier) logger.debug(f"Created supplier {oracle_id}: {name}") synced += 1 except Exception as e: logger.error(f"Error processing supplier row {row}: {e}") errors += 1 # Commit all changes await session.commit() logger.info(f"Synced {synced} suppliers for company {company_id}, {errors} errors") except Exception as e: logger.error(f"Error syncing suppliers for company {company_id}: {e}") errors += 1 await session.rollback() return synced, errors @staticmethod async def sync_cash_registers(session: AsyncSession, company_id: int, server_id: Optional[str] = None) -> Tuple[int, int]: """ Sync cash registers and bank accounts from Oracle to SQLite. Returns (synced_count, error_count). Uses CORESP_TIP_PART with: - id_tip_part = 22: CASA LEI - id_tip_part = 23: CASA VALUTA - id_tip_part = 24: BANCA LEI - id_tip_part = 25: BANCA VALUTA Args: session: SQLAlchemy async session for SQLite company_id: The company ID to sync cash registers for server_id: Optional Oracle server ID for multi-server mode """ schema = await SyncService.get_schema_for_company(company_id, server_id) if not schema: logger.warning(f"No schema mapping for company {company_id} on server {server_id}") return 0, 0 synced = 0 errors = 0 # Partner types mapping # 22=CASA LEI, 23=CASA VALUTA -> cash # 24=BANCA LEI, 25=BANCA VALUTA -> bank partner_types = [22, 23, 24, 25] try: async with oracle_pool.get_connection(server_id) as connection: with connection.cursor() as cursor: # Fetch cash/bank partners from CORESP_TIP_PART cursor.execute(f""" SELECT B.ID_PART, B.DENUMIRE, A.ID_TIP_PART FROM {schema}.CORESP_TIP_PART A INNER JOIN {schema}.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART WHERE A.ID_TIP_PART IN (22, 23, 24, 25) AND (B.INACTIV = 0 OR B.INACTIV IS NULL) AND B.ID_PART IS NOT NULL ORDER BY A.ID_TIP_PART, B.DENUMIRE """) rows = cursor.fetchall() # Type mapping: 22=CASA LEI, 23=CASA VALUTA -> cash; 24=BANCA LEI, 25=BANCA VALUTA -> bank type_mapping = { 22: ("cash", "CASA_LEI"), 23: ("cash", "CASA_VALUTA"), 24: ("bank", "BANCA_LEI"), 25: ("bank", "BANCA_VALUTA"), } for row in rows: try: oracle_id, name, tip_part_id = row # Determine type based on partner type register_type, account_code = type_mapping.get(tip_part_id, ("cash", "UNKNOWN")) # Check if already exists stmt = select(SyncedCashRegister).where( SyncedCashRegister.oracle_id == oracle_id, SyncedCashRegister.company_id == company_id ) result = await session.execute(stmt) existing = result.scalar_one_or_none() if existing: # Update existing record existing.name = name or "" existing.account_code = account_code existing.register_type = register_type existing.synced_at = datetime.utcnow() logger.debug(f"Updated cash register {oracle_id}: {name}") else: # Create new record cash_register = SyncedCashRegister( oracle_id=oracle_id, company_id=company_id, name=name or "", account_code=account_code, register_type=register_type, ) session.add(cash_register) logger.debug(f"Created cash register {oracle_id}: {name}") synced += 1 except Exception as e: logger.error(f"Error processing cash register row {row}: {e}") errors += 1 # Commit all changes await session.commit() logger.info(f"Synced {synced} cash registers for company {company_id}, {errors} errors") except Exception as e: logger.error(f"Error syncing cash registers for company {company_id}: {e}") errors += 1 await session.rollback() return synced, errors @staticmethod def _get_fiscal_code_variants(fiscal_code: str) -> list: """ Generate all possible variants of a Romanian fiscal code (CUI). Database may store: "22891860", "RO22891860", "RO 22891860" OCR may extract: "RO22891860" or "22891860" """ import re # Extract just the digits digits = re.sub(r'[^0-9]', '', fiscal_code) if not digits: return [fiscal_code] # Generate all variants variants = [ digits, # Just digits: 22891860 f"RO{digits}", # With RO prefix: RO22891860 f"RO {digits}", # With RO prefix and space: RO 22891860 ] # Also add the original if different if fiscal_code not in variants: variants.append(fiscal_code) return variants @staticmethod async def search_supplier( session: AsyncSession, company_id: int, fiscal_code: Optional[str] = None, name: Optional[str] = None ) -> Tuple[bool, Optional[dict], str]: """ Search for supplier in SQLite first, then Oracle if not found. Returns (found, supplier_data, source). Source can be: 'synced', 'local', 'not_found' """ # 1. Search in synced suppliers if fiscal_code: # Search all variants of the fiscal code (with/without RO, with/without space) variants = SyncService._get_fiscal_code_variants(fiscal_code) stmt = select(SyncedSupplier).where( SyncedSupplier.company_id == company_id, SyncedSupplier.fiscal_code.in_(variants) ) elif name: stmt = select(SyncedSupplier).where( SyncedSupplier.company_id == company_id, SyncedSupplier.name.ilike(f"%{name}%") ) else: return False, None, "no_query" result = await session.execute(stmt) supplier = result.scalar_one_or_none() if supplier: # Return only text data - no IDs needed for autocomplete return True, { "name": supplier.name, "fiscal_code": supplier.fiscal_code, "address": supplier.address, }, "synced" # 2. Search in local suppliers if fiscal_code: # Search all variants of the fiscal code (with/without RO, with/without space) variants = SyncService._get_fiscal_code_variants(fiscal_code) stmt = select(LocalSupplier).where( LocalSupplier.company_id == company_id, LocalSupplier.fiscal_code.in_(variants) ) elif name: stmt = select(LocalSupplier).where( LocalSupplier.company_id == company_id, LocalSupplier.name.ilike(f"%{name}%") ) result = await session.execute(stmt) local = result.scalar_one_or_none() if local: # Return only text data - no IDs needed for autocomplete return True, { "name": local.name, "fiscal_code": local.fiscal_code, "address": local.address, }, "local" # 3. Try live Oracle search (optional fallback for unsynced data) # This is a fallback - ideally sync should be up to date # TODO: Implement live Oracle search if needed return False, None, "not_found" @staticmethod async def create_local_supplier( session: AsyncSession, company_id: int, name: str, fiscal_code: Optional[str], address: Optional[str], created_by: str ) -> LocalSupplier: """Create a local supplier entry from OCR data.""" supplier = LocalSupplier( company_id=company_id, name=name, fiscal_code=fiscal_code, address=address, created_by=created_by, ) session.add(supplier) await session.commit() await session.refresh(supplier) logger.info(f"Created local supplier: {name} (CUI: {fiscal_code})") return supplier @staticmethod async def get_all_suppliers( session: AsyncSession, company_id: int, search: Optional[str] = None ) -> List[dict]: """ Get all suppliers (synced + local) for a company. Used for dropdown/autocomplete in UI. """ suppliers = [] # Get synced suppliers stmt = select(SyncedSupplier).where(SyncedSupplier.company_id == company_id) if search: stmt = stmt.where( (SyncedSupplier.name.ilike(f"%{search}%")) | (SyncedSupplier.fiscal_code.ilike(f"%{search}%")) ) stmt = stmt.limit(50) # Limit results for performance result = await session.execute(stmt) synced = result.scalars().all() for s in synced: suppliers.append({ "id": s.id, "oracle_id": s.oracle_id, "name": s.name, "fiscal_code": s.fiscal_code, "source": "synced" }) # Get local suppliers stmt = select(LocalSupplier).where(LocalSupplier.company_id == company_id) if search: stmt = stmt.where( (LocalSupplier.name.ilike(f"%{search}%")) | (LocalSupplier.fiscal_code.ilike(f"%{search}%")) ) stmt = stmt.limit(50) result = await session.execute(stmt) local = result.scalars().all() for l in local: suppliers.append({ "id": l.id, "name": l.name, "fiscal_code": l.fiscal_code, "source": "local" }) return suppliers @staticmethod async def get_all_cash_registers( session: AsyncSession, company_id: int ) -> List[dict]: """ Get all cash registers for a company. Used for dropdown in UI. """ stmt = select(SyncedCashRegister).where(SyncedCashRegister.company_id == company_id) result = await session.execute(stmt) registers = result.scalars().all() return [ { "id": r.id, "oracle_id": r.oracle_id, "name": r.name, "account_code": r.account_code, "register_type": r.register_type } for r in registers ]