feat(service-auto): phase 2 — comenzi browse, id_sucursala, cache, migrare SQL

Backend:
- GET /api/service-auto/comenzi cu paginare server-side, filtre dată/status
- ComandaRequest.id_sucursala (Optional) + FirmaItem.id_mama
- get_firme() expune id_mama din V_NOM_FIRME
- callproc SP_CREEAZA_COMANDA_PROTOTIP cu 7 argumente (+ p_id_sucursala)
- Cache TTL in-process: tip_deviz 24h, masini 5min

Frontend:
- ComenziBrowseView.vue — DataTable lazy + filtre + status badges
- ComandaNoua.vue — company store integration, idSucursala computed
- service-auto/stores/sharedStores.js (createCompaniesStore factory)
- HamburgerMenu: secțiune Service Auto (Comenzi + Comandă Nouă)
- router: /service-auto/comenzi

SQL:
- migrations/ff_2026_04_12_01_AUTO.sql — idempotent (COLUMNEXIST guard + CREATE OR REPLACE SP)
- onboarding_roa_web.sql — versioned, parametrizat cu :SCHEMA_NAME
- .claude/rules/oracle-migrations.md — convenție ff_YYYY_MM_DD_NN_MODULE.sql

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-12 22:25:32 +00:00
parent 574aca31e4
commit 0a880baef9
15 changed files with 694 additions and 12 deletions

View File

@@ -1,15 +1,16 @@
import time
from typing import List
from datetime import date
from typing import List, Optional
import oracledb
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from shared.auth.dependencies import get_current_user
from shared.auth.models import CurrentUser
from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import (
ComandaRequest, ComandaResponse,
ComandaListResponse, ComandaRequest, ComandaResponse,
FirmaItem, TipDevizItem, MasinaClientItem,
)
from ..services.comanda_service import ComandaService
@@ -51,6 +52,26 @@ async def get_masini(_: CurrentUser = Depends(get_current_user)):
return await LookupService.get_masini()
@router.get("/comenzi", response_model=ComandaListResponse)
async def list_comenzi(
page: int = Query(default=1, ge=1),
per_page: int = Query(default=20, ge=1, le=100),
validat: Optional[int] = Query(default=None, ge=0, le=1),
data_de_la: Optional[date] = Query(default=None),
data_pana_la: Optional[date] = Query(default=None),
_: CurrentUser = Depends(get_current_user),
):
# NOTE: DEV_ORDL has no id_firma column — firmă filter not available at DB level.
# All comenzi in MARIUSM_AUTO schema are visible (companies 110/167/169 share schema).
return await ComandaService.get_comenzi(
page=page,
per_page=per_page,
validat=validat,
data_de_la=data_de_la,
data_pana_la=data_pana_la,
)
@router.post("/comenzi", response_model=ComandaResponse)
async def creeaza_comanda(
data: ComandaRequest,

View File

@@ -1,3 +1,5 @@
from typing import List, Optional
from pydantic import BaseModel
@@ -6,6 +8,7 @@ class ComandaRequest(BaseModel):
id_masiniclient: int
solicitari: str
id_firma: int
id_sucursala: Optional[int] = None
class ComandaResponse(BaseModel):
@@ -18,6 +21,7 @@ class FirmaItem(BaseModel):
id_firma: int
firma: str
schema_name: str
id_mama: Optional[int] = None
class TipDevizItem(BaseModel):
@@ -29,3 +33,22 @@ class TipDevizItem(BaseModel):
class MasinaClientItem(BaseModel):
id_masiniclient: int
label: str # "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)"
class ComandaListItem(BaseModel):
id_ordl: int
nrord: str
datai: Optional[str] # ISO date "YYYY-MM-DD"
validat: int # 0=deschisă, 1=validată
inchis_fortat: int # 1=arhivată fără validare
id_tip: int
tip_denumire: str
vehicul: str # "PARTENER — MARCA MASINA, NRINMAT (AN)"
id_masiniclient: Optional[int]
class ComandaListResponse(BaseModel):
comenzi: List[ComandaListItem]
total: int
page: int
per_page: int

View File

@@ -1,12 +1,17 @@
import re
from typing import NoReturn
from datetime import date
from typing import List, NoReturn, Optional
import oracledb
from fastapi import HTTPException
from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import ComandaRequest, ComandaResponse
from ..schemas.comanda import (
ComandaListItem, ComandaListResponse, ComandaRequest, ComandaResponse,
)
from .. import logger
_MAX_PER_PAGE = 100
def _handle_oracle_error(e: Exception) -> NoReturn:
"""
@@ -81,6 +86,7 @@ class ComandaService:
data.id_masiniclient, # p_id_masiniclient IN NUMBER
data.solicitari, # p_solicitari IN VARCHAR2
data.id_firma, # p_id_firma IN NUMBER
data.id_sucursala, # p_id_sucursala IN NUMBER (None for parent firm)
out_id_ordl, # p_id_ordl OUT NUMBER
out_nrord, # p_nrord OUT VARCHAR2
],
@@ -107,3 +113,108 @@ class ComandaService:
nrord=nrord,
mesaj=f"Comanda {nrord} creata cu succes.",
)
@staticmethod
async def get_comenzi(
page: int,
per_page: int,
validat: Optional[int],
data_de_la: Optional[date],
data_pana_la: Optional[date],
) -> ComandaListResponse:
per_page = min(per_page, _MAX_PER_PAGE)
offset = (page - 1) * per_page
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 MARIUSM_AUTO.DEV_ORDL d
LEFT JOIN MARIUSM_AUTO.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare
LEFT JOIN MARIUSM_AUTO.AUTO_VMASINICLIENTI mc
ON d.id_masiniclient = mc.id_masiniclient
LEFT JOIN MARIUSM_AUTO.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("mariusm_test") 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,
)

View File

@@ -3,7 +3,8 @@ Lookup data for service_auto forms — tip deviz, masini, firme.
All three endpoints are read-only and infrequently changing.
"""
from typing import List
import time
from typing import List, Optional, Tuple
import oracledb
from fastapi import HTTPException
@@ -12,6 +13,23 @@ from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import FirmaItem, MasinaClientItem, TipDevizItem
from .. import logger
# In-memory TTL cache: key → (monotonic_timestamp, value)
_cache: dict = {}
_TTL_TIP_DEVIZ = 86400 # 24 h — tip deviz changes only via DB migration
_TTL_MASINI = 300 # 5 min — vehicle inventory changes regularly
def _cache_get(key: str, ttl: float):
entry: Optional[Tuple] = _cache.get(key)
if entry and (time.monotonic() - entry[0]) < ttl:
return entry[1]
return None
def _cache_set(key: str, value) -> None:
_cache[key] = (time.monotonic(), value)
class LookupService:
@@ -26,7 +44,7 @@ class LookupService:
placeholders = ", ".join(f":id{i}" for i in range(len(company_ids)))
query = f"""
SELECT id_firma, firma, schema
SELECT id_firma, firma, schema, id_mama
FROM CONTAFIN_ORACLE.V_NOM_FIRME
WHERE id_firma IN ({placeholders})
ORDER BY id_firma
@@ -43,7 +61,7 @@ class LookupService:
raise HTTPException(status_code=503, detail="Eroare la încărcarea firmelor")
return [
FirmaItem(id_firma=r[0], firma=r[1], schema_name=r[2] or "")
FirmaItem(id_firma=r[0], firma=r[1], schema_name=r[2] or "", id_mama=r[3])
for r in rows
]
@@ -51,8 +69,13 @@ class LookupService:
async def get_tip_deviz() -> List[TipDevizItem]:
"""
Returns all active tip deviz from MARIUSM_AUTO.DEV_TIP_DEVIZ.
Cached in-process for 24 h (changes only via DB migration).
ROA_WEB has SELECT grant on this view.
"""
cached = _cache_get("tip_deviz", _TTL_TIP_DEVIZ)
if cached is not None:
return cached
query = """
SELECT id_tip, denumire, inch_validare
FROM MARIUSM_AUTO.DEV_TIP_DEVIZ
@@ -67,18 +90,25 @@ class LookupService:
logger.error("get_tip_deviz Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la încărcarea tipurilor de deviz")
return [
result = [
TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0)
for r in rows
]
_cache_set("tip_deviz", result)
return result
@staticmethod
async def get_masini() -> List[MasinaClientItem]:
"""
Returns active masini from MARIUSM_AUTO.AUTO_VMASINICLIENTI.
Cached in-process for 5 min (vehicle inventory changes regularly).
ROA_WEB has SELECT grant on this view.
Label format: "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)"
"""
cached = _cache_get("masini", _TTL_MASINI)
if cached is not None:
return cached
query = """
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener
FROM MARIUSM_AUTO.AUTO_VMASINICLIENTI
@@ -107,4 +137,5 @@ class LookupService:
label = f"{partener or '?'}{vehicul}, {nrinmat or '?'}{an_str}"
result.append(MasinaClientItem(id_masiniclient=int(id_mc), label=label))
_cache_set("masini", result)
return result