Inlocuieste cele 3 controale de asociere cod RAR cu un singur rand de chips + un singur select care adauga instant la change. Stare = un hidden chips_state JSON versionat; post_form_chips redus la 2 actiuni (add/remove). Optgroup Sugestii (fuzzy/k-NN) + optiune "Nu se declara la RAR" in select, cu tinta implicita evidentiata si placeholder care o numeste. exclus persistat prin payload_json (treapta noua de precedenta in resolve_prestatii, round-trip complet). Siguranta: itemii exclusi sunt scosi din payload la momentul trimiterii (worker split_prestatii_excluse inainte de build_rar_payload + filtru defensiv), ca sa nu ajunga NICIODATA la RAR ca codPrestatie:null. Suita: 1680 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
928 lines
34 KiB
Python
928 lines
34 KiB
Python
"""Prestatii multi-cod (lista) la editare/corectie.
|
|
|
|
Verifica:
|
|
- Handler-ele accepta LISTA de cod_prestatie (form.getlist) -> prestatii cu mai multe coduri.
|
|
- cod_op_service/denumire RAMAN pe item.
|
|
- Cod invalid -> respins cu mesaj; cod necunoscut NU ajunge la RAR (ORA-12899).
|
|
- Lista goala -> ramane needs_mapping.
|
|
- Dedup per-item: (op_service, cod) unic, NU cod unic (doua ops diferite cu acelasi cod ok).
|
|
- Recalcul idempotenta dupa editare.
|
|
- odometruInitial obligatoriu cand cod_prestatie contine R-ODO/I-ODO.
|
|
- op_service supravietuieste /repune cu cod.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fixtures #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
tmp = tempfile.mkdtemp()
|
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "prestatii.db"))
|
|
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
|
|
from app.config import get_settings
|
|
get_settings.cache_clear()
|
|
from app.web import ratelimit
|
|
ratelimit._hits.clear()
|
|
from app.main import app
|
|
with TestClient(app, follow_redirects=False) as c:
|
|
yield c
|
|
ratelimit._hits.clear()
|
|
get_settings.cache_clear()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpere #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _create_account_user(email: str, password: str = "parolasecreta10"):
|
|
from app.accounts import create_account
|
|
from app.users import create_user
|
|
from app.db import get_connection
|
|
conn = get_connection()
|
|
try:
|
|
acct_id = create_account(conn, "Service", active=True)
|
|
create_user(conn, acct_id, email, password)
|
|
return acct_id
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _login(client, email: str, password: str = "parolasecreta10") -> None:
|
|
resp = client.get("/login")
|
|
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
|
|
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
|
|
assert m, "csrf_token nu gasit in login"
|
|
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
|
assert resp.status_code == 303
|
|
|
|
|
|
def _csrf(client) -> str:
|
|
resp = client.get("/?tab=coada")
|
|
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
|
|
assert m, "csrf_token nu gasit in dashboard"
|
|
return m.group(1)
|
|
|
|
|
|
def _insert(acct: int, *, status: str, payload: dict, key: str | None = None) -> int:
|
|
from app.db import get_connection
|
|
conn = get_connection()
|
|
try:
|
|
k = key or f"k-{os.urandom(6).hex()}"
|
|
cur = conn.execute(
|
|
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(k, acct, status, json.dumps(payload)),
|
|
)
|
|
conn.commit()
|
|
return int(cur.lastrowid)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _row(sid: int):
|
|
from app.db import get_connection
|
|
conn = get_connection()
|
|
try:
|
|
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _payload_json(sid: int) -> dict:
|
|
from app.db import get_connection
|
|
conn = get_connection()
|
|
try:
|
|
r = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone()
|
|
return json.loads(r["payload_json"])
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
|
|
"""Insereaza un cod in nomenclator_rar (fara operatii_mapping)."""
|
|
from app.db import get_connection
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
|
(cod, denumire),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _payload_cu_ops(vin: str, ops: list[tuple[str, str]]) -> dict:
|
|
"""Payload cu prestatii avand cod_op_service/denumire (needs_mapping state)."""
|
|
return {
|
|
"vin": vin,
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [
|
|
{"cod_op_service": op, "denumire": den}
|
|
for op, den in ops
|
|
],
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Teste #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_mai_multe_coduri_acceptate(client):
|
|
"""LISTA de cod_prestatie -> prestatii cu N itemi, fiecare cu cod setat."""
|
|
acct = _create_account_user("multi.cod@test.com")
|
|
_login(client, "multi.cod@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
_seed_cod("IG-1", "Inlocuire garnitura")
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0MC001",
|
|
[("Op-A", "Schimb ulei motor"), ("Op-B", "Inlocuire garnitura chiulasa")],
|
|
))
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"cod_prestatie": ["OE-1", "IG-1"], # 2 coduri pentru 2 operatii
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:500]
|
|
|
|
r = _row(sid)
|
|
assert r["status"] == "queued", f"status asteptat queued, got {r['status']}"
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
assert len(prestatii) == 2, f"asteptat 2 prestatii, got {len(prestatii)}: {prestatii}"
|
|
coduri = [p.get("cod_prestatie") for p in prestatii]
|
|
assert "OE-1" in coduri, f"OE-1 lipsa din prestatii: {prestatii}"
|
|
assert "IG-1" in coduri, f"IG-1 lipsa din prestatii: {prestatii}"
|
|
|
|
|
|
def test_cod_op_service_pastrat_dupa_corecteaza(client):
|
|
"""cod_op_service si denumire RAMAN pe item dupa /corecteaza cu cod direct."""
|
|
acct = _create_account_user("op.pastrat@test.com")
|
|
_login(client, "op.pastrat@test.com")
|
|
_seed_cod("OE-1")
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0OP001",
|
|
[("Schimb ulei", "Schimb ulei motor 5W30")],
|
|
))
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
assert len(prestatii) == 1
|
|
item = prestatii[0]
|
|
assert item.get("cod_prestatie") == "OE-1", f"cod_prestatie lipsa: {item}"
|
|
assert item.get("cod_op_service") == "Schimb ulei", f"cod_op_service pierdut: {item}"
|
|
assert item.get("denumire") == "Schimb ulei motor 5W30", f"denumire pierduta: {item}"
|
|
|
|
|
|
def test_cod_invalid_respins(client):
|
|
"""Cod necunoscut in nomenclator -> respins cu mesaj, status neschimbat."""
|
|
acct = _create_account_user("cod.invalid@test.com")
|
|
_login(client, "cod.invalid@test.com")
|
|
# NU seed-uim "XX-99" -> cod necunoscut
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0CI001",
|
|
[("Op-Test", "Operatie test")],
|
|
))
|
|
old_status = _row(sid)["status"]
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf, "cod_prestatie": "XX-99"},
|
|
)
|
|
assert resp.status_code == 200
|
|
# Cod invalid -> mesaj de eroare vizibil
|
|
assert "XX-99" in resp.text or "necunoscut" in resp.text.lower(), (
|
|
f"Mesaj de eroare lipsa pentru cod invalid; text={resp.text[:500]}"
|
|
)
|
|
# Status neschimbat
|
|
assert _row(sid)["status"] == old_status, (
|
|
f"Status s-a schimbat desi codul e invalid: {_row(sid)['status']}"
|
|
)
|
|
|
|
|
|
def test_lista_goala_needs_mapping(client):
|
|
"""Nicio cod_prestatie trimis -> submission ramane needs_mapping."""
|
|
acct = _create_account_user("goala.nemap@test.com")
|
|
_login(client, "goala.nemap@test.com")
|
|
# NU seed-uim nicio mapare -> operatia ramane nemapata
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0GN001",
|
|
[("Op-Nemap", "Operatie nemapata")],
|
|
))
|
|
csrf = _csrf(client)
|
|
|
|
# Trimit form FARA cod_prestatie (lista goala)
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert _row(sid)["status"] == "needs_mapping", (
|
|
f"Status trebuia sa ramana needs_mapping, got {_row(sid)['status']}"
|
|
)
|
|
|
|
|
|
def test_idempotency_recalculat(client):
|
|
"""Dupa setarea de coduri noi, cheia de idempotenta e recalculata."""
|
|
acct = _create_account_user("ido.recalc@test.com")
|
|
_login(client, "ido.recalc@test.com")
|
|
_seed_cod("OE-1")
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0ND001",
|
|
[("Op-Ido", "Operatie ido")],
|
|
))
|
|
old_key = _row(sid)["idempotency_key"]
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert _row(sid)["status"] == "queued"
|
|
new_key = _row(sid)["idempotency_key"]
|
|
assert new_key != old_key, (
|
|
f"Cheia de idempotenta NU s-a schimbat dupa setarea codului: {new_key}"
|
|
)
|
|
|
|
|
|
def test_odometru_initial_conditionat_R_ODO(client):
|
|
"""cod_prestatie=R-ODO fara odometruInitial -> validate_prezentare
|
|
intoarce eroare -> submission ramane needs_data (NU queued)."""
|
|
acct = _create_account_user("odo.rodo@test.com")
|
|
_login(client, "odo.rodo@test.com")
|
|
_seed_cod("R-ODO", "Revizie odometru")
|
|
|
|
# Payload: needs_mapping (op fara cod), FARA odometru_initial
|
|
sid = _insert(acct, status="needs_mapping", payload={
|
|
"vin": "WVWZZZ1JZXW0RO001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
# odometru_initial ABSENT
|
|
"prestatii": [{"cod_op_service": "Revizie", "denumire": "Revizie odometru"}],
|
|
})
|
|
csrf = _csrf(client)
|
|
|
|
# Trimit R-ODO ca cod (valid in nomenclator), dar fara odometru_initial
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf, "cod_prestatie": "R-ODO"},
|
|
)
|
|
assert resp.status_code == 200
|
|
status = _row(sid)["status"]
|
|
# R-ODO fara odometruInitial -> validare esuata -> needs_data (nu queued)
|
|
assert status in ("needs_data", "needs_mapping"), (
|
|
f"Status neasteptat: {status}; trebuia needs_data/needs_mapping (R-ODO fara odo initial)"
|
|
)
|
|
assert status != "queued", (
|
|
"R-ODO fara odometruInitial NU trebuie sa treaca in queued!"
|
|
)
|
|
|
|
|
|
def test_dedup_per_item_nu_dupa_cod(client):
|
|
"""Doua operatii DIFERITE cu ACELASI cod RAR ambele supravietuiesc.
|
|
|
|
Dedup = (op_service, cod) identice, NU cod singur. Doua ops distincte pot
|
|
mapa legitim la acelasi cod RAR fara sa fie sterse de dedup.
|
|
"""
|
|
acct = _create_account_user("dedup.ops@test.com")
|
|
_login(client, "dedup.ops@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
|
|
# Doua operatii distincte, ambele vor primi OE-1
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0DD001",
|
|
[("Op-A", "Prima operatie"), ("Op-B", "A doua operatie")],
|
|
))
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"cod_prestatie": ["OE-1", "OE-1"], # acelasi cod pentru ambele ops
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
# Ambele TREBUIE sa supravietuiasca: (Op-A, OE-1) != (Op-B, OE-1)
|
|
assert len(prestatii) == 2, (
|
|
f"Dedup a sters o operatie distincta! prestatii={prestatii} "
|
|
"(doua ops cu acelasi cod trebuie pastrate)"
|
|
)
|
|
ops = [p.get("cod_op_service") for p in prestatii]
|
|
assert "Op-A" in ops and "Op-B" in ops, f"ops_service pierdute: {ops}"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# op_service supravietuieste /repune cu cod #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_op_service_supravietuieste_repune_cu_cod(client):
|
|
"""Dupa /repune cu cod_prestatie, cod_op_service/denumire RAMAN pe item.
|
|
|
|
Fara acest invariant se pierde contextul op->cod necesar pentru salvarea
|
|
maparii din chip.
|
|
"""
|
|
acct = _create_account_user("e1.repune@test.com")
|
|
_login(client, "e1.repune@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei motor")
|
|
|
|
# Starea error: payload cu op_service (operatia venita de la import/API)
|
|
sid = _insert(acct, status="error", payload={
|
|
"vin": "WVWZZZ1JZXW0E1001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [{
|
|
"cod_op_service": "Schimb ulei",
|
|
"denumire": "Schimb ulei motor 5W30",
|
|
# fara cod_prestatie initial
|
|
}],
|
|
})
|
|
csrf = _csrf(client)
|
|
|
|
# /repune cu cod direct
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/repune",
|
|
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:500]
|
|
|
|
r = _row(sid)
|
|
assert r["status"] == "queued", f"status neasteptat: {r['status']}"
|
|
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
assert len(prestatii) == 1
|
|
item = prestatii[0]
|
|
|
|
# IRON RULE E1: op_service si denumire TREBUIE sa fie prezente
|
|
assert item.get("cod_op_service") == "Schimb ulei", (
|
|
f"E1 VIOLATED: cod_op_service a fost sters de /repune! item={item}"
|
|
)
|
|
assert item.get("denumire") == "Schimb ulei motor 5W30", (
|
|
f"E1 VIOLATED: denumire a fost stearsa de /repune! item={item}"
|
|
)
|
|
# Codul trebuie setat
|
|
assert item.get("cod_prestatie") == "OE-1", (
|
|
f"cod_prestatie nu a fost setat corect: item={item}"
|
|
)
|
|
|
|
|
|
def test_repune_nu_trunchiaza_prestatii_multiple(client):
|
|
"""/repune nu pierde prestatii[1:].
|
|
|
|
Formularul /repune trimite UN SINGUR select cod_prestatie; codul se aplica
|
|
pozitional peste toate prestatiile existente, care raman toate (altfel un
|
|
rand error cu 2+ prestatii ar pierde restul -> declaratie INCOMPLETA la RAR,
|
|
FINALIZATA ireversibil).
|
|
"""
|
|
acct = _create_account_user("repune.multi@test.com")
|
|
_login(client, "repune.multi@test.com")
|
|
_seed_cod("AAA", "Prestatie A")
|
|
_seed_cod("BBB", "Prestatie B")
|
|
_seed_cod("CCC", "Prestatie C")
|
|
|
|
# Rand error cu DOUA prestatii (ambele cu cod valid).
|
|
sid = _insert(acct, status="error", payload={
|
|
"vin": "WVWZZZ1JZXW0RM001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [
|
|
{"cod_prestatie": "AAA"},
|
|
{"cod_prestatie": "BBB"},
|
|
],
|
|
})
|
|
csrf = _csrf(client)
|
|
|
|
# /repune cu UN SINGUR cod nou (schimba prima prestatie).
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/repune",
|
|
data={"csrf_token": csrf, "cod_prestatie": "CCC"},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:500]
|
|
|
|
r = _row(sid)
|
|
assert r["status"] == "queued", f"status neasteptat: {r['status']}"
|
|
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
assert len(prestatii) == 2, (
|
|
f"AMBELE prestatii trebuie pastrate de /repune, nu doar prima! got={prestatii}"
|
|
)
|
|
coduri = [p.get("cod_prestatie") for p in prestatii]
|
|
assert coduri == ["CCC", "BBB"], (
|
|
f"Codul nou se aplica POZITIONAL primei prestatii, a doua ramane intacta: {coduri}"
|
|
)
|
|
|
|
|
|
def test_corectie_eroare_validare_pastreaza_picker(client):
|
|
"""Re-render-ul de eroare validare pastreaza optiunile pickerului.
|
|
|
|
_detaliu_ctx primeste `conn`+`account_id` pe toate ramurile de re-render;
|
|
altfel `nomenclator_rar=[]` si picker-ul chips randeaza zero optiuni.
|
|
"""
|
|
acct = _create_account_user("corectie.picker@test.com")
|
|
_login(client, "corectie.picker@test.com")
|
|
_seed_cod("ZZ-9", "Operatie existenta") # codul curent al randului (valid -> fara unmapped)
|
|
_seed_cod("PK-1", "Optiune picker") # cod doar in nomenclator (detector de picker)
|
|
|
|
# needs_data editabil, prestatie cu cod direct valid (resolve OK, fara unmapped).
|
|
sid = _insert(acct, status="needs_data", payload={
|
|
"vin": "WVWZZZ1JZXW0PK001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [{"cod_prestatie": "ZZ-9"}],
|
|
})
|
|
csrf = _csrf(client)
|
|
|
|
# Corectie cu VIN invalid -> validare esueaza -> ramura de re-render 1432.
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf, "vin": "BAD"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert _row(sid)["status"] == "needs_data"
|
|
# Picker-ul trebuie sa contina optiunile din nomenclator (conn/account_id pasate).
|
|
assert "PK-1" in resp.text, (
|
|
"Picker-ul chips e GOL dupa eroare de validare — _detaliu_ctx fara conn/account_id"
|
|
)
|
|
|
|
|
|
def test_repune_select_afiseaza_denumirea(client):
|
|
"""Selectul /repune afiseaza denumirea operatiei, nu doar codul gol."""
|
|
acct = _create_account_user("repune.denumire@test.com")
|
|
_login(client, "repune.denumire@test.com")
|
|
_seed_cod("AAA", "Schimb ulei motor")
|
|
|
|
sid = _insert(acct, status="error", payload={
|
|
"vin": "WVWZZZ1JZXW0RD001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [{"cod_prestatie": "AAA"}],
|
|
})
|
|
|
|
resp = client.get(f"/_fragments/trimitere/{sid}")
|
|
assert resp.status_code == 200
|
|
html = resp.text
|
|
# Optiunea trebuie sa afiseze denumirea, nu doar codul gol.
|
|
assert "Schimb ulei motor" in html, (
|
|
"Selectul /repune nu afiseaza denumirea operatiei (typo nome_prestatie)"
|
|
)
|
|
assert "AAA — Schimb ulei motor" in html, (
|
|
f"Optiunea select nu randeaza 'cod — denumire': {html[html.find('AAA'):html.find('AAA')+60]}"
|
|
)
|
|
|
|
|
|
# ============================================================================= #
|
|
# Picker: denumiri, cod extra, salvare fara buton, aliniere by-index #
|
|
# ============================================================================= #
|
|
|
|
def test_picker_flat_arata_cod_si_denumire(client):
|
|
"""Picker plat afiseaza 'cod — denumire', nu doar codul."""
|
|
acct = _create_account_user("picker.flat.denu@test.com")
|
|
_login(client, "picker.flat.denu@test.com")
|
|
_seed_cod("FRN1", "Sistem de franare")
|
|
|
|
# Submission flat: fara cod_op_service (mod plat)
|
|
sid = _insert(acct, status="needs_mapping", payload={
|
|
"vin": "WVWZZZ1JZXW0US4001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [], # mod plat: fara operatii cu cod_op_service
|
|
})
|
|
|
|
resp = client.get(f"/_fragments/trimitere/{sid}")
|
|
assert resp.status_code == 200
|
|
# Optiunea trebuie sa arate 'FRN1 — Sistem de franare', nu doar 'FRN1'
|
|
assert "FRN1 — Sistem de franare" in resp.text, (
|
|
f"Picker plat nu arata denumirea: "
|
|
f"{resp.text[resp.text.find('FRN1'):resp.text.find('FRN1')+80] if 'FRN1' in resp.text else 'FRN1 absent'}"
|
|
)
|
|
|
|
|
|
def test_adauga_cod_extra_in_mod_operatii(client):
|
|
"""In mod operatii, actiunea unificata 'add' fara tinta adauga un cod RAR liber."""
|
|
_create_account_user("add.extra.ops@test.com")
|
|
_login(client, "add.extra.ops@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei motor")
|
|
_seed_cod("FRN1", "Sistem de franare")
|
|
csrf = _csrf(client)
|
|
|
|
# Chips stare: 1 operatie deja mapata (mod ops) → _has_ops = True
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_state": json.dumps({"v": 1, "items": [
|
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0},
|
|
]}),
|
|
"chips_action": "add",
|
|
"chips_pick": "FRN1", # codul extra de adaugat, fara tinta -> chip liber
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:300]
|
|
# FRN1 trebuie sa apara in raspuns (chip extra adaugat)
|
|
assert "FRN1" in resp.text, (
|
|
f"Codul extra FRN1 nu a fost adaugat in mod operatii: {resp.text[:300]}"
|
|
)
|
|
# OE-1 trebuie sa ramana (chip original neatins)
|
|
assert "OE-1" in resp.text, f"Chip original OE-1 disparut: {resp.text[:300]}"
|
|
|
|
|
|
def test_extra_cod_persistat_la_salvare(client):
|
|
"""Codul extra adaugat via form-chips e salvat la /corecteaza.
|
|
|
|
Simulam starea form dupa add_extra: hidden inputs pentru op mapata (OE-1)
|
|
+ hidden inputs pentru chip extra flat (FRN1, fara op_service).
|
|
"""
|
|
acct = _create_account_user("extra.persist@test.com")
|
|
_login(client, "extra.persist@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
_seed_cod("FRN1", "Sistem de franare")
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0XP001",
|
|
[("SchimbUlei", "Schimb ulei motor")],
|
|
))
|
|
csrf = _csrf(client)
|
|
|
|
# Form state dupa add_extra: op mapata (idx=0, OE-1) + chip extra flat (idx=1, FRN1)
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"cod_prestatie": ["OE-1", "FRN1"], # OE-1 pt op, FRN1 chip extra
|
|
"chip_op_service": ["SchimbUlei", ""], # idx 0 are op_service, idx 1 nu
|
|
"chip_denumire": ["Schimb ulei motor", ""],
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:300]
|
|
|
|
r = _row(sid)
|
|
assert r["status"] == "queued", f"status asteptat queued, got {r['status']}"
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
coduri = [p.get("cod_prestatie") for p in prestatii]
|
|
assert "OE-1" in coduri, f"OE-1 (op mapata) lipsa: {prestatii}"
|
|
assert "FRN1" in coduri, f"FRN1 (chip extra) lipsa: {prestatii}"
|
|
|
|
|
|
def test_extra_cod_validat_nomenclator(client):
|
|
"""'add' fara tinta respinge cod necunoscut in nomenclator (invariant ORA-12899)."""
|
|
_create_account_user("extra.valid@test.com")
|
|
_login(client, "extra.valid@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
csrf = _csrf(client)
|
|
|
|
# add fara tinta cu cod INVALID (XX-99 nu e in nomenclator)
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_state": json.dumps({"v": 1, "items": [
|
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
|
|
]}),
|
|
"chips_action": "add",
|
|
"chips_pick": "XX-99", # cod necunoscut
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
html = resp.text
|
|
# XX-99 NU trebuie sa apara in starea persistata (chips_state)
|
|
import re as _re
|
|
m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
|
|
assert m
|
|
payload = json.loads(m.group(1).replace(""", '"'))
|
|
coduri = [it["cod"] for it in payload["items"]]
|
|
assert "XX-99" not in coduri, (
|
|
f"Codul invalid XX-99 a fost adaugat ca chip! items={payload['items']}"
|
|
)
|
|
assert "chips-extra-error" in html
|
|
|
|
|
|
def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client):
|
|
"""Codul ales in picker flat se aplica la /corecteaza fara a apasa '+'."""
|
|
acct = _create_account_user("picker.save.nobutton@test.com")
|
|
_login(client, "picker.save.nobutton@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei motor")
|
|
|
|
# Submission flat fara prestatii
|
|
sid = _insert(acct, status="needs_mapping", payload={
|
|
"vin": "WVWZZZ1JZXW0PS001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [],
|
|
})
|
|
csrf = _csrf(client)
|
|
|
|
# Browser trimite chips_add_cod_flat=OE-1 (ales in picker) dar FARA hidden cod_prestatie
|
|
# (userul nu a apasat '+' sa promoveze selectia intr-un chip).
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_add_cod_flat": "OE-1", # ales in picker, ne-aprobat prin '+'
|
|
# NU exista 'cod_prestatie' in form (zero hidden chips)
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:300]
|
|
|
|
r = _row(sid)
|
|
assert r["status"] == "queued", (
|
|
f"Codul ales in picker trebuia sa se aplice la salvare fara '+': status={r['status']}"
|
|
)
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
coduri = [p.get("cod_prestatie") for p in prestatii]
|
|
assert "OE-1" in coduri, f"OE-1 (ales in picker) lipsa din prestatii: {prestatii}"
|
|
|
|
|
|
def test_salvare_fara_chip_explicit_nu_e_no_op(client):
|
|
"""O trimitere needs_mapping cu cod ales in picker nu ramane no-op.
|
|
|
|
Complementar cu test_cod_ales_in_picker_se_salveaza_fara_buton_add: verifica
|
|
explicit ca statusul se schimba (nu ramane needs_mapping).
|
|
"""
|
|
acct = _create_account_user("noop.previne@test.com")
|
|
_login(client, "noop.previne@test.com")
|
|
_seed_cod("FRN1", "Sistem de franare")
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload={
|
|
"vin": "WVWZZZ1JZXW0NP001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [],
|
|
})
|
|
old_status = _row(sid)["status"]
|
|
assert old_status == "needs_mapping"
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_add_cod_flat": "FRN1",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
new_status = _row(sid)["status"]
|
|
assert new_status != "needs_mapping", (
|
|
f"Salvarea cu cod ales in picker trebuia sa nu fie no-op: status ramas {new_status}"
|
|
)
|
|
assert new_status == "queued", f"status asteptat queued, got {new_status}"
|
|
|
|
|
|
def test_picker_by_index_op2_nu_op1(client):
|
|
"""Codul ales pentru op#2 aterizeaza pe op#2, NU pe op#1.
|
|
|
|
Verifica alinierea by-index in modul operatii: chips_target_index=1 +
|
|
chips_pick actualizeaza chips[1] (op#2), nu chips[0] (op#1).
|
|
"""
|
|
_create_account_user("byindex.op2@test.com")
|
|
_login(client, "byindex.op2@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
_seed_cod("FRN1", "Sistem de franare")
|
|
csrf = _csrf(client)
|
|
|
|
# Chips: op#1 (idx=0) deja mapata cu OE-1, op#2 (idx=1) nemapata (cod gol)
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_state": json.dumps({"v": 1, "items": [
|
|
{"cod": "OE-1", "op": "Op-A", "den": "Prima", "exclus": 0},
|
|
{"cod": "", "op": "Op-B", "den": "A doua", "exclus": 0},
|
|
]}),
|
|
"chips_action": "add",
|
|
"chips_target_index": "1", # adauga pe op#2 (idx=1)
|
|
"chips_pick": "FRN1",
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:300]
|
|
html = resp.text
|
|
|
|
import re as _re
|
|
m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
|
|
assert m
|
|
payload = json.loads(m.group(1).replace(""", '"'))
|
|
items = payload["items"]
|
|
assert items[0]["cod"] == "OE-1", f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {items}"
|
|
assert items[1]["cod"] == "FRN1", f"FRN1 nu a aterizat pe op#2 (idx=1): {items}"
|
|
|
|
|
|
def test_empty_state_picker_nomenclator_gol(client):
|
|
"""Empty-state vizibil cand nomenclatorul e gol.
|
|
|
|
Un rand needs_mapping fara nomenclator randeaza div.chips-nom-gol,
|
|
altfel nu are nicio cale de a adauga cod.
|
|
"""
|
|
acct = _create_account_user("empty.nom@test.com")
|
|
_login(client, "empty.nom@test.com")
|
|
# Golim nomenclatorul: seed_nomenclator_if_empty populeaza la initializare DB;
|
|
# testul simuleaza cazul extrem cand tabla e goala (post-update, inainte de re-seed).
|
|
from app.db import get_connection as _gconn
|
|
_c = _gconn()
|
|
_c.execute("DELETE FROM nomenclator_rar")
|
|
_c.commit()
|
|
_c.close()
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload={
|
|
"vin": "WVWZZZ1JZXW0EN001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [],
|
|
})
|
|
|
|
resp = client.get(f"/_fragments/trimitere/{sid}")
|
|
assert resp.status_code == 200
|
|
assert "chips-nom-gol" in resp.text, (
|
|
f"Empty state 'chips-nom-gol' lipsa cand nomenclatorul e gol: {resp.text[resp.text.find('chips'):resp.text.find('chips')+200] if 'chips' in resp.text else resp.text[:500]}"
|
|
)
|
|
|
|
|
|
def test_add_extra_semnal_vizibil_cod_invalid(client):
|
|
"""'add' fara tinta cu cod invalid da semnal vizibil (nu esua silentios).
|
|
|
|
div.chips-extra-error vizibil cand codul e invalid sau selectul e gol.
|
|
"""
|
|
_create_account_user("extra.err.signal@test.com")
|
|
_login(client, "extra.err.signal@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
csrf = _csrf(client)
|
|
|
|
# add fara tinta cu cod necunoscut in nomenclator
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_state": json.dumps({"v": 1, "items": [
|
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
|
|
]}),
|
|
"chips_action": "add",
|
|
"chips_pick": "XX-99", # cod inexistent
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "chips-extra-error" in resp.text, (
|
|
f"Semnalul 'chips-extra-error' lipsa pentru cod invalid: {resp.text[:300]}"
|
|
)
|
|
|
|
|
|
def test_add_per_op_cod_invalid_afiseaza_mesaj(client):
|
|
"""Actiunea 'add' (picker per operatie, cu tinta) cu cod invalid da semnal vizibil."""
|
|
_create_account_user("add.op.err@test.com")
|
|
_login(client, "add.op.err@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_state": json.dumps({"v": 1, "items": [
|
|
{"cod": "", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
|
|
]}),
|
|
"chips_action": "add",
|
|
"chips_target_index": "0",
|
|
"chips_pick": "XX-99", # cod inexistent
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "chips-extra-error" in resp.text, (
|
|
f"Semnal de eroare lipsa la add cu cod invalid: {resp.text[:300]}"
|
|
)
|
|
|
|
|
|
def test_add_flat_cod_invalid_afiseaza_mesaj(client):
|
|
"""Actiunea 'add_flat' cu cod invalid da semnal vizibil (nu no-op silentios)."""
|
|
_create_account_user("add.flat.err@test.com")
|
|
_login(client, "add.flat.err@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}),
|
|
"chips_action": "add",
|
|
"chips_pick": "XX-99",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "chips-extra-error" in resp.text, (
|
|
f"Semnal de eroare lipsa la add cu cod invalid: {resp.text[:300]}"
|
|
)
|
|
|
|
|
|
def test_add_flat_duplicat_afiseaza_mesaj(client):
|
|
"""'add' fara tinta cu un cod deja prezent NU dubleaza chip-ul si afiseaza mesaj."""
|
|
_create_account_user("add.flat.dup@test.com")
|
|
_login(client, "add.flat.dup@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_state": json.dumps({"v": 1, "items": [{"cod": "OE-1", "op": "", "den": "", "exclus": 0}]}),
|
|
"chips_action": "add",
|
|
"chips_pick": "OE-1", # deja in lista
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
import re as _re
|
|
m = _re.search(r'name="chips_state"\s+value=\'([^\']*)\'', resp.text)
|
|
assert m
|
|
payload = json.loads(m.group(1).replace(""", '"'))
|
|
assert len(payload["items"]) == 1, f"Chip OE-1 duplicat: {payload['items']}"
|
|
assert "deja in lista" in resp.text, (
|
|
f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}"
|
|
)
|
|
|
|
|
|
def test_add_extra_duplicat_afiseaza_mesaj(client):
|
|
"""'add' fara tinta cu chip liber identic existent afiseaza mesaj (nu no-op)."""
|
|
_create_account_user("add.extra.dup@test.com")
|
|
_login(client, "add.extra.dup@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
_seed_cod("FRN1", "Franare")
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_state": json.dumps({"v": 1, "items": [
|
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
|
|
{"cod": "FRN1", "op": "", "den": "", "exclus": 0},
|
|
]}),
|
|
"chips_action": "add",
|
|
"chips_pick": "FRN1", # chip liber identic deja existent
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "deja in lista" in resp.text, (
|
|
f"Mesaj de duplicat lipsa la add fara tinta: {resp.text[:300]}"
|
|
)
|
|
|
|
|
|
def test_add_reusit_marcheaza_chip_nou(client):
|
|
"""Adaugare reusita: chip-ul nou e evidentiat (chip-nou) + mesaj de confirmare."""
|
|
_create_account_user("add.ok.nou@test.com")
|
|
_login(client, "add.ok.nou@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
_seed_cod("FRN1", "Franare")
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_state": json.dumps({"v": 1, "items": [
|
|
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei", "exclus": 0},
|
|
]}),
|
|
"chips_action": "add",
|
|
"chips_pick": "FRN1",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "chip-nou" in resp.text, f"Chip nou neevidentiat: {resp.text[:300]}"
|
|
assert "adaugat" in resp.text, f"Mesaj de confirmare lipsa: {resp.text[:300]}"
|