feat(geoportal-v2): manual fetch flag + friendlier pool-exhausted error

Two operational gaps observed after PR3 deep-enrich rollout:

1. Raw \"Eroare: no_available_account\" surfaced when the eTerra
   account pool hit its hourly quota. Replace with a plain-language
   note ("Pool-ul ANCPI e temporar epuizat — încearcă peste câteva
   minute"). Same friendly treatment for the other common
   orchestrator errors: no_immovable_match, parcel_not_found,
   eterra_fetch_failed.

2. Marius wants the auto-trigger (fires on sparse-data load) and the
   explicit "Citește din ANCPI" button to be separable on the
   orchestrator side. Casual map browsing burns through the 500/h
   quota with auto-triggers; a working session that needs 20-30
   specific parcels shouldn't be starved.

   refreshFromAncpi now takes { manual?: boolean }. The button passes
   manual: true → request body includes manualOverride: true. The
   auto-trigger useEffect calls it with no argument (manual defaults
   to false). gis-api / orchestrator can later route manualOverride
   to a separate-quota bucket or skip the per-hour check entirely.
   Until then the flag is harmless (orchestrator ignores unknown
   fields).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-19 22:44:25 +03:00
parent 02a466ccaa
commit a4f61bf3d8
@@ -409,7 +409,7 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
}; };
}, [isCladiri, feature.siruta, feature.cadastralRef, basic]); }, [isCladiri, feature.siruta, feature.cadastralRef, basic]);
const refreshFromAncpi = useCallback(async () => { const refreshFromAncpi = useCallback(async (opts: { manual?: boolean } = {}) => {
if (!feature.siruta || !feature.cadastralRef) { if (!feature.siruta || !feature.cadastralRef) {
setError("missing_siruta_or_cad"); setError("missing_siruta_or_cad");
return; return;
@@ -419,9 +419,16 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
try { try {
// PR3 deep-enrich path: gis-api orchestrates the eTerra round-trip // PR3 deep-enrich path: gis-api orchestrates the eTerra round-trip
// and persists NR_CF / ADRESA / PROPRIETARI + tech fields in gis_core // and persists NR_CF / ADRESA / PROPRIETARI + tech fields in gis_core
// (30-day cache; force=true bypasses). After this returns the // (30-day cache; force=true bypasses cache). After this returns the
// central record is canonical — we re-fetch it via parcela.get or // central record is canonical — we re-fetch it via parcela.get or
// parcela.find so the panel sees what's actually in gis_core. // parcela.find so the panel sees what's actually in gis_core.
//
// manualOverride=true is set when the user explicitly pressed the
// "Citește din ANCPI" button (vs the auto-trigger that fires on
// sparse-data load). gis-api/orchestrator can treat this as a
// separate-quota bucket so casual map browsing doesn't starve a
// user who needs to fetch 20-30 specific parcels in a working
// session. Until orchestrator supports it the flag is ignored.
const enrichResp = await fetch("/api/gis/parcel/enrich", { const enrichResp = await fetch("/api/gis/parcel/enrich", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -429,6 +436,7 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
siruta: feature.siruta, siruta: feature.siruta,
cadastralRef: feature.cadastralRef, cadastralRef: feature.cadastralRef,
force: true, force: true,
...(opts.manual ? { manualOverride: true } : {}),
}), }),
}); });
if (!enrichResp.ok) { if (!enrichResp.ok) {
@@ -658,7 +666,17 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
{error && error !== "forbidden" && ( {error && error !== "forbidden" && (
<div className="m-3 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive"> <div className="m-3 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" /> <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>Eroare: {error}</span> <span>
{error === "no_available_account"
? "Pool-ul ANCPI e temporar epuizat — încearcă din nou peste câteva minute (cota orară se resetează automat)."
: error === "no_immovable_match"
? "Parcela nu există în baza eTerra (cadref + SIRUTA nu se potrivesc)."
: error === "parcel_not_found"
? "Parcela nu există în baza centrală gis_core."
: error === "eterra_fetch_failed"
? "eTerra ANCPI nu răspunde momentan. Reîncearcă în 1-2 minute."
: `Eroare: ${error}`}
</span>
</div> </div>
)} )}
@@ -920,7 +938,7 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
<div className="flex flex-wrap gap-1 border-t bg-muted/30 p-2"> <div className="flex flex-wrap gap-1 border-t bg-muted/30 p-2">
<button <button
type="button" type="button"
onClick={refreshFromAncpi} onClick={() => refreshFromAncpi({ manual: true })}
disabled={refreshing || basic} disabled={refreshing || basic}
className={cn( className={cn(
"inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors", "inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors",