import re from datetime import date, datetime from typing import List, NoReturn, Optional, Tuple import oracledb from fastapi import HTTPException from shared.database.oracle_pool import oracle_pool from ..schemas.comanda import ( ComandaListItem, ComandaListResponse, ComandaRequest, ComandaResponse, ) from .lookup_service import LookupService from ._context import get_schema from .. import logger _MAX_PER_PAGE = 100 _MAX_OPERATII_CSV = 4000 # Oracle VARCHAR2 limit; ~600 IDs at 6 chars each # Source: DEV_TIP_DEVIZ (verified 2026-04-13): # 1=POST GARANTIE, 2=GARANTIE, 3=REGIE, 4=PREGATIRE, 5=REGIE 2 (no VFP mapping → ""), # 6=PRODUCTIE, 7=CONSTATARE # VFP reference: oproceduri_devize.prg lines 108-120 (pntipcom switch) _PREFIX_MAP = {1: "", 2: "G", 3: "R", 4: "P", 6: "PR", 7: "C"} def _aloca_numar_devize( cursor, schema: str, user_id: int, id_sucursala: int, ) -> Tuple[int, int]: """ Calls {schema}.PACK_SERII_NUMERE.aloca_numar(20, NULL, NULL, user_id, id_sucursala) — 7-param overload. Returns (seq, id_numar): seq — the allocated command number (used in pc_nr) id_numar — SERII_NUMERE.ID_NUMAR row, used by dezaloca_id_numar compensating call """ out_numar = cursor.var(oracledb.NUMBER) out_id_numar = cursor.var(oracledb.NUMBER) cursor.callproc( f"{schema}.PACK_SERII_NUMERE.aloca_numar", [20, None, None, user_id, id_sucursala, out_numar, out_id_numar], ) seq = int(out_numar.getvalue() or 0) id_numar = int(out_id_numar.getvalue() or 0) logger.info( "service_auto.seq_allocated", extra={"seq": seq, "id_numar": id_numar, "user_id": user_id, "id_sucursala": id_sucursala}, ) if seq <= 0: raise HTTPException( status_code=422, detail="Nu aveți serie alocată pentru comenzi devize. Contactați administratorul.", ) return seq, id_numar def _dezaloca_numar_devize( cursor, schema: str, seq: int, id_numar: int, reason: str, ) -> None: """Compensating transaction — releases allocated seq number on callproc failure.""" try: cursor.callproc(f"{schema}.PACK_SERII_NUMERE.dezaloca_id_numar", [id_numar]) logger.info( "service_auto.seq_released", extra={"seq": seq, "id_numar": id_numar, "reason": reason}, ) except Exception: logger.warning( "SEQ_LEAK", extra={"seq": seq, "id_numar": id_numar, "reason": reason}, exc_info=True, ) def _build_pc_nr(tip_id: int, seq: int, nr_inmatriculare: str) -> str: """Format: /""" prefix = _PREFIX_MAP.get(tip_id) if prefix is None: logger.warning("service_auto.unknown_tip_id", extra={"tip_id": tip_id}) prefix = "" return f"{prefix}{seq}/{nr_inmatriculare}" def _build_sir_id_operatii(operatii: Optional[List[int]]) -> Optional[str]: """Serializes list of operation IDs to CSV string for pcSirIdOperatii param.""" if not operatii: return None csv = ",".join(str(i) for i in operatii) if len(csv) > _MAX_OPERATII_CSV: raise HTTPException( status_code=422, detail=f"Prea multe operații selectate (max ~{_MAX_OPERATII_CSV // 6}).", ) return csv def _handle_oracle_error(e: Exception) -> NoReturn: """Map Oracle error codes to FastAPI HTTPExceptions. Always raises.""" err = e.args[0] code = getattr(err, "code", 0) raw_message = getattr(err, "message", str(e)) if 20000 <= code <= 20999: # Strip "ORA-2xxxx: " prefix; strip "\nORA-06512: at ..." stack frames. clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip() clean = clean.split("\n")[0].strip() raise HTTPException(status_code=422, detail=clean) if code == 1438: raise HTTPException(status_code=422, detail="Valoare invalidă pentru câmp (ID prea mare)") if code in (12541, 12170, 12154, 12560): raise HTTPException( status_code=503, detail="Serviciul bazei de date e temporar indisponibil", ) if code == 1017: logger.critical("Oracle credentials rejected (ORA-01017)", exc_info=True) raise HTTPException( status_code=500, detail="Eroare de configurare. Contactați administratorul.", ) if code == 942: logger.critical("Oracle object not found or grant missing (ORA-00942)", exc_info=True) raise HTTPException( status_code=500, detail="Eroare internă. Contactați administratorul.", ) logger.error("Unexpected Oracle error ORA-%05d", code, exc_info=True) raise HTTPException(status_code=500, detail="Eroare internă neașteptată") class ComandaService: @staticmethod async def creeaza_comanda( data: ComandaRequest, username: str, user_id: Optional[int] = None, server_id: Optional[str] = None, ) -> ComandaResponse: now = datetime.now() schema = await get_schema(data.id_firma, server_id) # Fetch vehicle details early: validates vehicle exists + gets nrinmat for pc_nr masina = await LookupService.get_masina_details( data.id_masiniclient, data.id_firma, server_id ) if masina is None: raise HTTPException(status_code=422, detail="Mașina selectată nu există.") nr_inmatriculare = masina.nr_inmatriculare or "?" pc_sir_id_operatii = _build_sir_id_operatii(data.sir_id_operatii) id_sucursala = data.id_sucursala or data.id_firma logger.info( "service_auto.create_comanda START", extra={ "user": username, "schema": schema, "server_id": server_id, "tip": data.tip_id, "client_id": data.id_masiniclient, "id_firma": data.id_firma, "km": data.km_int, "ore": data.ore_functionare, "id_asigurator": data.id_asigurator, "id_inspector": data.id_inspector, "nr_operatii": len(data.sir_id_operatii) if data.sir_id_operatii else 0, }, ) async with oracle_pool.get_connection(server_id) as connection: with connection.cursor() as cursor: # Step 1: allocate sequence number via pack_serii_numere try: seq, id_numar = _aloca_numar_devize( cursor, schema, user_id or 0, id_sucursala ) except oracledb.DatabaseError as e: try: connection.rollback() except Exception: pass _handle_oracle_error(e) pc_nr = _build_pc_nr(data.tip_id, seq, nr_inmatriculare) # Step 2: create comanda; compensating dezaloca on DB failure. # pnIdOrdl is IN OUT — setvalue(0, 0) sets the IN side to 0; # Oracle overwrites it with the new DEV_ORDL.ID_ORDL. out_id_ordl = cursor.var(oracledb.NUMBER) out_id_ordl.setvalue(0, 0) try: cursor.callproc( f"{schema}.PACK_AUTO.dev_adauga_lucrare", [ schema, # v_gcs IN VARCHAR2 now.year, # tnan IN NUMBER now.month, # tnluna IN NUMBER user_id or 0, # tnIdUtil IN NUMBER pc_nr, # pcNr IN VARCHAR2 (NOM_LUCRARI.NRORD) data.id_inspector, # pnIdInsp IN NUMBER data.id_asigurator, # pnIdAsig IN NUMBER data.nr_dosar or "", # pcNrDosar IN VARCHAR2 data.id_masiniclient, # pnIdMC IN NUMBER data.km_int, # pnKmInt IN NUMBER data.ore_functionare, # pnOreFct IN NUMBER data.termen, # pdTermen IN DATE data.tip_id, # pnTipCom IN NUMBER pc_sir_id_operatii, # pcSirIdOperatii IN VARCHAR2 (None or CSV) data.observatii or None, # pcObservatii IN VARCHAR2 data.defectiuni or None, # pcDefectiuni IN VARCHAR2 data.id_part_ref or 0, # pnIdPartRef IN NUMBER (decode(0)→NULL in SP) out_id_ordl, # pnIdOrdl IN OUT NUMBER ], ) connection.commit() except oracledb.DatabaseError as e: try: connection.rollback() except Exception: pass _dezaloca_numar_devize(cursor, schema, seq, id_numar, "dev_adauga_lucrare_failed") try: # aloca uses AUTONOMOUS_TRANSACTION; survives our rollback. Commit dezaloca. connection.commit() except Exception: pass _handle_oracle_error(e) id_ordl = int(out_id_ordl.getvalue()) logger.info( "service_auto.create_comanda OK", extra={"user": username, "id_ordl": id_ordl, "nrord": pc_nr}, ) return ComandaResponse( id_ordl=id_ordl, nrord=pc_nr, mesaj=f"Comanda {pc_nr} creată cu succes.", ) @staticmethod async def get_comenzi( company_id: int, page: int, per_page: int, validat: Optional[int], data_de_la: Optional[date], data_pana_la: Optional[date], server_id: Optional[str] = None, ) -> ComandaListResponse: per_page = min(per_page, _MAX_PER_PAGE) offset = (page - 1) * per_page schema = await get_schema(company_id, server_id) where_parts = ["d.sters = 0"] filter_params: dict = {} if validat is not None: where_parts.append("d.validat = :validat") filter_params["validat"] = validat if data_de_la is not None: where_parts.append("d.datai >= :data_de_la") filter_params["data_de_la"] = data_de_la if data_pana_la is not None: # +1 day range avoids TRUNC (keeps index use on datai) where_parts.append("d.datai < :data_pana_la + 1") filter_params["data_pana_la"] = data_pana_la where_clause = " AND ".join(where_parts) base_from = f""" FROM {schema}.DEV_ORDL d LEFT JOIN {schema}.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare LEFT JOIN {schema}.AUTO_VMASINICLIENTI mc ON d.id_masiniclient = mc.id_masiniclient LEFT JOIN {schema}.DEV_TIP_DEVIZ t ON d.id_tip = t.id_tip WHERE {where_clause} """ count_query = f"SELECT COUNT(*) {base_from}" data_query = f""" SELECT d.id_ordl, l.nrord, d.datai, d.validat, d.inchis_fortat, d.id_tip, t.denumire, d.id_masiniclient, mc.nrinmat, mc.marca, mc.masina, mc.anfabricatie, mc.partener {base_from} ORDER BY d.datai DESC, d.id_ordl DESC OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY """ try: async with oracle_pool.get_connection(server_id) as conn: with conn.cursor() as cur: cur.execute(count_query, filter_params) total = cur.fetchone()[0] cur.execute( data_query, {**filter_params, "offset": offset, "per_page": per_page}, ) rows = cur.fetchall() except oracledb.DatabaseError as e: _handle_oracle_error(e) comenzi: List[ComandaListItem] = [] for r in rows: (id_ordl, nrord, datai, validat_val, inchis_fortat, id_tip, tip_denumire, id_mc, nrinmat, marca, masina, an, partener) = r if id_mc: parts = [] if marca: parts.append(marca) if masina: parts.append(masina) vehicul_str = " ".join(parts) if parts else "?" an_str = f" ({int(an)})" if an else "" vehicul = f"{partener or '?'} — {vehicul_str}, {nrinmat or '?'}{an_str}" else: vehicul = "" comenzi.append(ComandaListItem( id_ordl=int(id_ordl), nrord=nrord or "", datai=datai.strftime("%Y-%m-%d") if datai else None, validat=int(validat_val), inchis_fortat=int(inchis_fortat or 0), id_tip=int(id_tip), tip_denumire=tip_denumire or "", vehicul=vehicul, id_masiniclient=int(id_mc) if id_mc else None, )) logger.debug( "service_auto.get_comenzi page=%d per_page=%d total=%d", page, per_page, total, ) return ComandaListResponse( comenzi=comenzi, total=total, page=page, per_page=per_page, )