feat(mappings): strict validation + silent CSV skip for missing CODMAT

Add Pydantic validators and service-level checks that reject empty SKU/CODMAT
on create/edit (400). CSV import now silently skips rows without CODMAT and
counts them in skipped_no_codmat instead of treating them as errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-14 21:46:59 +00:00
parent 9cacc19d15
commit 452dc9b9f0
3 changed files with 42 additions and 16 deletions

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Query, Request, UploadFile, File
from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import BaseModel from pydantic import BaseModel, validator
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import io import io
@@ -21,6 +21,12 @@ class MappingCreate(BaseModel):
cantitate_roa: float = 1 cantitate_roa: float = 1
procent_pret: float = 100 procent_pret: float = 100
@validator('sku', 'codmat')
def not_empty(cls, v):
if not v or not v.strip():
raise ValueError('nu poate fi gol')
return v.strip()
class MappingUpdate(BaseModel): class MappingUpdate(BaseModel):
cantitate_roa: Optional[float] = None cantitate_roa: Optional[float] = None
procent_pret: Optional[float] = None procent_pret: Optional[float] = None
@@ -32,6 +38,12 @@ class MappingEdit(BaseModel):
cantitate_roa: float = 1 cantitate_roa: float = 1
procent_pret: float = 100 procent_pret: float = 100
@validator('new_sku', 'new_codmat')
def not_empty(cls, v):
if not v or not v.strip():
raise ValueError('nu poate fi gol')
return v.strip()
class MappingLine(BaseModel): class MappingLine(BaseModel):
codmat: str codmat: str
cantitate_roa: float = 1 cantitate_roa: float = 1

View File

@@ -147,6 +147,10 @@ def get_mappings(search: str = "", page: int = 1, per_page: int = 50,
def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100): def create_mapping(sku: str, codmat: str, cantitate_roa: float = 1, procent_pret: float = 100):
"""Create a new mapping. Returns dict or raises HTTPException on duplicate.""" """Create a new mapping. Returns dict or raises HTTPException on duplicate."""
if not sku or not sku.strip():
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
if not codmat or not codmat.strip():
raise HTTPException(status_code=400, detail="CODMAT este obligatoriu")
if database.pool is None: if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable") raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -229,6 +233,10 @@ def delete_mapping(sku: str, codmat: str):
def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str, def edit_mapping(old_sku: str, old_codmat: str, new_sku: str, new_codmat: str,
cantitate_roa: float = 1, procent_pret: float = 100): cantitate_roa: float = 1, procent_pret: float = 100):
"""Edit a mapping. If PK changed, soft-delete old and insert new.""" """Edit a mapping. If PK changed, soft-delete old and insert new."""
if not new_sku or not new_sku.strip():
raise HTTPException(status_code=400, detail="SKU este obligatoriu")
if not new_codmat or not new_codmat.strip():
raise HTTPException(status_code=400, detail="CODMAT este obligatoriu")
if database.pool is None: if database.pool is None:
raise HTTPException(status_code=503, detail="Oracle unavailable") raise HTTPException(status_code=503, detail="Oracle unavailable")
@@ -283,23 +291,27 @@ def import_csv(file_content: str):
reader = csv.DictReader(io.StringIO(file_content)) reader = csv.DictReader(io.StringIO(file_content))
created = 0 created = 0
updated = 0 skipped_no_codmat = 0
errors = [] errors = []
with database.pool.acquire() as conn: with database.pool.acquire() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
for i, row in enumerate(reader, 1): for i, row in enumerate(reader, 1):
sku = row.get("sku", "").strip()
codmat = row.get("codmat", "").strip()
if not sku:
errors.append(f"Rând {i}: SKU lipsă")
continue
if not codmat:
skipped_no_codmat += 1
continue
try: try:
sku = row.get("sku", "").strip()
codmat = row.get("codmat", "").strip()
cantitate = float(row.get("cantitate_roa", "1") or "1") cantitate = float(row.get("cantitate_roa", "1") or "1")
procent = float(row.get("procent_pret", "100") or "100") procent = float(row.get("procent_pret", "100") or "100")
if not sku or not codmat:
errors.append(f"Row {i}: missing sku or codmat")
continue
# Try update first, insert if not exists (MERGE)
cur.execute(""" cur.execute("""
MERGE INTO ARTICOLE_TERTI t MERGE INTO ARTICOLE_TERTI t
USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s USING (SELECT :sku AS sku, :codmat AS codmat FROM DUAL) s
@@ -314,16 +326,14 @@ def import_csv(file_content: str):
(sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare) (sku, codmat, cantitate_roa, procent_pret, activ, sters, data_creare, id_util_creare)
VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3) VALUES (:sku, :codmat, :cantitate_roa, :procent_pret, 1, 0, SYSDATE, -3)
""", {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent}) """, {"sku": sku, "codmat": codmat, "cantitate_roa": cantitate, "procent_pret": procent})
created += 1
# Check if it was insert or update by rowcount
created += 1 # We count total processed
except Exception as e: except Exception as e:
errors.append(f"Row {i}: {str(e)}") errors.append(f"Rând {i}: {str(e)}")
conn.commit() conn.commit()
return {"processed": created, "errors": errors} return {"processed": created, "skipped_no_codmat": skipped_no_codmat, "errors": errors}
def export_csv(): def export_csv():
"""Export all mappings as CSV string.""" """Export all mappings as CSV string."""

View File

@@ -669,9 +669,13 @@ async function importCsv() {
try { try {
const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData }); const res = await fetch('/api/mappings/import-csv', { method: 'POST', body: formData });
const data = await res.json(); const data = await res.json();
let html = `<div class="alert alert-success">Procesate: ${data.processed}</div>`; let msg = `${data.processed} mapări importate`;
if (data.skipped_no_codmat > 0) {
msg += `, ${data.skipped_no_codmat} rânduri fără CODMAT omise`;
}
let html = `<div class="alert alert-success">${msg}</div>`;
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
html += `<div class="alert alert-warning">Erori: <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`; html += `<div class="alert alert-warning">Erori (${data.errors.length}): <ul>${data.errors.map(e => `<li>${esc(e)}</li>`).join('')}</ul></div>`;
} }
document.getElementById('importResult').innerHTML = html; document.getElementById('importResult').innerHTML = html;
loadMappings(); loadMappings();