Files
gomag-vending/api/app/routers/mappings.py
Claude Agent d15f8b085d fix(missing-skus): reconcile stale false positives against Oracle
SKUs mapped externally (via SSH script or direct SQL) never triggered
resolve_missing_sku(), leaving them stuck as unresolved=0 indefinitely.
New reconcile_unresolved_missing_skus() revalidates ALL unresolved SKUs
against Oracle at sync, rescan, and CSV import time. Fail-soft on Oracle
down. Clears the 7 prod false positives on next sync or manual rescan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:42:43 +00:00

191 lines
6.9 KiB
Python

from fastapi import APIRouter, Query, Request, UploadFile, File
from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from fastapi import HTTPException
from pydantic import BaseModel, validator
from pathlib import Path
from typing import Optional
import io
import asyncio
from ..services import mapping_service, sqlite_service, validation_service
import logging
logger = logging.getLogger(__name__)
router = APIRouter(tags=["mappings"])
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
class MappingCreate(BaseModel):
sku: str
codmat: str
cantitate_roa: float = 1
@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):
cantitate_roa: Optional[float] = None
activ: Optional[int] = None
class MappingEdit(BaseModel):
new_sku: str
new_codmat: str
cantitate_roa: float = 1
@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):
codmat: str
cantitate_roa: float = 1
class MappingBatchCreate(BaseModel):
sku: str
mappings: list[MappingLine]
auto_restore: bool = False
# HTML page
@router.get("/mappings", response_class=HTMLResponse)
async def mappings_page(request: Request):
return templates.TemplateResponse("mappings.html", {"request": request})
# API endpoints
@router.get("/api/mappings")
async def list_mappings(search: str = "", page: int = 1, per_page: int = 50,
sort_by: str = "sku", sort_dir: str = "asc",
show_deleted: bool = False):
app_settings = await sqlite_service.get_app_settings()
id_pol = int(app_settings.get("id_pol") or 0) or None
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
result = mapping_service.get_mappings(search=search, page=page, per_page=per_page,
sort_by=sort_by, sort_dir=sort_dir,
show_deleted=show_deleted,
id_pol=id_pol, id_pol_productie=id_pol_productie)
# Merge product names from web_products (R4)
skus = list({m["sku"] for m in result.get("mappings", [])})
product_names = await sqlite_service.get_web_products_batch(skus)
for m in result.get("mappings", []):
m["product_name"] = product_names.get(m["sku"], "")
# Ensure counts key is always present
if "counts" not in result:
result["counts"] = {"total": 0}
return result
@router.post("/api/mappings")
async def create_mapping(data: MappingCreate):
try:
result = mapping_service.create_mapping(data.sku, data.codmat, data.cantitate_roa)
# Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku)
return {"success": True, **result}
except HTTPException as e:
can_restore = e.headers.get("X-Can-Restore") == "true" if e.headers else False
resp: dict = {"error": e.detail}
if can_restore:
resp["can_restore"] = True
return JSONResponse(status_code=e.status_code, content=resp)
except Exception as e:
return {"success": False, "error": str(e)}
@router.put("/api/mappings/{sku}/{codmat}")
def update_mapping(sku: str, codmat: str, data: MappingUpdate):
try:
updated = mapping_service.update_mapping(sku, codmat, data.cantitate_roa, data.activ)
return {"success": updated}
except Exception as e:
return {"success": False, "error": str(e)}
@router.put("/api/mappings/{sku}/{codmat}/edit")
def edit_mapping(sku: str, codmat: str, data: MappingEdit):
try:
result = mapping_service.edit_mapping(sku, codmat, data.new_sku, data.new_codmat,
data.cantitate_roa)
return {"success": result}
except Exception as e:
return {"success": False, "error": str(e)}
@router.delete("/api/mappings/{sku}/{codmat}")
def delete_mapping(sku: str, codmat: str):
try:
deleted = mapping_service.delete_mapping(sku, codmat)
return {"success": deleted}
except Exception as e:
return {"success": False, "error": str(e)}
@router.post("/api/mappings/{sku}/{codmat}/restore")
def restore_mapping(sku: str, codmat: str):
try:
restored = mapping_service.restore_mapping(sku, codmat)
return {"success": restored}
except Exception as e:
return {"success": False, "error": str(e)}
@router.post("/api/mappings/batch")
async def create_batch_mapping(data: MappingBatchCreate):
"""Create multiple (sku, codmat) rows for complex sets (R11)."""
if not data.mappings:
return {"success": False, "error": "No mappings provided"}
try:
results = []
for m in data.mappings:
r = mapping_service.create_mapping(data.sku, m.codmat, m.cantitate_roa, auto_restore=data.auto_restore)
results.append(r)
# Mark SKU as resolved in missing_skus tracking
await sqlite_service.resolve_missing_sku(data.sku)
return {"success": True, "created": len(results)}
except Exception as e:
return {"success": False, "error": str(e)}
@router.get("/api/mappings/prices")
async def get_mapping_prices(sku: str = Query(...)):
"""Get component prices from crm_politici_pret_art for a kit SKU."""
app_settings = await sqlite_service.get_app_settings()
id_pol = int(app_settings.get("id_pol") or 0) or None
id_pol_productie = int(app_settings.get("id_pol_productie") or 0) or None
if not id_pol:
return {"error": "Politica de pret nu este configurata", "prices": []}
try:
prices = await asyncio.to_thread(
mapping_service.get_component_prices, sku, id_pol, id_pol_productie
)
return {"prices": prices}
except Exception as e:
return {"error": str(e), "prices": []}
@router.post("/api/mappings/import-csv")
async def import_csv(file: UploadFile = File(...)):
content = await file.read()
text = content.decode("utf-8-sig")
result = mapping_service.import_csv(text)
await validation_service.reconcile_unresolved_missing_skus()
return result
@router.get("/api/mappings/export-csv")
def export_csv():
csv_content = mapping_service.export_csv()
return StreamingResponse(
io.BytesIO(csv_content.encode("utf-8-sig")),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=mappings.csv"}
)
@router.get("/api/mappings/csv-template")
def csv_template():
content = mapping_service.get_csv_template()
return StreamingResponse(
io.BytesIO(content.encode("utf-8-sig")),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=mappings_template.csv"}
)