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:
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user