diff --git a/docker-compose.yml b/docker-compose.yml index 01b1acb..b2a3ad9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,9 @@ services: - AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048} # Visual CoPilot (at-vim) - VIM_URL=${VIM_URL:-} + # eTerra ANCPI (parcel-sync module) + - ETERRA_USERNAME=${ETERRA_USERNAME:-} + - ETERRA_PASSWORD=${ETERRA_PASSWORD:-} volumes: # SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime) - /mnt/manictime:/mnt/manictime diff --git a/package-lock.json b/package-lock.json index 3ef4cc1..820e4b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@prisma/client": "^6.19.2", + "axios": "^1.13.6", + "axios-cookiejar-support": "^6.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "docx": "^9.6.0", @@ -19,12 +21,14 @@ "next": "16.1.6", "next-auth": "^4.24.13", "next-themes": "^0.4.6", + "proj4": "^2.20.3", "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", "tailwind-merge": "^3.4.1", "tesseract.js": "^7.0.0", + "tough-cookie": "^6.0.0", "utif2": "^4.1.0", "uuid": "^13.0.0" }, @@ -32,9 +36,11 @@ "@tailwindcss/postcss": "^4", "@types/jszip": "^3.4.0", "@types/node": "^20", + "@types/proj4": "^2.5.6", "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", + "@types/tough-cookie": "^4.0.5", "@types/uuid": "^10.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", @@ -4024,7 +4030,6 @@ "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4035,6 +4040,13 @@ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, + "node_modules/@types/proj4": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@types/proj4/-/proj4-2.5.6.tgz", + "integrity": "sha512-zfMrPy9fx+8DchqM0kIUGeu2tTVB5ApO1KGAYcSGFS8GoqRIkyL41xq2yCx/iV3sOLzo7v4hEgViSLTiPI1L0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qrcode": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -4081,6 +4093,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4703,7 +4722,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -5031,6 +5049,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5056,6 +5080,37 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios-cookiejar-support": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-6.0.5.tgz", + "integrity": "sha512-ldPOQCJWB0ipugkTNVB8QRl/5L2UgfmVNVQtS9en1JQJ1wW588PqAmymnwmmgc12HLDzDtsJ28xE2ppj4rD4ng==", + "license": "MIT", + "dependencies": { + "http-cookie-agent": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/3846masa" + }, + "peerDependencies": { + "axios": ">=0.20.0", + "tough-cookie": ">=4.0.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5606,6 +5661,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -6032,6 +6099,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6416,7 +6492,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7343,6 +7418,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -7358,6 +7453,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -7847,6 +7979,30 @@ "node": ">=8.0.0" } }, + "node_modules/http-cookie-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.3.tgz", + "integrity": "sha512-EeZo7CGhfqPW6R006rJa4QtZZUpBygDa2HZH3DJqsTzTjyRE6foDBVQIv/pjVsxHC8z2GIdbB1Hvn9SRorP3WQ==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/3846masa" + }, + "peerDependencies": { + "tough-cookie": "^4.0.0 || ^5.0.0 || ^6.0.0", + "undici": "^7.0.0" + }, + "peerDependenciesMeta": { + "undici": { + "optional": true + } + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -9311,6 +9467,12 @@ "node": ">= 8" } }, + "node_modules/mgrs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz", + "integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==", + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -10564,6 +10726,19 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/proj4": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.3.tgz", + "integrity": "sha512-uKJXnf/RkHhExxnWHqQqy2J1bPc5Qo8XSGzrMSJTdPWUQDo1DkunIRBfAS0crQaP9bZCSKNjqYJdYWVov0hDXw==", + "license": "MIT", + "dependencies": { + "mgrs": "1.0.0", + "wkt-parser": "^1.5.3" + }, + "funding": { + "url": "https://github.com/sponsors/ahocevar" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -10614,6 +10789,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -12436,7 +12617,6 @@ "version": "7.0.23", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", - "dev": true, "license": "MIT", "dependencies": { "tldts-core": "^7.0.23" @@ -12449,7 +12629,6 @@ "version": "7.0.23", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", - "dev": true, "license": "MIT" }, "node_modules/to-regex-range": { @@ -12479,8 +12658,8 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "tldts": "^7.0.5" }, @@ -13143,6 +13322,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wkt-parser": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.3.tgz", + "integrity": "sha512-myla+RrMj+WTlnHc8Y4HEwjBcBF9dqJ3vjff/zmlrn9V3OKOM1mZVIyNjlPEmOM9Jjr/PPut0tnaTs9NyHcK8Q==", + "license": "MIT" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index bba7604..3511ec7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@prisma/client": "^6.19.2", + "axios": "^1.13.6", + "axios-cookiejar-support": "^6.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "docx": "^9.6.0", @@ -20,12 +22,14 @@ "next": "16.1.6", "next-auth": "^4.24.13", "next-themes": "^0.4.6", + "proj4": "^2.20.3", "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", "tailwind-merge": "^3.4.1", "tesseract.js": "^7.0.0", + "tough-cookie": "^6.0.0", "utif2": "^4.1.0", "uuid": "^13.0.0" }, @@ -33,9 +37,11 @@ "@tailwindcss/postcss": "^4", "@types/jszip": "^3.4.0", "@types/node": "^20", + "@types/proj4": "^2.5.6", "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", + "@types/tough-cookie": "^4.0.5", "@types/uuid": "^10.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8987d8e..29ac9ea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,10 +1,12 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions"] } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") + extensions = [postgis] } model KeyValueStore { @@ -18,3 +20,59 @@ model KeyValueStore { @@unique([namespace, key]) @@index([namespace]) } + +// ─── GIS: eTerra ParcelSync ──────────────────────────────────────── + +model GisFeature { + id String @id @default(uuid()) + layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE + siruta String + objectId Int // eTerra OBJECTID (unique per layer) + inspireId String? + cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE + areaValue Float? + isActive Boolean @default(true) + attributes Json // all raw eTerra attributes + geometry Json? // GeoJSON geometry (Polygon/MultiPolygon) + syncRunId String? + projectId String? // link to project tag + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id]) + + @@unique([layerId, objectId]) + @@index([siruta]) + @@index([cadastralRef]) + @@index([layerId, siruta]) + @@index([projectId]) +} + +model GisSyncRun { + id String @id @default(uuid()) + siruta String + uatName String? + layerId String + status String @default("pending") // pending | running | done | error + totalRemote Int @default(0) + totalLocal Int @default(0) + newFeatures Int @default(0) + removedFeatures Int @default(0) + startedAt DateTime @default(now()) + completedAt DateTime? + errorMessage String? + features GisFeature[] + + @@index([siruta]) + @@index([layerId]) + @@index([siruta, layerId]) +} + +model GisUat { + siruta String @id + name String + county String? + updatedAt DateTime @updatedAt + + @@index([name]) +} diff --git a/src/app/(modules)/parcel-sync/page.tsx b/src/app/(modules)/parcel-sync/page.tsx new file mode 100644 index 0000000..4a176b7 --- /dev/null +++ b/src/app/(modules)/parcel-sync/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { FeatureGate } from "@/core/feature-flags"; +import { useI18n } from "@/core/i18n"; +import { ParcelSyncModule } from "@/modules/parcel-sync"; + +export default function ParcelSyncPage() { + const { t } = useI18n(); + + return ( + }> +
+
+

+ {t("parcel-sync.title")} +

+

+ {t("parcel-sync.description")} +

+
+ +
+
+ ); +} + +function ModuleDisabled() { + return ( +
+

Modulul eTerra Parcele este dezactivat.

+
+ ); +} diff --git a/src/app/api/eterra/count/route.ts b/src/app/api/eterra/count/route.ts new file mode 100644 index 0000000..421bdf8 --- /dev/null +++ b/src/app/api/eterra/count/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; +import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers"; +import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Body = { + username?: string; + password?: string; + siruta?: string | number; + layerId?: string; +}; + +export async function POST(req: Request) { + try { + const body = (await req.json()) as Body; + const username = ( + body.username ?? + process.env.ETERRA_USERNAME ?? + "" + ).trim(); + const password = ( + body.password ?? + process.env.ETERRA_PASSWORD ?? + "" + ).trim(); + const siruta = String(body.siruta ?? "").trim(); + + if (!username || !password) + return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 }); + if (!/^\d+$/.test(siruta)) + return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 }); + + const layerId = body.layerId ? String(body.layerId) : undefined; + const layer = layerId ? findLayerById(layerId) : undefined; + if (layerId && !layer) + return NextResponse.json({ error: "Layer necunoscut" }, { status: 400 }); + + const client = await EterraClient.create(username, password); + + let geometry; + if (layer?.spatialFilter) { + geometry = await fetchUatGeometry(client, siruta); + } + + const count = layer + ? geometry + ? await client.countLayerByGeometry(layer, geometry) + : await client.countLayer(layer, siruta) + : await client.countParcels(siruta); + + return NextResponse.json({ count }); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/eterra/features/route.ts b/src/app/api/eterra/features/route.ts new file mode 100644 index 0000000..1d7516b --- /dev/null +++ b/src/app/api/eterra/features/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server"; +import { PrismaClient, type Prisma } from "@prisma/client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const prisma = new PrismaClient(); + +type Body = { + siruta?: string; + layerId?: string; + search?: string; + page?: number; + pageSize?: number; + projectId?: string; +}; + +/** + * List features stored in local GIS database with pagination & search. + */ +export async function POST(req: Request) { + try { + const body = (await req.json()) as Body; + const siruta = String(body.siruta ?? "").trim(); + const layerId = String(body.layerId ?? "").trim(); + const search = (body.search ?? "").trim(); + const page = Math.max(1, body.page ?? 1); + const pageSize = Math.min(200, Math.max(1, body.pageSize ?? 50)); + + if (!siruta) { + return NextResponse.json( + { error: "SIRUTA obligatoriu" }, + { status: 400 }, + ); + } + + const where: Prisma.GisFeatureWhereInput = { siruta }; + if (layerId) where.layerId = layerId; + if (body.projectId) where.projectId = body.projectId; + if (search) { + where.OR = [ + { cadastralRef: { contains: search, mode: "insensitive" } }, + { inspireId: { contains: search, mode: "insensitive" } }, + ]; + } + + const [features, total] = await Promise.all([ + prisma.gisFeature.findMany({ + where, + select: { + id: true, + layerId: true, + siruta: true, + objectId: true, + inspireId: true, + cadastralRef: true, + areaValue: true, + isActive: true, + attributes: true, + projectId: true, + createdAt: true, + updatedAt: true, + // geometry omitted for list — too large; fetch single feature by ID for geometry + }, + orderBy: { objectId: "asc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.gisFeature.count({ where }), + ]); + + return NextResponse.json({ + features, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +/** + * GET /api/eterra/features?id=... — Single feature with full geometry. + */ +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const id = url.searchParams.get("id"); + if (!id) { + return NextResponse.json({ error: "ID obligatoriu" }, { status: 400 }); + } + + const feature = await prisma.gisFeature.findUnique({ where: { id } }); + if (!feature) { + return NextResponse.json({ error: "Negăsit" }, { status: 404 }); + } + + return NextResponse.json(feature); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/eterra/layers/summary/route.ts b/src/app/api/eterra/layers/summary/route.ts new file mode 100644 index 0000000..c2c3f55 --- /dev/null +++ b/src/app/api/eterra/layers/summary/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { + EterraClient, + type EsriGeometry, +} from "@/modules/parcel-sync/services/eterra-client"; +import { LAYER_CATALOG } from "@/modules/parcel-sync/services/eterra-layers"; +import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Body = { + username?: string; + password?: string; + siruta?: string | number; + layerIds?: string[]; // subset — omit to count all +}; + +/** + * POST — Count features per layer on the remote eTerra server. + */ +export async function POST(req: Request) { + try { + const body = (await req.json()) as Body; + const username = ( + body.username ?? + process.env.ETERRA_USERNAME ?? + "" + ).trim(); + const password = ( + body.password ?? + process.env.ETERRA_PASSWORD ?? + "" + ).trim(); + const siruta = String(body.siruta ?? "").trim(); + + if (!username || !password) + return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 }); + if (!/^\d+$/.test(siruta)) + return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 }); + + const client = await EterraClient.create(username, password); + let uatGeometry: EsriGeometry | undefined; + + // Pre-fetch UAT geometry for spatial layers + try { + uatGeometry = await fetchUatGeometry(client, siruta); + } catch { + // Some layers don't need it + } + + const layers = body.layerIds + ? LAYER_CATALOG.filter((l) => body.layerIds!.includes(l.id)) + : LAYER_CATALOG; + + const results: Record = {}; + + // Count layers in parallel, max 4 concurrent + const chunks: (typeof layers)[] = []; + for (let i = 0; i < layers.length; i += 4) { + chunks.push(layers.slice(i, i + 4)); + } + + for (const chunk of chunks) { + const promises = chunk.map(async (layer) => { + try { + const count = + layer.spatialFilter && uatGeometry + ? await client.countLayerByGeometry(layer, uatGeometry) + : await client.countLayer(layer, siruta); + results[layer.id] = { count }; + } catch (err) { + results[layer.id] = { + count: 0, + error: err instanceof Error ? err.message : "eroare", + }; + } + }); + await Promise.all(promises); + } + + return NextResponse.json({ siruta, counts: results }); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/eterra/login/route.ts b/src/app/api/eterra/login/route.ts new file mode 100644 index 0000000..2c6c571 --- /dev/null +++ b/src/app/api/eterra/login/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + try { + const body = (await req.json()) as { username?: string; password?: string }; + const username = ( + body.username ?? + process.env.ETERRA_USERNAME ?? + "" + ).trim(); + const password = ( + body.password ?? + process.env.ETERRA_PASSWORD ?? + "" + ).trim(); + if (!username || !password) + return NextResponse.json( + { error: "Credențiale eTerra lipsă" }, + { status: 400 }, + ); + + await EterraClient.create(username, password); + return NextResponse.json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + const status = message.toLowerCase().includes("login") ? 401 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/eterra/progress/route.ts b/src/app/api/eterra/progress/route.ts new file mode 100644 index 0000000..facb982 --- /dev/null +++ b/src/app/api/eterra/progress/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { getProgress } from "@/modules/parcel-sync/services/progress-store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/eterra/progress?jobId=... + * Poll sync progress for a running job. + */ +export async function GET(req: Request) { + const url = new URL(req.url); + const jobId = url.searchParams.get("jobId"); + if (!jobId) { + return NextResponse.json({ error: "jobId obligatoriu" }, { status: 400 }); + } + + const progress = getProgress(jobId); + if (!progress) { + return NextResponse.json({ jobId, status: "unknown" }); + } + + return NextResponse.json(progress); +} diff --git a/src/app/api/eterra/sync-status/route.ts b/src/app/api/eterra/sync-status/route.ts new file mode 100644 index 0000000..36b95eb --- /dev/null +++ b/src/app/api/eterra/sync-status/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { getSyncStatus } from "@/modules/parcel-sync/services/sync-service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/eterra/sync-status?siruta=... + * Returns sync run history & local feature counts per layer. + */ +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const siruta = url.searchParams.get("siruta"); + if (!siruta || !/^\d+$/.test(siruta)) { + return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 }); + } + + const status = await getSyncStatus(siruta); + return NextResponse.json(status); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/eterra/sync/route.ts b/src/app/api/eterra/sync/route.ts new file mode 100644 index 0000000..ae80097 --- /dev/null +++ b/src/app/api/eterra/sync/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { syncLayer } from "@/modules/parcel-sync/services/sync-service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 300; // 5 minute timeout for long syncs + +type Body = { + username?: string; + password?: string; + siruta?: string | number; + layerId?: string; + uatName?: string; + jobId?: string; + forceFullSync?: boolean; +}; + +export async function POST(req: Request) { + try { + const body = (await req.json()) as Body; + const username = ( + body.username ?? + process.env.ETERRA_USERNAME ?? + "" + ).trim(); + const password = ( + body.password ?? + process.env.ETERRA_PASSWORD ?? + "" + ).trim(); + const siruta = String(body.siruta ?? "").trim(); + const layerId = String(body.layerId ?? "TERENURI_ACTIVE").trim(); + + if (!username || !password) + return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 }); + if (!/^\d+$/.test(siruta)) + return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 }); + + const result = await syncLayer(username, password, siruta, layerId, { + uatName: body.uatName, + jobId: body.jobId, + forceFullSync: body.forceFullSync, + }); + + return NextResponse.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/config/flags.ts b/src/config/flags.ts index faf580c..500a8bf 100644 --- a/src/config/flags.ts +++ b/src/config/flags.ts @@ -114,6 +114,14 @@ export const DEFAULT_FLAGS: FeatureFlag[] = [ category: "module", overridable: true, }, + { + key: "module.parcel-sync", + enabled: true, + label: "eTerra Parcele", + description: "Sincronizare parcele cadastrale din eTerra ANCPI", + category: "module", + overridable: true, + }, // System flags { diff --git a/src/config/modules.ts b/src/config/modules.ts index e9cd504..6401686 100644 --- a/src/config/modules.ts +++ b/src/config/modules.ts @@ -15,6 +15,7 @@ import { miniUtilitiesConfig } from "@/modules/mini-utilities/config"; import { aiChatConfig } from "@/modules/ai-chat/config"; import { hotDeskConfig } from "@/modules/hot-desk/config"; import { visualCopilotConfig } from "@/modules/visual-copilot/config"; +import { parcelSyncConfig } from "@/modules/parcel-sync/config"; /** * Toate configurările modulelor ArchiTools, ordonate după navOrder. @@ -32,9 +33,10 @@ export const MODULE_CONFIGS: ModuleConfig[] = [ hotDeskConfig, // navOrder: 33 | management tagManagerConfig, // navOrder: 40 | tools miniUtilitiesConfig, // navOrder: 41 | tools - promptGeneratorConfig, // navOrder: 50 | ai - aiChatConfig, // navOrder: 51 | ai - visualCopilotConfig, // navOrder: 52 | ai + parcelSyncConfig, // navOrder: 42 | tools + promptGeneratorConfig, // navOrder: 50 | ai + aiChatConfig, // navOrder: 51 | ai + visualCopilotConfig, // navOrder: 52 | ai ]; // Înregistrare automată a tuturor modulelor în registru diff --git a/src/core/i18n/locales/ro.ts b/src/core/i18n/locales/ro.ts index 08e7e40..b0bdc39 100644 --- a/src/core/i18n/locales/ro.ts +++ b/src/core/i18n/locales/ro.ts @@ -111,4 +111,9 @@ export const ro: Labels = { title: "Birouri Partajate", description: "Rezervare birouri în camera partajată", }, + "parcel-sync": { + title: "eTerra Parcele", + description: + "Sincronizare parcele cadastrale ANCPI cu bază de date GIS locală", + }, }; diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx new file mode 100644 index 0000000..41a0a25 --- /dev/null +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -0,0 +1,928 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { + Search, + RefreshCw, + Database, + Cloud, + Download, + CheckCircle2, + XCircle, + Loader2, + ChevronLeft, + ChevronRight, + MapPin, + History, + Layers, +} from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { Label } from "@/shared/components/ui/label"; +import { Badge } from "@/shared/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/shared/components/ui/tabs"; +import { cn } from "@/shared/lib/utils"; +import { + LAYER_CATALOG, + LAYER_CATEGORY_LABELS, + type LayerCategory, + type LayerCatalogItem, +} from "../services/eterra-layers"; +import type { SyncProgress, ParcelFeature } from "../types"; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function formatDate(iso?: string | null) { + if (!iso) return "—"; + const d = new Date(iso); + return d.toLocaleDateString("ro-RO", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatArea(val?: number | null) { + if (val == null) return "—"; + return val.toLocaleString("ro-RO", { maximumFractionDigits: 2 }) + " mp"; +} + +function StatusBadge({ status }: { status: string }) { + switch (status) { + case "done": + return ( + + + Finalizat + + ); + case "running": + return ( + + + În curs + + ); + case "error": + return ( + + + Eroare + + ); + default: + return {status}; + } +} + +/* ------------------------------------------------------------------ */ +/* Main Component */ +/* ------------------------------------------------------------------ */ + +export function ParcelSyncModule() { + /* ---- Connection ---- */ + const [connected, setConnected] = useState(false); + const [connecting, setConnecting] = useState(false); + const [connectionError, setConnectionError] = useState(""); + + /* ---- UAT selection ---- */ + const [siruta, setSiruta] = useState(""); + const [uatName, setUatName] = useState(""); + + /* ---- Remote / Local counts ---- */ + const [remoteCounts, setRemoteCounts] = useState< + Record + >({}); + const [localCounts, setLocalCounts] = useState>({}); + const [loadingCounts, setLoadingCounts] = useState(false); + + /* ---- Sync ---- */ + const [syncingLayer, setSyncingLayer] = useState(null); + const [syncProgress, setSyncProgress] = useState(null); + type SyncRun = { + id: string; + siruta: string; + uatName?: string; + layerId: string; + status: string; + totalRemote: number; + totalLocal: number; + newFeatures: number; + removedFeatures: number; + startedAt: string; + completedAt?: string; + errorMessage?: string; + }; + const [syncHistory, setSyncHistory] = useState([]); + + /* ---- Features browser ---- */ + const [features, setFeatures] = useState([]); + const [featuresTotal, setFeaturesTotal] = useState(0); + const [featuresPage, setFeaturesPage] = useState(1); + const [featuresSearch, setFeaturesSearch] = useState(""); + const [featuresLayerFilter, setFeaturesLayerFilter] = useState(""); + const [loadingFeatures, setLoadingFeatures] = useState(false); + + const progressRef = useRef | null>(null); + const PAGE_SIZE = 50; + + /* ================================================================ */ + /* Connection */ + /* ================================================================ */ + + const handleConnect = useCallback(async () => { + setConnecting(true); + setConnectionError(""); + try { + const res = await fetch("/api/eterra/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = (await res.json()) as { success?: boolean; error?: string }; + if (data.success) setConnected(true); + else setConnectionError(data.error ?? "Eroare conectare"); + } catch { + setConnectionError("Eroare rețea"); + } + setConnecting(false); + }, []); + + /* ================================================================ */ + /* Load remote counts */ + /* ================================================================ */ + + const loadRemoteCounts = useCallback(async () => { + if (!siruta || !/^\d+$/.test(siruta)) return; + setLoadingCounts(true); + try { + const res = await fetch("/api/eterra/layers/summary", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ siruta }), + }); + const data = (await res.json()) as { + counts?: Record; + }; + if (data.counts) setRemoteCounts(data.counts); + } catch { + /* ignore */ + } + setLoadingCounts(false); + }, [siruta]); + + /* ================================================================ */ + /* Load sync status (local counts + run history) */ + /* ================================================================ */ + + const loadSyncStatus = useCallback(async () => { + if (!siruta || !/^\d+$/.test(siruta)) return; + try { + const res = await fetch(`/api/eterra/sync-status?siruta=${siruta}`); + const data = (await res.json()) as { + localCounts?: Record; + runs?: SyncRun[]; + }; + if (data.localCounts) setLocalCounts(data.localCounts); + if (data.runs) setSyncHistory(data.runs.slice(0, 30)); + } catch { + /* ignore */ + } + }, [siruta]); + + /* ================================================================ */ + /* Sync a layer */ + /* ================================================================ */ + + const handleSync = useCallback( + async (layerId: string, forceFullSync = false) => { + if (!siruta || syncingLayer) return; + const jobId = `sync-${Date.now()}-${layerId}`; + setSyncingLayer(layerId); + setSyncProgress({ + jobId, + downloaded: 0, + status: "running", + phase: "Pornire...", + }); + + // Poll progress + progressRef.current = setInterval(async () => { + try { + const res = await fetch(`/api/eterra/progress?jobId=${jobId}`); + const data = (await res.json()) as SyncProgress; + if (data.status === "done" || data.status === "error") { + setSyncProgress(data); + if (progressRef.current) clearInterval(progressRef.current); + progressRef.current = null; + setTimeout(() => { + setSyncingLayer(null); + setSyncProgress(null); + void loadSyncStatus(); + }, 2500); + } else if (data.status === "running") { + setSyncProgress(data); + } + } catch { + /* ignore polling errors */ + } + }, 1200); + + // Fire sync (may take minutes — progress comes via polling) + try { + await fetch("/api/eterra/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + siruta, + layerId, + uatName, + jobId, + forceFullSync, + }), + }); + } catch { + /* error is reported via sync run DB record */ + } + + // Cleanup polling if still active + if (progressRef.current) { + clearInterval(progressRef.current); + progressRef.current = null; + } + setSyncingLayer(null); + setSyncProgress(null); + void loadSyncStatus(); + }, + [siruta, syncingLayer, uatName, loadSyncStatus], + ); + + /* ================================================================ */ + /* Load features from local DB */ + /* ================================================================ */ + + const loadFeatures = useCallback(async () => { + if (!siruta || !/^\d+$/.test(siruta)) return; + setLoadingFeatures(true); + try { + const res = await fetch("/api/eterra/features", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + siruta, + layerId: featuresLayerFilter || undefined, + search: featuresSearch || undefined, + page: featuresPage, + pageSize: PAGE_SIZE, + }), + }); + const data = (await res.json()) as { + features?: ParcelFeature[]; + total?: number; + }; + if (data.features) setFeatures(data.features); + if (data.total != null) setFeaturesTotal(data.total); + } catch { + /* ignore */ + } + setLoadingFeatures(false); + }, [siruta, featuresLayerFilter, featuresSearch, featuresPage]); + + /* ================================================================ */ + /* Effects */ + /* ================================================================ */ + + // Load sync status on siruta change + useEffect(() => { + if (siruta && /^\d+$/.test(siruta)) { + void loadSyncStatus(); + } else { + setLocalCounts({}); + setSyncHistory([]); + } + }, [siruta, loadSyncStatus]); + + // Load features on filter/page change (only when on Data tab, but keep it simple) + useEffect(() => { + if (siruta && /^\d+$/.test(siruta)) void loadFeatures(); + }, [siruta, featuresLayerFilter, featuresSearch, featuresPage, loadFeatures]); + + // Cleanup interval on unmount + useEffect(() => { + return () => { + if (progressRef.current) clearInterval(progressRef.current); + }; + }, []); + + /* ================================================================ */ + /* Derived data */ + /* ================================================================ */ + + const layersByCategory = useMemo(() => { + const grouped: Record = {}; + for (const layer of LAYER_CATALOG) { + if (!grouped[layer.category]) grouped[layer.category] = []; + grouped[layer.category]!.push(layer); + } + return grouped; + }, []); + + const totalLocal = Object.values(localCounts).reduce((s, c) => s + c, 0); + const totalRemote = Object.values(remoteCounts).reduce( + (s, c) => s + c.count, + 0, + ); + const totalPages = Math.ceil(featuresTotal / PAGE_SIZE); + const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta); + + /* ================================================================ */ + /* Render helpers */ + /* ================================================================ */ + + function renderSyncProgress() { + if (!syncProgress || !syncingLayer) return null; + const layer = LAYER_CATALOG.find((l) => l.id === syncingLayer); + const pct = + syncProgress.total && syncProgress.total > 0 + ? Math.round((syncProgress.downloaded / syncProgress.total) * 100) + : 0; + + return ( + + +
+ +
+

+ Sincronizare: {layer?.label ?? syncingLayer} +

+

+ {syncProgress.phase} + {syncProgress.total + ? ` — ${syncProgress.downloaded} / ${syncProgress.total}` + : ""} +

+
+
+
+
+
+ + + ); + } + + function renderLayerRow(layer: LayerCatalogItem) { + const remote = remoteCounts[layer.id]; + const local = localCounts[layer.id] ?? 0; + const isSyncing = syncingLayer === layer.id; + const lastRun = syncHistory.find((r) => r.layerId === layer.id); + + return ( +
+
+

{layer.label}

+
+ + + {local.toLocaleString("ro-RO")} + + {remote != null && ( + + + {remote.error ? ( + eroare + ) : ( + remote.count.toLocaleString("ro-RO") + )} + + )} + {lastRun && ( + + Ultima: {formatDate(lastRun.completedAt ?? lastRun.startedAt)} + + )} +
+
+ +
+ {local > 0 && remote && remote.count > 0 && local >= remote.count && ( + + )} + +
+
+ ); + } + + /* ================================================================ */ + /* JSX */ + /* ================================================================ */ + + return ( + + + + + Sincronizare + + + + Parcele + + + + Istoric + + + + {/* ─── Tab: Sincronizare ─────────────────────────────────── */} + + {/* Connection + UAT */} + + + + + Conexiune & UAT + + + + {/* Connection row */} +
+ + {connected && ( + + + Conectat + + )} + {connectionError && ( + + {connectionError} + + )} +
+ + {/* UAT input */} +
+
+ + { + setSiruta(e.target.value.replace(/\D/g, "")); + setFeaturesPage(1); + }} + className="font-mono" + /> +
+
+ + setUatName(e.target.value)} + /> +
+
+ + +
+
+ + {/* Quick stats */} + {sirutaValid && ( +
+ + + Local:{" "} + + {totalLocal.toLocaleString("ro-RO")} + {" "} + features + + {totalRemote > 0 && ( + + + Remote:{" "} + + {totalRemote.toLocaleString("ro-RO")} + + + )} + + Layere sincronizate:{" "} + + {Object.keys(localCounts).length} + {" "} + / {LAYER_CATALOG.length} + +
+ )} +
+
+ + {/* Sync progress banner */} + {renderSyncProgress()} + + {/* Layer grid */} + {sirutaValid && ( +
+ {(Object.keys(LAYER_CATEGORY_LABELS) as LayerCategory[]).map( + (cat) => { + const layers = layersByCategory[cat]; + if (!layers || layers.length === 0) return null; + return ( + + + + {LAYER_CATEGORY_LABELS[cat]} + + {layers.length} + + + + + {layers.map(renderLayerRow)} + + + ); + }, + )} +
+ )} +
+ + {/* ─── Tab: Parcele ──────────────────────────────────────── */} + + {!sirutaValid ? ( + + + +

+ Introduceți un cod SIRUTA în tabul Sincronizare pentru a vedea + datele locale. +

+
+
+ ) : ( + <> + {/* Filters */} + + +
+
+ +
+ + { + setFeaturesSearch(e.target.value); + setFeaturesPage(1); + }} + /> +
+
+
+ + +
+ +
+
+
+ + {/* Table */} + + +
+ + + + + + + + + + + + + {features.length === 0 && !loadingFeatures ? ( + + + + ) : ( + features.map((f) => { + const layerLabel = + LAYER_CATALOG.find((l) => l.id === f.layerId) + ?.label ?? f.layerId; + return ( + + + + + + + + + ); + }) + )} + +
+ OBJECTID + + Ref. cadastrală + + INSPIRE ID + + Suprafață + + Layer + + Actualizat +
+ {featuresSearch + ? "Nicio parcela găsită pentru căutarea curentă." + : "Nicio parcelă sincronizată. Folosiți tabul Sincronizare."} +
+ {f.objectId} + + {f.cadastralRef ?? "—"} + + {f.inspireId ?? "—"} + + {formatArea(f.areaValue)} + + + {layerLabel} + + + {formatDate(f.updatedAt)} +
+
+ + {/* Pagination */} + {featuresTotal > PAGE_SIZE && ( +
+ + {featuresTotal.toLocaleString("ro-RO")} total — pagina{" "} + {featuresPage} / {totalPages} + +
+ + +
+
+ )} +
+
+ + )} +
+ + {/* ─── Tab: Istoric ──────────────────────────────────────── */} + + {!sirutaValid ? ( + + + +

+ Introduceți un cod SIRUTA pentru a vedea istoricul + sincronizărilor. +

+
+
+ ) : syncHistory.length === 0 ? ( + + + +

Nicio sincronizare efectuată pentru SIRUTA {siruta}.

+
+
+ ) : ( + + + + Istoric sincronizări + + {syncHistory.length} + + + + +
+ + + + + + + + + + + + + {syncHistory.map((run) => { + const layerLabel = + LAYER_CATALOG.find((l) => l.id === run.layerId) + ?.label ?? run.layerId; + return ( + + + + + + + + + ); + })} + +
+ Layer + + Status + + Noi + + Șterse + + Total Remote + + Data +
+ + {layerLabel} + + + + + {run.newFeatures > 0 ? ( + + +{run.newFeatures} + + ) : ( + "0" + )} + + {run.removedFeatures > 0 ? ( + + -{run.removedFeatures} + + ) : ( + "0" + )} + + {run.totalRemote.toLocaleString("ro-RO")} + + {formatDate(run.completedAt ?? run.startedAt)} + {run.errorMessage && ( +

+ {run.errorMessage} +

+ )} +
+
+
+
+ )} +
+
+ ); +} diff --git a/src/modules/parcel-sync/config.ts b/src/modules/parcel-sync/config.ts new file mode 100644 index 0000000..61df961 --- /dev/null +++ b/src/modules/parcel-sync/config.ts @@ -0,0 +1,18 @@ +import type { ModuleConfig } from "@/core/module-registry/types"; + +export const parcelSyncConfig: ModuleConfig = { + id: "parcel-sync", + name: "eTerra Parcele", + description: + "Sincronizare parcele cadastrale din eTerra ANCPI cu bază de date GIS locală", + icon: "map-pin", + route: "/parcel-sync", + category: "tools", + featureFlag: "module.parcel-sync", + visibility: "all", + version: "0.1.0", + dependencies: [], + storageNamespace: "parcel-sync", + navOrder: 42, + tags: ["gis", "eterra", "parcele", "cadastru", "sincronizare", "shapefile"], +}; diff --git a/src/modules/parcel-sync/index.ts b/src/modules/parcel-sync/index.ts new file mode 100644 index 0000000..f73c8e8 --- /dev/null +++ b/src/modules/parcel-sync/index.ts @@ -0,0 +1,2 @@ +export { ParcelSyncModule } from "./components/parcel-sync-module"; +export { parcelSyncConfig } from "./config"; diff --git a/src/modules/parcel-sync/services/esri-geojson.ts b/src/modules/parcel-sync/services/esri-geojson.ts new file mode 100644 index 0000000..820b0f9 --- /dev/null +++ b/src/modules/parcel-sync/services/esri-geojson.ts @@ -0,0 +1,82 @@ +/** + * ESRI → GeoJSON conversion for eTerra features. + */ + +import type { EsriFeature } from "./eterra-client"; + +export type GeoJsonPolygon = { type: "Polygon"; coordinates: number[][][] }; +export type GeoJsonMultiPolygon = { + type: "MultiPolygon"; + coordinates: number[][][][]; +}; + +export type GeoJsonFeature = { + type: "Feature"; + properties: Record; + geometry: GeoJsonPolygon | GeoJsonMultiPolygon; +}; + +export type GeoJsonFeatureCollection = { + type: "FeatureCollection"; + features: GeoJsonFeature[]; +}; + +const ringArea = (ring: number[][]) => { + let area = 0; + for (let i = 0; i < ring.length - 1; i++) { + const curr = ring[i]!; + const next = ring[i + 1]!; + area += curr[0]! * next[1]! - next[0]! * curr[1]!; + } + return area / 2; +}; + +const isClockwise = (ring: number[][]) => ringArea(ring) < 0; + +const closeRing = (ring: number[][]) => { + if (ring.length === 0) return ring; + const first = ring[0]!; + const last = ring[ring.length - 1]!; + return first[0] !== last[0] || first[1] !== last[1] + ? [...ring, [first[0]!, first[1]!]] + : ring; +}; + +const ringsToPolygons = (rings: number[][][]) => { + const polygons: number[][][][] = []; + let current: number[][][] | null = null; + for (const ring of rings) { + const closed = closeRing(ring); + if (closed.length < 4) continue; + if (isClockwise(closed) || !current) { + if (current) polygons.push(current); + current = [closed]; + } else { + current.push(closed); + } + } + if (current) polygons.push(current); + return polygons; +}; + +export const esriToGeojson = ( + features: EsriFeature[], +): GeoJsonFeatureCollection => { + const geoFeatures: GeoJsonFeature[] = []; + for (const feature of features) { + const rings = feature.geometry?.rings; + if (!rings?.length) continue; + const polygons = ringsToPolygons(rings); + if (!polygons.length) continue; + const geometry: GeoJsonPolygon | GeoJsonMultiPolygon = + polygons.length === 1 + ? { type: "Polygon", coordinates: polygons[0]! } + : { type: "MultiPolygon", coordinates: polygons }; + geoFeatures.push({ + type: "Feature", + properties: feature.attributes ?? {}, + geometry, + }); + } + return { type: "FeatureCollection", features: geoFeatures }; +}; diff --git a/src/modules/parcel-sync/services/eterra-client.ts b/src/modules/parcel-sync/services/eterra-client.ts new file mode 100644 index 0000000..9e08287 --- /dev/null +++ b/src/modules/parcel-sync/services/eterra-client.ts @@ -0,0 +1,608 @@ +/** + * eTerra ANCPI client — server-side only. + * + * Authenticates via form-post, keeps a JSESSIONID cookie jar, + * and exposes typed wrappers for count / list / fetchAll / parcel-detail + * queries against the eTerra ArcGIS-REST API. + * + * Ported from the standalone ParcelSync project and adapted for + * ArchiTools module architecture. + */ + +import axios, { type AxiosError, type AxiosInstance } from "axios"; +import crypto from "crypto"; +import { wrapper } from "axios-cookiejar-support"; +import { CookieJar } from "tough-cookie"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type EsriGeometry = { rings: number[][][] }; + +export type EsriFeature = { + attributes: Record; + geometry?: EsriGeometry; +}; + +export type LayerEndpoint = "aut" | "all"; + +export type LayerConfig = { + id: string; + name: string; + endpoint: LayerEndpoint; + subLayerId?: number; + whereTemplate?: string; + spatialFilter?: boolean; +}; + +type EsriQueryResponse = { + error?: { message?: string; details?: string[] }; + count?: number; + objectIds?: number[]; + objectIdFieldName?: string; + features?: EsriFeature[]; + exceededTransferLimit?: boolean; +}; + +type EsriLayerInfo = { + fields?: { name: string }[]; + error?: { message?: string; details?: string[] }; +}; + +type ProgressCallback = (downloaded: number, total?: number) => void; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +const BASE_URL = "https://eterra.ancpi.ro/eterra"; +const LOGIN_URL = `${BASE_URL}/api/authentication`; + +const DEFAULT_TIMEOUT_MS = 40_000; +const DEFAULT_PAGE_SIZE = 2000; +const FALLBACK_PAGE_SIZE = 1000; +const MAX_RETRIES = 2; +const SESSION_TTL_MS = 9 * 60 * 1000; +const MAX_URL_LENGTH = 1500; + +/* ------------------------------------------------------------------ */ +/* Session cache (global singleton) */ +/* ------------------------------------------------------------------ */ + +type SessionEntry = { + jar: CookieJar; + client: AxiosInstance; + createdAt: number; + lastUsed: number; +}; + +const globalStore = globalThis as { + __eterraSessionStore?: Map; +}; +const sessionStore = + globalStore.__eterraSessionStore ?? new Map(); +globalStore.__eterraSessionStore = sessionStore; + +const makeCacheKey = (u: string, p: string) => + crypto.createHash("sha256").update(`${u}:${p}`).digest("hex"); + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +const isTransient = (error: unknown) => { + const err = error as AxiosError; + if (!err) return false; + if ( + err.code === "ECONNRESET" || + err.code === "ETIMEDOUT" || + err.code === "ECONNABORTED" + ) + return true; + const status = err.response?.status ?? 0; + return status >= 500; +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/* ------------------------------------------------------------------ */ +/* Client */ +/* ------------------------------------------------------------------ */ + +export class EterraClient { + private client: AxiosInstance; + private jar: CookieJar; + private timeoutMs: number; + private maxRetries: number; + private username: string; + private password: string; + private reloginAttempted = false; + private layerFieldsCache = new Map(); + + private constructor( + client: AxiosInstance, + jar: CookieJar, + timeoutMs: number, + username: string, + password: string, + maxRetries: number, + ) { + this.client = client; + this.jar = jar; + this.timeoutMs = timeoutMs; + this.username = username; + this.password = password; + this.maxRetries = maxRetries; + } + + /* ---- Factory --------------------------------------------------- */ + + static async create( + username: string, + password: string, + options?: { timeoutMs?: number; maxRetries?: number; useCache?: boolean }, + ) { + const useCache = options?.useCache !== false; + const now = Date.now(); + const cacheKey = makeCacheKey(username, password); + const cached = sessionStore.get(cacheKey); + + if (useCache && cached && now - cached.lastUsed < SESSION_TTL_MS) { + cached.lastUsed = now; + return new EterraClient( + cached.client, + cached.jar, + options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + username, + password, + options?.maxRetries ?? MAX_RETRIES, + ); + } + + const jar = new CookieJar(); + const client = wrapper( + axios.create({ + jar, + withCredentials: true, + maxContentLength: Infinity, + maxBodyLength: Infinity, + headers: { + Accept: "application/json, text/plain, */*", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + Referer: `${BASE_URL}/`, + }, + }), + ); + + const instance = new EterraClient( + client, + jar, + options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + username, + password, + options?.maxRetries ?? MAX_RETRIES, + ); + await instance.login(username, password); + sessionStore.set(cacheKey, { jar, client, createdAt: now, lastUsed: now }); + return instance; + } + + /* ---- Auth ------------------------------------------------------ */ + + private async login(u: string, p: string) { + const body = new URLSearchParams(); + body.set("j_username", u); + body.set("j_password", p); + body.set("j_uuid", "undefined"); + body.set("j_isRevoked", "undefined"); + body.set("_spring_security_remember_me", "true"); + body.set("submit", "Login"); + + try { + await this.requestWithRetry(() => + this.client.post(LOGIN_URL, body.toString(), { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + timeout: this.timeoutMs, + }), + ); + } catch (error) { + const err = error as AxiosError; + if (err?.response?.status === 401) + throw new Error("Login failed (invalid credentials)"); + throw error; + } + + const cookies = await this.jar.getCookies(LOGIN_URL); + if (!cookies.some((c) => c.key === "JSESSIONID")) + throw new Error("Login failed / session not set"); + } + + /* ---- High-level parcels --------------------------------------- */ + + async countParcels(siruta: string) { + return this.countLayer( + { + id: "TERENURI_ACTIVE", + name: "TERENURI_ACTIVE", + endpoint: "aut", + whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1", + }, + siruta, + ); + } + + async fetchAllParcels( + siruta: string, + options?: { + pageSize?: number; + total?: number; + onProgress?: ProgressCallback; + delayMs?: number; + }, + ) { + return this.fetchAllLayer( + { + id: "TERENURI_ACTIVE", + name: "TERENURI_ACTIVE", + endpoint: "aut", + whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1", + }, + siruta, + options, + ); + } + + /* ---- Generic layer methods ------------------------------------ */ + + async countLayer(layer: LayerConfig, siruta: string) { + const where = await this.buildWhere(layer, siruta); + return this.countLayerByWhere(layer, where); + } + + async countLayerByWhere(layer: LayerConfig, where: string) { + const params = new URLSearchParams(); + params.set("f", "json"); + params.set("where", where); + return this.countLayerWithParams(layer, params, false); + } + + async countLayerByGeometry(layer: LayerConfig, geometry: EsriGeometry) { + const params = new URLSearchParams(); + params.set("f", "json"); + params.set("where", "1=1"); + this.applyGeometryParams(params, geometry); + return this.countLayerWithParams(layer, params, true); + } + + async listLayer( + layer: LayerConfig, + siruta: string, + options?: { limit?: number; outFields?: string }, + ) { + const where = await this.buildWhere(layer, siruta); + return this.listLayerByWhere(layer, where, options); + } + + async listLayerByWhere( + layer: LayerConfig, + where: string, + options?: { limit?: number; outFields?: string }, + ) { + const params = new URLSearchParams(); + params.set("f", "json"); + params.set("where", where); + params.set("outFields", options?.outFields ?? "*"); + params.set("returnGeometry", "false"); + params.set("resultRecordCount", String(options?.limit ?? 200)); + params.set("resultOffset", "0"); + const data = await this.queryLayer(layer, params, false); + return data.features ?? []; + } + + async fetchAllLayer( + layer: LayerConfig, + siruta: string, + options?: { + pageSize?: number; + total?: number; + onProgress?: ProgressCallback; + delayMs?: number; + }, + ) { + const where = await this.buildWhere(layer, siruta); + return this.fetchAllLayerByWhere(layer, where, options); + } + + async fetchAllLayerByWhere( + layer: LayerConfig, + where: string, + options?: { + pageSize?: number; + total?: number; + onProgress?: ProgressCallback; + delayMs?: number; + outFields?: string; + returnGeometry?: boolean; + geometry?: EsriGeometry; + }, + ) { + let pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE; + const total = options?.total; + const onProgress = options?.onProgress; + const delayMs = options?.delayMs ?? 0; + const outFields = options?.outFields ?? "*"; + const returnGeometry = options?.returnGeometry ?? true; + let offset = 0; + const all: EsriFeature[] = []; + + while (true) { + const params = new URLSearchParams(); + params.set("f", "json"); + params.set("where", where); + if (options?.geometry) this.applyGeometryParams(params, options.geometry); + params.set("outFields", outFields); + params.set("returnGeometry", returnGeometry ? "true" : "false"); + if (returnGeometry) params.set("outSR", "3844"); + params.set("resultRecordCount", String(pageSize)); + params.set("resultOffset", String(offset)); + + let data: EsriQueryResponse; + try { + data = await this.queryLayer(layer, params, Boolean(options?.geometry)); + } catch { + if (pageSize > FALLBACK_PAGE_SIZE) { + pageSize = FALLBACK_PAGE_SIZE; + continue; + } + throw new Error(`Failed to fetch layer ${layer.name}`); + } + + const features = data.features ?? []; + if (features.length === 0) { + if (total && all.length < total && pageSize > FALLBACK_PAGE_SIZE) { + pageSize = FALLBACK_PAGE_SIZE; + continue; + } + break; + } + + all.push(...features); + offset += features.length; + if (onProgress) onProgress(all.length, total); + if (total && all.length >= total) break; + if (features.length < pageSize) { + if (total && all.length < total && pageSize > FALLBACK_PAGE_SIZE) { + pageSize = FALLBACK_PAGE_SIZE; + continue; + } + break; + } + + if (delayMs > 0) await sleep(delayMs); + } + + return all; + } + + async fetchAllLayerByGeometry( + layer: LayerConfig, + geometry: EsriGeometry, + options?: { + pageSize?: number; + total?: number; + onProgress?: ProgressCallback; + delayMs?: number; + outFields?: string; + returnGeometry?: boolean; + }, + ) { + return this.fetchAllLayerByWhere(layer, "1=1", { ...options, geometry }); + } + + async getLayerFieldNames(layer: LayerConfig) { + return this.getLayerFields(layer); + } + + /* ---- Internals ------------------------------------------------ */ + + private layerQueryUrl(layer: LayerConfig) { + const suffix = + typeof layer.subLayerId === "number" ? `/${layer.subLayerId}` : ""; + return `${BASE_URL}/api/map/rest/${layer.endpoint}/layer/${layer.name}${suffix}/query`; + } + + private applyGeometryParams(params: URLSearchParams, geometry: EsriGeometry) { + params.set("geometry", JSON.stringify(geometry)); + params.set("geometryType", "esriGeometryPolygon"); + params.set("spatialRel", "esriSpatialRelIntersects"); + params.set("inSR", "3844"); + } + + private async buildWhere(layer: LayerConfig, siruta: string) { + const fields = await this.getLayerFields(layer); + const adminField = this.findAdminField(fields); + if (!adminField) + throw new Error(`Layer ${layer.name} has no SIRUTA/ADMIN_UNIT field`); + + if (!layer.whereTemplate) return `${adminField}=${siruta}`; + + const hasIsActive = fields.some((f) => f.toUpperCase() === "IS_ACTIVE"); + if (layer.whereTemplate.includes("IS_ACTIVE") && !hasIsActive) + return `${adminField}=${siruta}`; + + return layer.whereTemplate + .replace(/\{\{adminField\}\}/g, adminField) + .replace(/\{\{siruta\}\}/g, siruta); + } + + private findAdminField(fields: string[]) { + const preferred = [ + "ADMIN_UNIT_ID", + "SIRUTA", + "UAT_ID", + "SIRUTA_UAT", + "UAT_SIRUTA", + ]; + const upper = fields.map((f) => f.toUpperCase()); + for (const key of preferred) { + const idx = upper.indexOf(key); + if (idx >= 0) return fields[idx]; + } + return null; + } + + private async getLayerFields(layer: LayerConfig) { + const cacheKey = `${layer.endpoint}:${layer.name}`; + const cached = this.layerFieldsCache.get(cacheKey); + if (cached) return cached; + + const suffix = + typeof layer.subLayerId === "number" ? `/${layer.subLayerId}` : ""; + const url = `${BASE_URL}/api/map/rest/${layer.endpoint}/layer/${layer.name}${suffix}?f=json`; + + let response; + try { + response = await this.requestWithRetry(() => + this.client.get(url, { timeout: this.timeoutMs }), + ); + } catch (error) { + const err = error as AxiosError; + if (err?.response?.status === 401 && !this.reloginAttempted) { + this.reloginAttempted = true; + await this.login(this.username, this.password); + response = await this.requestWithRetry(() => + this.client.get(url, { timeout: this.timeoutMs }), + ); + } else throw error; + } + + const data = response.data as EsriLayerInfo | string; + if (typeof data === "string") + throw new Error(`Layer info invalid for ${layer.name}`); + if (data.error) { + const details = data.error.details?.join(" ") ?? ""; + throw new Error( + `${data.error.message ?? "Layer error"}${details ? ` (${details})` : ""}`, + ); + } + + let fields = data.fields?.map((f) => f.name) ?? []; + if (!fields.length) { + const params = new URLSearchParams(); + params.set("f", "json"); + params.set("where", "1=1"); + params.set("outFields", "*"); + params.set("returnGeometry", "false"); + params.set("resultRecordCount", "1"); + params.set("resultOffset", "0"); + const sample = await this.getJson( + `${this.layerQueryUrl(layer)}?${params}`, + ); + fields = Object.keys(sample.features?.[0]?.attributes ?? {}); + } + if (!fields.length) + throw new Error(`Layer ${layer.name} returned no fields`); + + this.layerFieldsCache.set(cacheKey, fields); + return fields; + } + + private async getJson(url: string): Promise { + return this.requestJson(() => + this.client.get(url, { timeout: this.timeoutMs }), + ); + } + + private async postJson( + url: string, + body: URLSearchParams, + ): Promise { + return this.requestJson(() => + this.client.post(url, body.toString(), { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + timeout: this.timeoutMs, + }), + ); + } + + private async requestJson( + request: () => Promise<{ + data: EsriQueryResponse | string; + status: number; + }>, + ): Promise { + let response; + try { + response = await this.requestWithRetry(request); + } catch (error) { + const err = error as AxiosError; + if (err?.response?.status === 401 && !this.reloginAttempted) { + this.reloginAttempted = true; + await this.login(this.username, this.password); + response = await this.requestWithRetry(request); + } else if (err?.response?.status === 401) { + throw new Error("Session expired (401)"); + } else throw error; + } + const data = response.data as EsriQueryResponse | string; + if (typeof data === "string") + throw new Error("Session expired or invalid response from eTerra"); + if (data.error) { + const details = data.error.details?.join(" ") ?? ""; + throw new Error( + `${data.error.message ?? "eTerra query error"}${details ? ` (${details})` : ""}`, + ); + } + return data; + } + + private async queryLayer( + layer: LayerConfig, + params: URLSearchParams, + usePost: boolean, + ): Promise { + const url = this.layerQueryUrl(layer); + const qs = params.toString(); + const shouldPost = usePost || qs.length > MAX_URL_LENGTH; + if (shouldPost) return this.postJson(url, params); + try { + return await this.getJson(`${url}?${qs}`); + } catch (error) { + const err = error as AxiosError; + if (err?.response?.status === 414) return this.postJson(url, params); + throw error; + } + } + + private async countLayerWithParams( + layer: LayerConfig, + params: URLSearchParams, + usePost: boolean, + ) { + const cp = new URLSearchParams(params); + cp.set("returnCountOnly", "true"); + const data = await this.queryLayer(layer, cp, usePost); + if (typeof data.count === "number") return data.count; + const message = data.error?.message; + throw new Error( + message + ? `Count unavailable (${layer.name}): ${message}` + : `Count unavailable (${layer.name})`, + ); + } + + private async requestWithRetry(request: () => Promise) { + let attempt = 0; + while (true) { + try { + return await request(); + } catch (error) { + if (attempt >= this.maxRetries || !isTransient(error)) throw error; + attempt += 1; + await sleep(300 * attempt); + } + } + } +} diff --git a/src/modules/parcel-sync/services/eterra-layers.ts b/src/modules/parcel-sync/services/eterra-layers.ts new file mode 100644 index 0000000..ee61d75 --- /dev/null +++ b/src/modules/parcel-sync/services/eterra-layers.ts @@ -0,0 +1,286 @@ +/** + * eTerra layer catalog — all known layers grouped by category. + */ + +import type { LayerConfig } from "./eterra-client"; + +export type LayerCategory = + | "terenuri" + | "documentatii" + | "cladiri" + | "administrativ"; + +export type LayerCatalogItem = LayerConfig & { + label: string; + category: LayerCategory; +}; + +export const LAYER_CATEGORY_LABELS: Record = { + terenuri: "Terenuri", + documentatii: "Documentații", + cladiri: "Clădiri", + administrativ: "Administrativ", +}; + +export const LAYER_CATALOG: LayerCatalogItem[] = [ + // ── Terenuri ── + { + id: "TERENURI_ACTIVE", + name: "TERENURI_ACTIVE", + label: "Terenuri active", + category: "terenuri", + endpoint: "aut", + whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1", + }, + { + id: "TERENURI_NEINREGISTRATE", + name: "TERENURI_NEINREGISTRATE", + label: "Terenuri neînregistrate", + category: "terenuri", + endpoint: "aut", + }, + { + id: "TERENURI_IN_LUCRU", + name: "TERENURI_IN_LUCRU", + label: "Terenuri în lucru", + category: "terenuri", + endpoint: "aut", + }, + { + id: "TERENURI_RECEPTIONATE", + name: "TERENURI_RECEPTIONATE", + label: "Terenuri recepționate", + category: "terenuri", + endpoint: "aut", + }, + { + id: "TERENURI_RESPINSE", + name: "TERENURI_RESPINSE", + label: "Terenuri respinse", + category: "terenuri", + endpoint: "aut", + }, + { + id: "TERENURI_PE_CERERE", + name: "TERENURI_PE_CERERE", + label: "Terenuri pe cerere", + category: "terenuri", + endpoint: "aut", + }, + { + id: "CADGEN_LAND_ACTIVE", + name: "CADGEN_LAND_ACTIVE", + label: "Cadgen land active", + category: "terenuri", + endpoint: "aut", + }, + + // ── Clădiri ── + { + id: "CLADIRI_ACTIVE", + name: "CLADIRI_ACTIVE", + label: "Clădiri active", + category: "cladiri", + endpoint: "aut", + }, + { + id: "CLADIRI_RECEPTIONATE", + name: "CLADIRI_RECEPTIONATE", + label: "Clădiri recepționate", + category: "cladiri", + endpoint: "aut", + }, + { + id: "CLADIRI_RESPINSE", + name: "CLADIRI_RESPINSE", + label: "Clădiri respinse", + category: "cladiri", + endpoint: "aut", + }, + { + id: "CLADIRI_PE_CERERE", + name: "CLADIRI_PE_CERERE", + label: "Clădiri pe cerere", + category: "cladiri", + endpoint: "aut", + }, + { + id: "CLADIRI_IN_LUCRU", + name: "CLADIRI_IN_LUCRU", + label: "Clădiri în lucru", + category: "cladiri", + endpoint: "aut", + }, + { + id: "CLADIRI_NEINREGISTRATE", + name: "CLADIRI_NEINREGISTRATE", + label: "Clădiri neînregistrate", + category: "cladiri", + endpoint: "aut", + }, + { + id: "CADGEN_BUILDING_ACTIVE", + name: "CADGEN_BUILDING_ACTIVE", + label: "Cadgen building active", + category: "cladiri", + endpoint: "aut", + }, + + // ── Documentații ── + { + id: "EXPERTIZA_CLADIRI_ACTIVE_DYNAMIC", + name: "EXPERTIZA_CLADIRI_ACTIVE_DYNAMIC", + label: "Expertiză clădiri active", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "EXPERTIZA_CLADIRI_INACTIVE_DYNAMIC", + name: "EXPERTIZA_CLADIRI_INACTIVE_DYNAMIC", + label: "Expertiză clădiri inactive", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "EXPERTIZA_TERENURI_ACTIVE_DYNAMIC", + name: "EXPERTIZA_TERENURI_ACTIVE_DYNAMIC", + label: "Expertiză terenuri active", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "EXPERTIZA_TERENURI_INACTIVE_DYNAMIC", + name: "EXPERTIZA_TERENURI_INACTIVE_DYNAMIC", + label: "Expertiză terenuri inactive", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "EXPERTIZA_JUDICIARA_ADMISE_DYNAMIC", + name: "EXPERTIZA_JUDICIARA_ADMISE_DYNAMIC", + label: "Expertiză judiciară admise", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "EXPERTIZA_JUDICIARA_RESPINSE_DYNAMIC", + name: "EXPERTIZA_JUDICIARA_RESPINSE_DYNAMIC", + label: "Expertiză judiciară respinse", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "EXPERTIZA_JUDICIARA_NEINREGISTRATE_DYNAMIC", + name: "EXPERTIZA_JUDICIARA_NEINREGISTRATE_DYNAMIC", + label: "Expertiză judiciară neînreg.", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "ZONE_INTERES_ACTIVE_DYNAMIC", + name: "ZONE_INTERES_ACTIVE_DYNAMIC", + label: "Zone interes active", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "ZONE_INTERES_INACTIVE_DYNAMIC", + name: "ZONE_INTERES_INACTIVE_DYNAMIC", + label: "Zone interes inactive", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "ZONE_INTERES_PROPUSE_DYNAMIC", + name: "ZONE_INTERES_PROPUSE_DYNAMIC", + label: "Zone interes propuse", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "RECEPTII_TEHNICE_ADMISE_DYNAMIC", + name: "RECEPTII_TEHNICE_ADMISE_DYNAMIC", + label: "Recepții tehnice admise", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "RECEPTII_TEHNICE_RESPINSE_DYNAMIC", + name: "RECEPTII_TEHNICE_RESPINSE_DYNAMIC", + label: "Recepții tehnice respinse", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "RECEPTII_TEHNICE_NEINREGISTRATE_DYNAMIC", + name: "RECEPTII_TEHNICE_NEINREGISTRATE_DYNAMIC", + label: "Recepții tehnice neînreg.", + category: "documentatii", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + + // ── Administrativ ── + { + id: "LIMITE_UAT", + name: "LIMITE_UAT", + label: "Limite UAT", + category: "administrativ", + endpoint: "all", + }, + { + id: "LIMITE_INTRAV_DYNAMIC", + name: "LIMITE_INTRAV_DYNAMIC", + label: "Limite intravilan", + category: "administrativ", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "SPECIAL_AREAS_ACTIVE_DYNAMIC", + name: "SPECIAL_AREAS_ACTIVE_DYNAMIC", + label: "Arii speciale active", + category: "administrativ", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, + { + id: "SPECIAL_AREAS_INACTIVE_DYNAMIC", + name: "SPECIAL_AREAS_INACTIVE_DYNAMIC", + label: "Arii speciale inactive", + category: "administrativ", + endpoint: "aut", + spatialFilter: true, + subLayerId: 0, + }, +]; + +export const findLayerById = (id?: string) => + LAYER_CATALOG.find((l) => l.id === id); diff --git a/src/modules/parcel-sync/services/progress-store.ts b/src/modules/parcel-sync/services/progress-store.ts new file mode 100644 index 0000000..b3c9660 --- /dev/null +++ b/src/modules/parcel-sync/services/progress-store.ts @@ -0,0 +1,25 @@ +/** + * In-memory progress store for long-running sync/export jobs. + */ + +export type SyncProgress = { + jobId: string; + downloaded: number; + total?: number; + status: "running" | "done" | "error"; + phase?: string; + message?: string; + note?: string; + phaseCurrent?: number; + phaseTotal?: number; +}; + +type ProgressStore = Map; + +const g = globalThis as { __parcelSyncProgressStore?: ProgressStore }; +const store: ProgressStore = g.__parcelSyncProgressStore ?? new Map(); +g.__parcelSyncProgressStore = store; + +export const setProgress = (p: SyncProgress) => store.set(p.jobId, p); +export const getProgress = (jobId: string) => store.get(jobId); +export const clearProgress = (jobId: string) => store.delete(jobId); diff --git a/src/modules/parcel-sync/services/sync-service.ts b/src/modules/parcel-sync/services/sync-service.ts new file mode 100644 index 0000000..63618c4 --- /dev/null +++ b/src/modules/parcel-sync/services/sync-service.ts @@ -0,0 +1,344 @@ +/** + * Sync engine — downloads eTerra features and stores them in PostgreSQL. + * + * Supports incremental sync: compares remote OBJECTIDs with local DB, + * only downloads new features, marks removed ones. + */ + +import { Prisma, PrismaClient } from "@prisma/client"; +import { EterraClient } from "./eterra-client"; +import type { LayerConfig } from "./eterra-client"; +import { esriToGeojson } from "./esri-geojson"; +import { findLayerById, type LayerCatalogItem } from "./eterra-layers"; +import { fetchUatGeometry } from "./uat-geometry"; +import { + setProgress, + clearProgress, + type SyncProgress, +} from "./progress-store"; + +const prisma = new PrismaClient(); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export type SyncResult = { + layerId: string; + siruta: string; + totalRemote: number; + totalLocal: number; + newFeatures: number; + removedFeatures: number; + status: "done" | "error"; + error?: string; +}; + +/** + * Sync a single layer for a UAT into the local GIS database. + * + * 1. Count remote features + * 2. Get local OBJECTIDs already stored + * 3. Download only new OBJECTIDs (incremental) + * 4. Mark removed ones (present local, absent remote) + * 5. Store results + sync run metadata + */ +export async function syncLayer( + username: string, + password: string, + siruta: string, + layerId: string, + options?: { + uatName?: string; + jobId?: string; + forceFullSync?: boolean; + }, +): Promise { + const jobId = options?.jobId; + const layer = findLayerById(layerId); + if (!layer) throw new Error(`Layer ${layerId} not found`); + + const push = (partial: Partial) => { + if (!jobId) return; + setProgress({ + jobId, + downloaded: 0, + status: "running", + ...partial, + } as SyncProgress); + }; + + // Create sync run record + const syncRun = await prisma.gisSyncRun.create({ + data: { + siruta, + uatName: options?.uatName, + layerId, + status: "running", + }, + }); + + try { + push({ phase: "Conectare eTerra", downloaded: 0 }); + const client = await EterraClient.create(username, password); + + // Get UAT geometry for spatial-filtered layers + let uatGeometry; + if (layer.spatialFilter) { + push({ phase: "Obținere geometrie UAT" }); + uatGeometry = await fetchUatGeometry(client, siruta); + } + + // Count remote features + push({ phase: "Numărare remote" }); + let remoteCount: number; + try { + remoteCount = uatGeometry + ? await client.countLayerByGeometry(layer, uatGeometry) + : await client.countLayer(layer, siruta); + } catch { + remoteCount = 0; + } + + push({ phase: "Verificare locală", total: remoteCount }); + + // Get local OBJECTIDs for this layer+siruta + const localFeatures = await prisma.gisFeature.findMany({ + where: { layerId, siruta }, + select: { objectId: true }, + }); + const localObjIds = new Set(localFeatures.map((f) => f.objectId)); + + // Fetch all remote features + push({ phase: "Descărcare features", downloaded: 0, total: remoteCount }); + + const allRemote = uatGeometry + ? await client.fetchAllLayerByGeometry(layer, uatGeometry, { + total: remoteCount > 0 ? remoteCount : undefined, + onProgress: (dl, tot) => + push({ phase: "Descărcare features", downloaded: dl, total: tot }), + delayMs: 200, + }) + : await client.fetchAllLayerByWhere( + layer, + await buildWhere(client, layer, siruta), + { + total: remoteCount > 0 ? remoteCount : undefined, + onProgress: (dl, tot) => + push({ + phase: "Descărcare features", + downloaded: dl, + total: tot, + }), + delayMs: 200, + }, + ); + + // Convert to GeoJSON for geometry storage + const geojson = esriToGeojson(allRemote); + const geojsonByObjId = new Map(); + for (const f of geojson.features) { + const objId = f.properties.OBJECTID as number | undefined; + if (objId != null) geojsonByObjId.set(objId, f); + } + + // Determine which OBJECTIDs are new + const remoteObjIds = new Set(); + for (const f of allRemote) { + const objId = f.attributes.OBJECTID as number | undefined; + if (objId != null) remoteObjIds.add(objId); + } + + const newObjIds = options?.forceFullSync + ? remoteObjIds + : new Set([...remoteObjIds].filter((id) => !localObjIds.has(id))); + const removedObjIds = [...localObjIds].filter( + (id) => !remoteObjIds.has(id), + ); + + push({ + phase: "Salvare în baza de date", + downloaded: 0, + total: newObjIds.size, + }); + + // Insert new features in batches + let saved = 0; + const BATCH_SIZE = 100; + const newArray = [...newObjIds]; + for (let i = 0; i < newArray.length; i += BATCH_SIZE) { + const batch = newArray.slice(i, i + BATCH_SIZE); + const creates = batch + .map((objId) => { + const feature = allRemote.find( + (f) => (f.attributes.OBJECTID as number) === objId, + ); + if (!feature) return null; + const geoFeature = geojsonByObjId.get(objId); + const geom = geoFeature?.geometry; + return { + layerId, + siruta, + objectId: objId, + inspireId: + (feature.attributes.INSPIRE_ID as string | undefined) ?? null, + cadastralRef: + (feature.attributes.NATIONAL_CADASTRAL_REFERENCE as + | string + | undefined) ?? null, + areaValue: + typeof feature.attributes.AREA_VALUE === "number" + ? feature.attributes.AREA_VALUE + : null, + isActive: feature.attributes.IS_ACTIVE !== 0, + attributes: feature.attributes as Prisma.InputJsonValue, + geometry: geom ? (geom as Prisma.InputJsonValue) : Prisma.JsonNull, + syncRunId: syncRun.id, + }; + }) + .filter(Boolean); + + // Use upsert to handle potential conflicts (force sync) + for (const item of creates) { + if (!item) continue; + await prisma.gisFeature.upsert({ + where: { + layerId_objectId: { + layerId: item.layerId, + objectId: item.objectId, + }, + }, + create: item, + update: { + ...item, + updatedAt: new Date(), + }, + }); + } + saved += creates.length; + push({ + phase: "Salvare în baza de date", + downloaded: saved, + total: newObjIds.size, + }); + } + + // Mark removed features + if (removedObjIds.length > 0) { + push({ phase: "Marcare șterse" }); + await prisma.gisFeature.deleteMany({ + where: { + layerId, + siruta, + objectId: { in: removedObjIds }, + }, + }); + } + + // Update sync run + const localCount = await prisma.gisFeature.count({ + where: { layerId, siruta }, + }); + await prisma.gisSyncRun.update({ + where: { id: syncRun.id }, + data: { + status: "done", + totalRemote: remoteCount, + totalLocal: localCount, + newFeatures: newObjIds.size, + removedFeatures: removedObjIds.length, + completedAt: new Date(), + }, + }); + + push({ + phase: "Finalizat", + status: "done", + downloaded: remoteCount, + total: remoteCount, + }); + if (jobId) setTimeout(() => clearProgress(jobId), 60_000); + + return { + layerId, + siruta, + totalRemote: remoteCount, + totalLocal: localCount, + newFeatures: newObjIds.size, + removedFeatures: removedObjIds.length, + status: "done", + }; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + await prisma.gisSyncRun.update({ + where: { id: syncRun.id }, + data: { status: "error", errorMessage: msg, completedAt: new Date() }, + }); + push({ phase: "Eroare", status: "error", message: msg }); + if (jobId) setTimeout(() => clearProgress(jobId), 60_000); + return { + layerId, + siruta, + totalRemote: 0, + totalLocal: 0, + newFeatures: 0, + removedFeatures: 0, + status: "error", + error: msg, + }; + } +} + +/** Helper to build where clause outside the client */ +async function buildWhere( + client: EterraClient, + layer: LayerConfig, + siruta: string, +) { + const fields = await client.getLayerFieldNames(layer); + const preferred = [ + "ADMIN_UNIT_ID", + "SIRUTA", + "UAT_ID", + "SIRUTA_UAT", + "UAT_SIRUTA", + ]; + const upper = fields.map((f) => f.toUpperCase()); + let adminField: string | null = null; + for (const key of preferred) { + const idx = upper.indexOf(key); + if (idx >= 0) { + adminField = fields[idx] ?? null; + break; + } + } + if (!adminField) return "1=1"; + if (!layer.whereTemplate) return `${adminField}=${siruta}`; + const hasIsActive = fields.some((f) => f.toUpperCase() === "IS_ACTIVE"); + if (layer.whereTemplate.includes("IS_ACTIVE") && !hasIsActive) + return `${adminField}=${siruta}`; + return layer.whereTemplate + .replace(/\{\{adminField\}\}/g, adminField) + .replace(/\{\{siruta\}\}/g, siruta); +} + +/** + * Get sync status for all layers for a given UAT. + */ +export async function getSyncStatus(siruta: string) { + const runs = await prisma.gisSyncRun.findMany({ + where: { siruta }, + orderBy: { startedAt: "desc" }, + }); + + const counts = await prisma.gisFeature.groupBy({ + by: ["layerId"], + where: { siruta }, + _count: { id: true }, + }); + + const countMap: Record = {}; + for (const c of counts) { + countMap[c.layerId] = c._count.id; + } + + return { runs, localCounts: countMap }; +} diff --git a/src/modules/parcel-sync/services/uat-geometry.ts b/src/modules/parcel-sync/services/uat-geometry.ts new file mode 100644 index 0000000..3186f07 --- /dev/null +++ b/src/modules/parcel-sync/services/uat-geometry.ts @@ -0,0 +1,47 @@ +/** + * UAT geometry fetcher — retrieves the boundary polygon for a SIRUTA + * from the LIMITE_UAT layer. Used as spatial filter for dynamic layers. + */ + +import type { EsriGeometry } from "./eterra-client"; +import { EterraClient } from "./eterra-client"; +import { findLayerById } from "./eterra-layers"; + +const findAdminField = (fields: string[]) => { + const preferred = [ + "ADMIN_UNIT_ID", + "SIRUTA", + "UAT_ID", + "SIRUTA_UAT", + "UAT_SIRUTA", + ]; + const upper = fields.map((f) => f.toUpperCase()); + for (const key of preferred) { + const idx = upper.indexOf(key); + if (idx >= 0) return fields[idx]; + } + return null; +}; + +export const fetchUatGeometry = async ( + client: EterraClient, + siruta: string, +): Promise => { + const layer = findLayerById("LIMITE_UAT"); + if (!layer) throw new Error("LIMITE_UAT not configured"); + + const fields = await client.getLayerFieldNames(layer); + const adminField = findAdminField(fields); + if (!adminField) throw new Error("LIMITE_UAT missing admin field"); + + const where = `${adminField}=${siruta}`; + const features = await client.fetchAllLayerByWhere(layer, where, { + outFields: adminField, + returnGeometry: true, + pageSize: 1, + }); + + const geometry = features[0]?.geometry as EsriGeometry | undefined; + if (!geometry?.rings?.length) throw new Error("UAT geometry not found"); + return geometry; +}; diff --git a/src/modules/parcel-sync/types.ts b/src/modules/parcel-sync/types.ts new file mode 100644 index 0000000..092c49b --- /dev/null +++ b/src/modules/parcel-sync/types.ts @@ -0,0 +1,61 @@ +/** + * ParcelSync module types. + */ + +export type UatEntry = { + siruta: string; + name: string; + county?: string; +}; + +export type LayerSyncStatus = { + layerId: string; + localCount: number; + lastSync?: string; // ISO date + lastSyncStatus?: string; // done | error + lastSyncNew?: number; + lastSyncRemoved?: number; +}; + +export type SyncRunEntry = { + id: string; + siruta: string; + uatName?: string; + layerId: string; + status: string; + totalRemote: number; + totalLocal: number; + newFeatures: number; + removedFeatures: number; + startedAt: string; + completedAt?: string; + errorMessage?: string; +}; + +export type ParcelFeature = { + id: string; + layerId: string; + siruta: string; + objectId: number; + inspireId?: string; + cadastralRef?: string; + areaValue?: number; + isActive: boolean; + attributes: Record; + geometry?: unknown; + projectId?: string; + createdAt: string; + updatedAt: string; +}; + +export type SyncProgress = { + jobId: string; + downloaded: number; + total?: number; + status: "running" | "done" | "error"; + phase?: string; + message?: string; + note?: string; + phaseCurrent?: number; + phaseTotal?: number; +};