Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
452 lines
17 KiB
Python
452 lines
17 KiB
Python
"""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
|
|
]
|