# ParcelSync / eTerra GIS — Skills & Context

> Single source of truth for AI assistants working on ParcelSync, eTerra, or ANCPI ePay.

---

## Module Overview

ParcelSync connects to Romania's **eTerra / ANCPI** cadastral system to fetch, store, enrich, and export parcel data. GIS data lives in **PostGIS** via Prisma ORM. CF extract ordering uses **ANCPI ePay** (separate auth, separate API).

**Key paths:**
| Area | Path |
|------|------|
| Module root | `src/modules/parcel-sync/` |
| Main component | `components/parcel-sync-module.tsx` (~4100 lines) |
| Services | `services/` (eterra-client, sync, enrich, epay-*, etc.) |
| API routes — eTerra | `src/app/api/eterra/` (20+ routes) |
| API routes — ePay | `src/app/api/ancpi/` (7 routes) |
| Types | `types.ts` |
| DB models | `prisma/schema.prisma` — `GisFeature`, `GisSyncRun`, `GisUat`, `CfExtract` |
| Layer catalog | `services/eterra-layers.ts` — 23 layers, 4 categories |
| Static UAT list | `public/uat.json` (~3000 entries, SIRUTA + name) |
| County mapping | `src/app/api/eterra/session/county-refresh.ts` — `WORKSPACE_TO_COUNTY` (static, verified) |

---

## Architecture

### eTerra API Client (`services/eterra-client.ts`, ~1000 lines)

- **Auth**: form-post login, JSESSIONID cookie jar (axios-cookiejar-support)
- **Session cache**: keyed by credential hash, 9-min TTL, auto-relogin on 401/redirect
- **Two API surfaces**:
  - **ArcGIS REST** (`/api/map/rest/{endpoint}/layer/{name}/query`) — spatial queries, geometry
  - **Application API** (`/api/immovable/...`, `/api/immApps/...`, `/api/adm/nomen/...`) — business data
- **Pagination**: `maxRecordCount=1000`, fallback page sizes (500, 200)
- **Key methods**: `listLayer()`, `fetchAllLayer()`, `fetchAllLayerByWhere()`, `countLayer()`, `searchImmovableByIdentifier()`, `fetchDocumentationData()`, `fetchImmovableParcelDetails()`, `fetchImmovableListByAdminUnit()`, `fetchCounties()`, `fetchAdminUnitsByCounty(nomenPk)`

### Session Management (`services/session-store.ts`)

Global singleton, stores username + password in memory, tracks active job IDs (blocks disconnect while running).

### Health Check (`services/eterra-health.ts`)

Pings `eterra.ancpi.ro` every 3min, detects maintenance by keywords in HTML, blocks login when down, UI shows amber state.

### County Resolution

**Primary method**: Static `WORKSPACE_TO_COUNTY` mapping in `county-refresh.ts` — 42 entries, maps WORKSPACE_ID to county name. Populated on first eTerra login via LIMITE_UAT layer query.

**Legacy method**: `fetchCounties()` exists in eterra-client but the nomenclature API (`/api/adm/nomen/COUNTY/list`) is unreliable (404s intermittently). The PATCH `/api/eterra/uats` route still uses it as fallback. Prefer the static mapping.

---

## Database Models

### GisUat
```
siruta         String   @id      — SIRUTA code (NOT eTerra nomenPk!)
name           String
county         String?
workspacePk    Int?              — eTerra county WORKSPACE_ID
geometry       Json?             — LIMITE_UAT boundary polygon (EPSG:3844)
areaValue      Float?
lastUpdatedDtm String?
```

**PERFORMANCE CRITICAL**: `geometry` column stores huge polygon data. ALWAYS use `select` to exclude it in list queries. The GET `/api/eterra/uats` route uses `select: { siruta: true, name: true, county: true, workspacePk: true }` — never `findMany()` without select.

**Feature counts cache**: The groupBy query for per-UAT feature counts is expensive (~25s). The UATs route caches it in a global with 5-min TTL, returning stale data while refreshing in background.

### GisFeature
```
id             String    @id @default(uuid())
layerId        String    — "TERENURI_ACTIVE", "CLADIRI_ACTIVE", etc.
siruta         String
objectId       Int       — eTerra OBJECTID (negative = no-geometry record)
cadastralRef   String?
areaValue      Float?
isActive       Boolean
attributes     Json      — ArcGIS attributes (WORKSPACE_ID, ADMIN_UNIT_ID, etc.)
geometry       Json?     — EsriGeometry { rings: number[][][] }
geometrySource String?   — "NO_GEOMETRY" for parcels without GIS geometry
enrichment     Json?     — NR_CAD, NR_CF, PROPRIETARI, INTRAVILAN, etc.
enrichedAt     DateTime?
@@unique([layerId, objectId])
```

### GisSyncRun
```
siruta, layerId, status, totalRemote, totalLocal, newFeatures, removedFeatures, etc.
```

### CfExtract (ANCPI ePay orders)
```
id              String    @id @default(uuid())
orderId         String?            — ePay orderId (shared across batch items, NOT unique)
basketRowId     Int?
nrCadastral     String
nrCF            String?
siruta          String?
judetIndex      Int                — ePay county index (0-41, alphabetical)
judetName       String
uatId           Int                — ePay UAT ID = SIRUTA code
uatName         String
prodId          Int    @default(14200)
solicitantId    String @default("14452")  — Beletage persoana juridica
status          String @default("pending")  — pending|queued|cart|searching|ordering|polling|downloading|completed|failed|cancelled
epayStatus      String?
idDocument      Int?
documentName    String?
documentDate    DateTime?
minioPath       String?            — parcele/{nrCadastral}/{idx}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
minioIndex      Int?
creditsUsed     Int    @default(1)
immovableId     String?
immovableType   String?
measuredArea    String?
legalArea       String?
address         String?
gisFeatureId    String?
version         Int    @default(1)  — increments on re-order
expiresAt       DateTime?          — 30 days after documentDate
supersededById  String?
requestedBy     String?
errorMessage    String?
pollAttempts    Int    @default(0)
```

---

## Critical Gotchas

1. **nomenPk is NOT SIRUTA**: eTerra's internal nomenclature PKs differ from SIRUTA codes. Never assume they're the same.
2. **WORKSPACE_ID = county ID everywhere**: Both eTerra ArcGIS and ePay use the same WORKSPACE_IDs for counties (CLUJ=127, ALBA=10, etc.). The static `WORKSPACE_TO_COUNTY` mapping is the authoritative source.
3. **ePay county IDs = WORKSPACE_IDs, ePay UAT IDs = SIRUTA codes**: Zero discovery calls needed for ordering — use `GisUat.workspacePk` + `siruta` directly.
4. **GisUat.geometry is huge**: Always `select` only needed fields. Forgetting this turns a 50ms query into 5+ seconds.
5. **Feature counts are cached**: 5-min TTL global cache in UATs route. Returns stale data while refreshing.
6. **Session TTL**: eTerra expires after ~10min, client has 9-min cache + auto-relogin.
7. **Maintenance windows**: eTerra goes down regularly. Health check detects and blocks.
8. **ArcGIS maxRecordCount=1000**: Always paginate. Fallback: 1000 -> 500 -> 200.
9. **Admin field auto-discovery**: Different layers use different SIRUTA field names. Use `findAdminField()` / `buildWhere()`.
10. **LIMITE_UAT uses endpoint "all"**: No workspace context needed (unlike other layers using "aut").
11. **No-geometry parcels**: `objectId = -immovablePk` to avoid collision with real ArcGIS OBJECTIDs.
12. **Spring Boot Page responses**: Some APIs return `{content: [...]}` instead of arrays. Use `unwrapArray()`.
13. **Geometry in EPSG:3844**: Reproject to EPSG:4326 for GeoPackage export.
14. **Owner tree parsing**: Documentation API returns tree (C->A->I->P). Check `nodeStatus === -1` up parent chain for cancelled owners.

---

## ANCPI ePay — CF Extract Ordering

### Overview

Orders CF extracts (Carte Funciara) from ANCPI ePay (`epay.ancpi.ro`). Separate auth from eTerra. Uses prepaid credits (1 per extract).

### Critical ID Mapping

- **ePay county IDs = eTerra WORKSPACE_IDs** (CLUJ=127, ALBA=10, etc.)
- **ePay UAT IDs = SIRUTA codes** (Cluj-Napoca=54975, Floresti=57706)
- Zero discovery needed: use `GisUat.workspacePk` as countyId, `siruta` as uatId
- ePay also uses alphabetical county indices 0-41 for some endpoints (mapped via `epay-counties.ts`)

### Auth Flow (OpenAM)

1. POST `https://oassl.ancpi.ro/openam/UI/Login` with `IDToken1={user}&IDToken2={pass}` (form-urlencoded)
2. Response sets `AMAuthCookie` (NOT `iPlanetDirectoryPro`)
3. Navigate to `http://epay.ancpi.ro:80/epay/LogIn.action` (**HTTP, not HTTPS!**) for JSESSIONID
4. Session TTL: ~1 hour

### Order Flow (per batch)

1. **AddToCartOrWishListFromPost.action** x N -> basketRowIds
2. **EpayJsonInterceptor.action** x N -> `reqType=saveProductMetadataForBasketItem` + `productMetadataJSON`
3. **EditCartSubmit.action** -> `goToCheckout=true` (ONE submit for ALL items)
4. **CheckoutConfirmationSubmit.action** -> confirms order
5. Poll **ShowOrderDetails.action?orderId=...** -> parse documents from HTML-encoded JSON
6. **DownloadFile.action?typeD=4&id=...** -> PDF download

### ePay Endpoint Gotchas

1. **EpayJsonInterceptor uses form-urlencoded** (NOT JSON): `reqType=nomenclatorUAT&countyId=127`
2. **saveProductMetadataForBasketItem uses multipart/form-data** (form-data npm package)
3. **CF/CAD values use `stringValues[0]`** (array!), not `stringValue`
4. **Document IDs are HTML-encoded** in ShowOrderDetails: `&quot;idDocument&quot;:47301767` -> must decode before parsing
5. **DownloadFile sends Content-Type: application/pdf in the REQUEST** (not just response)
6. **EditCartSubmit returns 200** (not redirect) — Angular app does client-side redirect to CheckoutConfirmation
7. **SearchEstate needs `identificator`/`judet`/`uat`** (not `identifier`/`countyId`/`uatId`), plus internal IDs
8. **MinIO metadata must be ASCII** — strip diacritics (`Floresti` not `Florești`)
9. **fetchCounties() is unreliable** — the nomenclature API 404s intermittently. Use static `WORKSPACE_TO_COUNTY` mapping instead.

### Dedup Protection

- **Queue level**: batch key = sorted cadastral numbers, 60s dedup window
- **API level**: optional `nonce` field, 60s idempotency cache
- **Test endpoint**: 30s dedup on hardcoded test parcels

### Key Files

| File | Purpose |
|------|---------|
| `services/epay-client.ts` | HTTP client (login, cart, metadata, submit, poll, download) |
| `services/epay-queue.ts` | Batch queue with dedup |
| `services/epay-storage.ts` | MinIO storage helpers |
| `services/epay-counties.ts` | County index mapping (eTerra county name -> ePay alphabetical index) |
| `services/epay-session-store.ts` | Session singleton |
| `services/epay-types.ts` | TypeScript types |
| `components/epay-connect.tsx` | Connection widget |
| `components/epay-order-button.tsx` | Per-parcel order button |
| `components/epay-tab.tsx` | Full "Extrase CF" tab |
| `api/ancpi/session/` | Connect/disconnect |
| `api/ancpi/order/` | Create batch orders |
| `api/ancpi/orders/` | List all extracts |
| `api/ancpi/credits/` | Credit balance |
| `api/ancpi/download/` | Stream PDF from MinIO |
| `api/ancpi/download-zip/` | Batch ZIP download |
| `api/ancpi/test/` | Diagnostic endpoint (temporary) |

### Credentials

- Env vars: `ANCPI_USERNAME`, `ANCPI_PASSWORD`, `ANCPI_BASE_URL`, `ANCPI_LOGIN_URL`, `ANCPI_DEFAULT_SOLICITANT_ID`
- MinIO bucket: `ancpi-documente` (env: `MINIO_BUCKET_ANCPI`)

---

## API Routes Reference

### eTerra Routes (`/api/eterra/`)

| Route | Method | Purpose |
|-------|--------|---------|
| `/api/eterra/uats` | GET | All UATs from DB (with cached feature counts, excludes geometry) |
| `/api/eterra/uats` | POST | Seed DB from uat.json |
| `/api/eterra/uats` | PATCH | Populate county from eTerra nomenclature (fallback) |
| `/api/eterra/login` | POST | Connect to eTerra (triggers county-refresh on first login) |
| `/api/eterra/session` | GET/DELETE | Session status / disconnect |
| `/api/eterra/health` | GET | eTerra platform health |
| `/api/eterra/search` | POST | Search parcels by cadastral number |
| `/api/eterra/search-owner` | POST | Search by owner name |
| `/api/eterra/layers` | GET | Available layers + field names |
| `/api/eterra/count` | POST | Count features in layer |
| `/api/eterra/sync` | POST | Start layer sync job |
| `/api/eterra/sync-background` | POST | Background sync |
| `/api/eterra/sync-status` | GET | Sync job progress |
| `/api/eterra/progress` | GET | Generic job progress |
| `/api/eterra/features` | GET | Query local features |
| `/api/eterra/db-summary` | GET | Aggregate stats all UATs |
| `/api/eterra/uat-dashboard` | GET | Per-UAT analytics |
| `/api/eterra/export-bundle` | POST | Export ZIP (CSV + GPKG) |
| `/api/eterra/export-layer-gpkg` | POST | Export single layer GPKG |
| `/api/eterra/export-local` | POST | Export from local DB |
| `/api/eterra/no-geom-scan` | POST | Scan for no-geometry parcels |
| `/api/eterra/no-geom-debug` | POST | Debug no-geom data |
| `/api/eterra/setup-postgis` | POST | Initialize PostGIS extensions |

### ANCPI ePay Routes (`/api/ancpi/`)

| Route | Method | Purpose |
|-------|--------|---------|
| `/api/ancpi/session` | POST/DELETE | Connect/disconnect ePay |
| `/api/ancpi/credits` | GET | Credit balance |
| `/api/ancpi/order` | POST | Create batch CF extract order |
| `/api/ancpi/orders` | GET | List all extracts from DB |
| `/api/ancpi/download` | GET | Stream single PDF from MinIO |
| `/api/ancpi/download-zip` | POST | Batch ZIP download |
| `/api/ancpi/test` | POST | Diagnostic test endpoint |

---

## Test UAT

**Feleacu** -- SIRUTA `57582`, ~30k immovables, ~8k GIS features.

---

## Last Updated

2026-03-23 -- ANCPI ePay CF extract ordering, performance fixes (GisUat select, feature count cache), static WORKSPACE_TO_COUNTY mapping.
