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:
71
.claude/rules/oracle-migrations.md
Normal file
71
.claude/rules/oracle-migrations.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
paths: "**/*.sql,docs/service-auto/migrations/**"
|
||||
---
|
||||
|
||||
# Oracle SQL Migration Script Rules
|
||||
|
||||
You are an Oracle SQL migration script writer. Transform raw DDL/DML into idempotent migration scripts following these rules:
|
||||
|
||||
## STRUCTURE
|
||||
- Header comment: `-- brief description` (e.g. `-- adaugare coloana nom_firme.caen_revizie`)
|
||||
- Body (idempotency rules below)
|
||||
- Footer: `exec pack_migrare.UpdateVersiune('<filename_without_.sql>'); commit;`
|
||||
|
||||
## IDEMPOTENCY RULES
|
||||
|
||||
1. **ALTER TABLE ADD COLUMN** → wrap in:
|
||||
```sql
|
||||
BEGIN
|
||||
IF PACK_MIGRARE.COLUMNEXIST('TABLE','COL')=0 THEN
|
||||
EXECUTE IMMEDIATE '...';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
```
|
||||
|
||||
2. **CREATE OR REPLACE VIEW/PROCEDURE/FUNCTION** → keep as-is (already idempotent)
|
||||
|
||||
3. **INSERT** → replace with:
|
||||
```sql
|
||||
MERGE INTO table USING DUAL ON (key condition)
|
||||
WHEN NOT MATCHED THEN INSERT (cols) VALUES (vals);
|
||||
```
|
||||
|
||||
4. **UPDATE** → keep as-is
|
||||
|
||||
5. **CREATE TABLE** → wrap in:
|
||||
```sql
|
||||
BEGIN
|
||||
IF PACK_MIGRARE.OBJECTEXIST('TABLE','TABLE')=0 THEN
|
||||
EXECUTE IMMEDIATE '...';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
```
|
||||
|
||||
6. **DROP** → wrap in:
|
||||
```sql
|
||||
BEGIN
|
||||
IF PACK_MIGRARE.OBJECTEXIST('OBJ')=1 THEN
|
||||
EXECUTE IMMEDIATE 'DROP...';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
```
|
||||
|
||||
7. **COMMENT ON** → keep as plain DDL (not inside EXECUTE IMMEDIATE)
|
||||
|
||||
8. In MERGE/INSERT: omit NULL-valued columns and CLOB columns entirely
|
||||
|
||||
## FILENAME CONVENTION
|
||||
|
||||
```
|
||||
ff_YYYY_MM_DD_NN_<MODULE>.sql
|
||||
```
|
||||
- `YYYY_MM_DD` — data migrării
|
||||
- `NN` — secvență 2 cifre (01, 02...)
|
||||
- `<MODULE>` — modulul căruia îi aparține migrarea (ex: AUTO, FACTURARE, CONTAB)
|
||||
|
||||
## LANGUAGE
|
||||
Comments: write in Romanian.
|
||||
Output: only the SQL script, no explanation.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -543,3 +543,4 @@ scripts/ralph/usage.jsonl
|
||||
|
||||
# Service-auto reference material (local only, not in repo)
|
||||
docs/service-auto/*.pdf
|
||||
docs/service-auto/HANDOFF.md
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -117,7 +117,11 @@ GRANT EXECUTE ON FIRMA_NOUA.SP_CREEAZA_COMANDA_PROTOTIP TO ROA_WEB;
|
||||
GRANT SELECT ON FIRMA_NOUA.AUTO_VMASINICLIENTI TO ROA_WEB;
|
||||
GRANT SELECT ON FIRMA_NOUA.DEV_TIP_DEVIZ TO ROA_WEB;
|
||||
GRANT SELECT ON FIRMA_NOUA.CALENDAR TO ROA_WEB;
|
||||
-- CALENDAR folosit de shared/routes/calendar.py (period selector AppHeader)
|
||||
-- CALENDAR: period selector AppHeader (shared/routes/calendar.py)
|
||||
GRANT SELECT ON FIRMA_NOUA.DEV_ORDL TO ROA_WEB;
|
||||
-- DEV_ORDL: GET /api/service-auto/comenzi (list comenzi)
|
||||
GRANT SELECT ON FIRMA_NOUA.NOM_LUCRARI TO ROA_WEB;
|
||||
-- NOM_LUCRARI: JOIN cu DEV_ORDL pentru nrord (get_comenzi)
|
||||
-- adaugă orice alte SP/view-uri noi apărute de la ultimul onboarding
|
||||
```
|
||||
|
||||
@@ -197,6 +201,7 @@ ROLLBACK;
|
||||
| SP nou în toate schemele | `migration_YYYYMMDD_sp_noua_grants.sql` (loop V_NOM_FIRME) | 1 script per migrare |
|
||||
| View/tabelă nouă expusă | același pattern ca SP | 1 script per migrare |
|
||||
| Expunere `CALENDAR` pentru period selector | `GRANT SELECT {SCHEMA}.CALENDAR TO ROA_WEB` per schemă | 1 linie per schemă (parte din onboarding §4.1) |
|
||||
| Expunere `DEV_ORDL` + `NOM_LUCRARI` pentru GET /comenzi | `GRANT SELECT {SCHEMA}.DEV_ORDL/NOM_LUCRARI TO ROA_WEB` per schemă | 2 linii per schemă (parte din onboarding §4.1) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
63
docs/service-auto/migrations/ff_2026_04_12_01_AUTO.sql
Normal file
63
docs/service-auto/migrations/ff_2026_04_12_01_AUTO.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- adaugare coloana DEV_ORDL.id_sucursala + upgrade SP_CREEAZA_COMANDA_PROTOTIP
|
||||
|
||||
-- Rulat conectat ca schema tinta (ex: MARIUSM_AUTO), O SINGURA DATA per schema
|
||||
|
||||
BEGIN
|
||||
IF PACK_MIGRARE.COLUMNEXIST('DEV_ORDL','ID_SUCURSALA')=0 THEN
|
||||
EXECUTE IMMEDIATE 'ALTER TABLE DEV_ORDL ADD (id_sucursala NUMBER(10))';
|
||||
END IF;
|
||||
END;
|
||||
/
|
||||
|
||||
CREATE OR REPLACE PROCEDURE SP_CREEAZA_COMANDA_PROTOTIP(
|
||||
p_tip IN NUMBER,
|
||||
p_id_masiniclient IN NUMBER,
|
||||
p_solicitari IN VARCHAR2,
|
||||
p_id_firma IN NUMBER,
|
||||
p_id_sucursala IN NUMBER DEFAULT NULL,
|
||||
p_id_ordl OUT NUMBER,
|
||||
p_nrord OUT VARCHAR2
|
||||
) AS
|
||||
v_id_lucrare NUMBER;
|
||||
v_seq NUMBER;
|
||||
v_exists NUMBER;
|
||||
v_now DATE := SYSDATE;
|
||||
BEGIN
|
||||
v_seq := SEQ_NR_LUCRARE.NEXTVAL;
|
||||
p_nrord := 'P' || LPAD(p_id_firma, 2, '0') || '-' || v_seq;
|
||||
|
||||
SELECT COUNT(*) INTO v_exists
|
||||
FROM NOM_LUCRARI
|
||||
WHERE sters = 0 AND nrord = p_nrord;
|
||||
IF v_exists > 0 THEN
|
||||
RAISE_APPLICATION_ERROR(-20001,
|
||||
'Mai exista o comanda cu numarul ' || p_nrord);
|
||||
END IF;
|
||||
|
||||
INSERT INTO NOM_LUCRARI (nrord, id_mod)
|
||||
VALUES (p_nrord, 1200)
|
||||
RETURNING id_lucrare INTO v_id_lucrare;
|
||||
|
||||
INSERT INTO DEV_ORDL (
|
||||
an, luna,
|
||||
id_lucrare,
|
||||
datai, dataoraad,
|
||||
id_util_ad,
|
||||
id_masiniclient,
|
||||
id_tip,
|
||||
solicitari_client,
|
||||
id_sucursala
|
||||
) VALUES (
|
||||
EXTRACT(YEAR FROM v_now), EXTRACT(MONTH FROM v_now),
|
||||
v_id_lucrare,
|
||||
v_now, v_now,
|
||||
0,
|
||||
p_id_masiniclient,
|
||||
p_tip,
|
||||
p_solicitari,
|
||||
p_id_sucursala
|
||||
) RETURNING id_ordl INTO p_id_ordl;
|
||||
END SP_CREEAZA_COMANDA_PROTOTIP;
|
||||
/
|
||||
|
||||
exec pack_migrare.UpdateVersiune('ff_2026_04_12_01_AUTO'); commit;
|
||||
24
docs/service-auto/onboarding_roa_web.sql
Normal file
24
docs/service-auto/onboarding_roa_web.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- =============================================================================
|
||||
-- File purpose : Script de onboarding ROA_WEB pentru o schemă nouă
|
||||
-- When to run : Rulat ca CONTAFIN_ORACLE după impdp pentru fiecare firmă nouă
|
||||
-- Usage : Înlocuiește :SCHEMA_NAME cu schema reală (ex: MARIUSM_AUTO)
|
||||
-- Version : 2026-04-12
|
||||
-- Prerequisite : ROA_WEB user creat (onboarding_roa_web_user.sql)
|
||||
-- =============================================================================
|
||||
|
||||
GRANT EXECUTE ON :SCHEMA_NAME.SP_CREEAZA_COMANDA_PROTOTIP TO ROA_WEB;
|
||||
GRANT SELECT ON :SCHEMA_NAME.AUTO_VMASINICLIENTI TO ROA_WEB;
|
||||
GRANT SELECT ON :SCHEMA_NAME.DEV_TIP_DEVIZ TO ROA_WEB;
|
||||
GRANT SELECT ON :SCHEMA_NAME.CALENDAR TO ROA_WEB; -- period selector AppHeader
|
||||
GRANT SELECT ON :SCHEMA_NAME.DEV_ORDL TO ROA_WEB; -- GET /api/service-auto/comenzi
|
||||
GRANT SELECT ON :SCHEMA_NAME.NOM_LUCRARI TO ROA_WEB; -- JOIN cu DEV_ORDL pentru nrord
|
||||
|
||||
-- =============================================================================
|
||||
-- ROA_WEB user creation (one-time, run as SYS or CONTAFIN_ORACLE)
|
||||
-- =============================================================================
|
||||
-- Rulat O SINGURĂ DATĂ la setup inițial, NU pentru fiecare firmă nouă.
|
||||
-- Pentru fiecare firmă nouă se rulează doar secțiunea de GRANT-uri de mai sus.
|
||||
|
||||
CREATE USER ROA_WEB IDENTIFIED BY "<REPLACE_WITH_STRONG_PASSWORD_FROM_VAULT>";
|
||||
GRANT CREATE SESSION TO ROA_WEB;
|
||||
-- Fără alte privilegii sistem. Accesul la date = exclusiv prin granturi per-obiect.
|
||||
@@ -63,6 +63,24 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE AUTO Section -->
|
||||
<div class="menu-section">
|
||||
<h3 class="menu-title">Service Auto</h3>
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item" v-for="item in serviceAutoItems" :key="item.to">
|
||||
<router-link
|
||||
:to="item.to"
|
||||
class="menu-link"
|
||||
:class="{ active: isActive(item.to, item.exactMatch) }"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<i :class="['menu-icon', item.icon]"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ADMINISTRARE Section -->
|
||||
<div class="menu-section">
|
||||
<h3 class="menu-title">Administrare</h3>
|
||||
@@ -144,6 +162,12 @@ export default {
|
||||
{ to: '/reports/detailed-invoices', icon: 'pi pi-list', label: 'Facturi Detaliate', exactMatch: true }
|
||||
]);
|
||||
|
||||
// SERVICE AUTO: Comenzi, Comandă Nouă
|
||||
const serviceAutoItems = ref([
|
||||
{ to: '/service-auto/comenzi', icon: 'pi pi-list', label: 'Comenzi', exactMatch: true },
|
||||
{ to: '/service-auto/comanda-noua', icon: 'pi pi-plus', label: 'Comandă Nouă', exactMatch: true },
|
||||
]);
|
||||
|
||||
// ADMINISTRARE: Setări
|
||||
const administrareItems = ref([
|
||||
{ to: '/settings', icon: 'pi pi-cog', label: 'Setări', exactMatch: false }
|
||||
@@ -179,6 +203,7 @@ export default {
|
||||
principaleItems,
|
||||
rapoarteItems,
|
||||
analizeItems,
|
||||
serviceAutoItems,
|
||||
administrareItems,
|
||||
isActive,
|
||||
closeMenu,
|
||||
|
||||
@@ -12,5 +12,6 @@ export default {
|
||||
getFirme: () => api.get('/firme'),
|
||||
getTipDeviz: () => api.get('/tip-deviz'),
|
||||
getMasini: () => api.get('/masini'),
|
||||
getComenzi: (params) => api.get('/comenzi', { params }),
|
||||
creeazaComanda: (data) => api.post('/comenzi', data),
|
||||
}
|
||||
|
||||
15
src/modules/service-auto/stores/sharedStores.js
Normal file
15
src/modules/service-auto/stores/sharedStores.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Service Auto Module - Shared Store Instances
|
||||
*
|
||||
* Instantiates the shared stores (auth, companies) with the
|
||||
* Service Auto module's API service.
|
||||
*/
|
||||
|
||||
import { createAuthStore } from '@shared/stores/auth'
|
||||
import { createCompaniesStore } from '@shared/stores/companies'
|
||||
import api from '../services/api.js'
|
||||
|
||||
const resetAllStores = () => {}
|
||||
|
||||
export const useAuthStore = createAuthStore(api, { onLogout: resetAllStores })
|
||||
export const useCompanyStore = createCompaniesStore(api, useAuthStore)
|
||||
@@ -104,8 +104,10 @@ import Textarea from 'primevue/textarea'
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
import serviceAutoApi from '../services/api.js'
|
||||
import { useCompanyStore } from '../stores/sharedStores.js'
|
||||
|
||||
const toast = useToast()
|
||||
const companyStore = useCompanyStore()
|
||||
const clientDropdownRef = ref(null)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
@@ -131,9 +133,13 @@ async function loadLookups() {
|
||||
|
||||
if (firmeRes.status === 'fulfilled') {
|
||||
firme.value = firmeRes.value.data
|
||||
// Default: first company
|
||||
// Default: selected company from AppHeader store, fallback to first
|
||||
if (firme.value.length > 0 && form.value.id_firma === null) {
|
||||
form.value.id_firma = firme.value[0].id_firma
|
||||
const selected = companyStore.selectedCompany
|
||||
const defaultFirma = firme.value.find(f => f.id_firma === selected?.id_firma) || firme.value[0]
|
||||
if (defaultFirma) {
|
||||
form.value.id_firma = defaultFirma.id_firma
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.add({ severity: 'warn', summary: 'Firme', detail: 'Nu s-au putut încărca firmele', life: 4000 })
|
||||
@@ -169,6 +175,9 @@ const emptyForm = () => ({
|
||||
|
||||
const form = ref(emptyForm())
|
||||
|
||||
const selectedFirma = computed(() => firme.value.find(f => f.id_firma === form.value.id_firma) || null)
|
||||
const idSucursala = computed(() => selectedFirma.value?.id_mama != null ? form.value.id_firma : null)
|
||||
|
||||
const isFormValid = computed(() =>
|
||||
form.value.id_firma !== null &&
|
||||
form.value.tip_id !== null &&
|
||||
@@ -188,6 +197,7 @@ async function submitComanda() {
|
||||
id_masiniclient: form.value.id_masiniclient,
|
||||
solicitari: form.value.solicitari.trim(),
|
||||
id_firma: form.value.id_firma,
|
||||
id_sucursala: idSucursala.value,
|
||||
})
|
||||
|
||||
toast.add({
|
||||
|
||||
275
src/modules/service-auto/views/ComenziBrowseView.vue
Normal file
275
src/modules/service-auto/views/ComenziBrowseView.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<Toast />
|
||||
|
||||
<div class="card">
|
||||
<!-- Header -->
|
||||
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm);">
|
||||
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
|
||||
Comenzi Service
|
||||
</h2>
|
||||
<router-link to="/service-auto/comanda-noua">
|
||||
<Button label="Comandă Nouă" icon="pi pi-plus" size="small" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters row -->
|
||||
<div class="card-body" style="padding-bottom: 0;">
|
||||
<div class="filters-row">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Status</label>
|
||||
<Dropdown
|
||||
v-model="filters.validat"
|
||||
:options="statusOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Toate"
|
||||
class="w-full"
|
||||
@change="resetAndLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">De la</label>
|
||||
<Calendar
|
||||
v-model="filters.data_de_la"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="—"
|
||||
class="w-full"
|
||||
@date-select="resetAndLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Până la</label>
|
||||
<Calendar
|
||||
v-model="filters.data_pana_la"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="—"
|
||||
class="w-full"
|
||||
@date-select="resetAndLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group filter-actions">
|
||||
<Button
|
||||
label="Resetează"
|
||||
icon="pi pi-filter-slash"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card-body">
|
||||
<DataTable
|
||||
:value="comenzi"
|
||||
:lazy="true"
|
||||
:paginator="true"
|
||||
:rows="perPage"
|
||||
:total-records="total"
|
||||
:loading="loading"
|
||||
class="p-datatable-sm"
|
||||
striped-rows
|
||||
@page="onPage"
|
||||
>
|
||||
<template #empty>
|
||||
<div style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
|
||||
Nicio comandă găsită
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="nrord" header="Nr. Ord." style="min-width: 100px;" />
|
||||
|
||||
<Column field="datai" header="Data" style="min-width: 100px;" />
|
||||
|
||||
<Column header="Status" style="min-width: 110px;">
|
||||
<template #body="{ data }">
|
||||
<span :class="['status-badge', statusClass(data)]">
|
||||
{{ statusLabel(data) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Tip" style="min-width: 130px;">
|
||||
<template #body="{ data }">
|
||||
{{ data.tip_denumire }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Client / Vehicul" style="min-width: 240px;">
|
||||
<template #body="{ data }">
|
||||
<span style="color: var(--text-color);">{{ data.vehicul || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import Calendar from 'primevue/calendar'
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
import serviceAutoApi from '../services/api.js'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const comenzi = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const filters = ref({
|
||||
validat: null,
|
||||
data_de_la: null,
|
||||
data_pana_la: null,
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Toate', value: null },
|
||||
{ label: 'Deschisă', value: 0 },
|
||||
{ label: 'Validată', value: 1 },
|
||||
]
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtDate = (d) => d ? d.toISOString().slice(0, 10) : undefined
|
||||
|
||||
function statusLabel(row) {
|
||||
if (row.inchis_fortat) return 'Arhivată'
|
||||
return row.validat ? 'Validată' : 'Deschisă'
|
||||
}
|
||||
|
||||
function statusClass(row) {
|
||||
if (row.inchis_fortat) return 'status-archived'
|
||||
return row.validat ? 'status-validated' : 'status-open'
|
||||
}
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadComenzi() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
per_page: perPage.value,
|
||||
}
|
||||
if (filters.value.validat !== null) params.validat = filters.value.validat
|
||||
if (filters.value.data_de_la) params.data_de_la = fmtDate(filters.value.data_de_la)
|
||||
if (filters.value.data_pana_la) params.data_pana_la = fmtDate(filters.value.data_pana_la)
|
||||
|
||||
const { data } = await serviceAutoApi.getComenzi(params)
|
||||
comenzi.value = data.comenzi
|
||||
total.value = data.total
|
||||
} catch (err) {
|
||||
const status = err.response?.status
|
||||
if (status === 503) {
|
||||
toast.add({ severity: 'error', summary: 'Eroare conexiune', detail: 'Serviciul bazei de date nu este disponibil', life: 5000 })
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Eroare', detail: 'Nu s-au putut încărca comenzile', life: 4000 })
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetAndLoad() {
|
||||
page.value = 1
|
||||
loadComenzi()
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters.value = { validat: null, data_de_la: null, data_pana_la: null }
|
||||
page.value = 1
|
||||
loadComenzi()
|
||||
}
|
||||
|
||||
function onPage(event) {
|
||||
page.value = event.page + 1 // PrimeVue is 0-indexed
|
||||
perPage.value = event.rows
|
||||
loadComenzi()
|
||||
}
|
||||
|
||||
onMounted(loadComenzi)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
align-items: flex-end;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filter-group.filter-actions {
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.status-open {
|
||||
background: var(--green-50);
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.status-validated {
|
||||
background: var(--blue-50);
|
||||
color: var(--blue-600);
|
||||
}
|
||||
|
||||
.status-archived {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-open {
|
||||
background: var(--green-900);
|
||||
color: var(--green-200);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-validated {
|
||||
background: var(--blue-900);
|
||||
color: var(--blue-200);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-archived {
|
||||
background: var(--surface-100);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -140,6 +140,12 @@ const routes = [
|
||||
path: '/service-auto',
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: 'comenzi',
|
||||
name: 'Comenzi',
|
||||
component: () => import('@/modules/service-auto/views/ComenziBrowseView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Comenzi - Service Auto' }
|
||||
},
|
||||
{
|
||||
path: 'comanda-noua',
|
||||
name: 'ComandaNoua',
|
||||
|
||||
Reference in New Issue
Block a user