/** * Crop Zoning API * @see CROP_ZONING_APIS.md */ import type { Feature, FeatureCollection, Polygon } from "geojson"; import { apiClient } from "../client"; import type { RecommendationTaskInitResponse, RecommendationTaskStatus, RecommendationTaskStatusResponse, } from "./recommendationTask"; import { normalizeRecommendationTaskStatus } from "./recommendationTask"; const PREFIX = "/api/crop-zoning"; const AREA_CACHE_KEY_PREFIX = "crop-zoning:area"; const AREA_CACHE_VERSION = "v1"; const AREA_CACHE_TTL_MS = 1000 * 60 * 60 * 6; export interface Product { id: string; label: string; color: string; } export interface ZoneInitialData { zoneId: string; zoneUuid?: string; geometry: Polygon; center?: { latitude: number; longitude: number; }; area_sqm?: number; area_hectares?: number; sequence?: number; processing_status?: "pending" | "processing" | "completed" | "failed"; processing_error?: string; /** اگر null/خالی/uncultivable باشد، زون غیرقابل کشت و خاکستری نمایش داده می‌شود */ crop?: string | null; matchPercent?: number | null; waterNeed?: string | null; estimatedProfit?: string | null; waterNeedLayer?: { level: "low" | "medium" | "high"; value?: string; color: string; }; soilQualityLayer?: { level: "low" | "medium" | "high"; score?: number; color: string; }; cultivationRiskLayer?: { level: "low" | "medium" | "high"; color: string; }; } export interface ZonesInitialResponse { total_area_hectares: number; total_area_sqm: number; zone_count: number; zones: ZoneInitialData[]; } export interface AreaResponse { area: Feature | null; } export interface CropZoningAreaTask { status?: | "IDLE" | "PENDING" | "PROCESSING" | "SUCCESS" | "FAILURE" | "pending" | "processing" | "success" | "completed" | "failure" | "failed"; stage?: string; stage_label?: string; area_uuid?: string; total_zones?: number; completed_zones?: number; processing_zones?: number; pending_zones?: number; failed_zones?: number; remaining_zones?: number; progress_percent?: number; message?: string; failed_zone_errors?: string[]; cell_side_km?: number; } export interface CropZoningAreaResult extends AreaResponse { status?: string; task?: CropZoningAreaTask | null; zones?: ZoneInitialData[]; pagination?: { page: number; page_size: number; total_pages: number; total_zones: number; returned_zones: number; has_next: boolean; has_previous: boolean; }; } export type CropZoningAreaResponse = | CropZoningAreaResult | RecommendationTaskInitResponse; export interface ZoneDetailData { zoneId: string; crop: string; matchPercent: number; waterNeed: string; estimatedProfit: string; reason: string; criteria: { name: string; value: number }[]; area_hectares?: number; } /** دیتای نیاز آبی هر زون — لایهٔ نیاز آبی */ export interface ZoneWaterNeedData { zoneId: string; geometry: Polygon; level: "low" | "medium" | "high"; value?: string; color: string; } /** دیتای کیفیت خاک هر زون — لایهٔ کیفیت خاک */ export interface ZoneSoilQualityData { zoneId: string; geometry: Polygon; level: "low" | "medium" | "high"; score?: number; color: string; } /** دیتای ریسک کشت هر زون — لایهٔ ریسک کشت */ export interface ZoneCultivationRiskData { zoneId: string; geometry: Polygon; level: "low" | "medium" | "high"; color: string; } /** دادهٔ نمایشی هر زون روی نقشه — خروجی تبدیل از تمام لایه‌ها */ export interface ZoneMapData { zoneId: string; geometry: Polygon; color: string; tooltipContent: string; cultivable: boolean; zoneInitialData?: ZoneInitialData; } interface ApiResponse { status: string; data: T; } interface CachedAreaEntry { expiresAt: number; value: CropZoningAreaResult; } async function unwrap(promise: Promise>): Promise { const res = await promise; return res.data; } function normalizeTaskInitResponse( task: RecommendationTaskInitResponse, ): RecommendationTaskInitResponse { return { ...task, status: normalizeRecommendationTaskStatus(task.status), }; } function normalizeAreaResult( result: CropZoningAreaResult, ): CropZoningAreaResult { return { ...result, task: result.task ? { ...result.task, status: normalizeRecommendationTaskStatus(result.task.status), } : result.task, }; } function getAreaCacheUserKey(): string { if (typeof window === "undefined") return "server"; const token = localStorage.getItem("auth_token"); if (!token) { return "guest"; } try { return btoa(token).slice(0, 24); } catch { return token.slice(0, 24); } } function getAreaCacheKey( farmUuid: string, page: number, pageSize: number, ): string { return [ AREA_CACHE_KEY_PREFIX, AREA_CACHE_VERSION, getAreaCacheUserKey(), farmUuid, page, pageSize, ].join(":"); } function readCachedArea( farmUuid: string, page: number, pageSize: number, ): CropZoningAreaResult | null { if (typeof window === "undefined") return null; try { const raw = localStorage.getItem(getAreaCacheKey(farmUuid, page, pageSize)); if (!raw) { return null; } const parsed = JSON.parse(raw) as CachedAreaEntry; if (!parsed?.expiresAt || parsed.expiresAt < Date.now()) { localStorage.removeItem(getAreaCacheKey(farmUuid, page, pageSize)); return null; } return normalizeAreaResult(parsed.value); } catch { return null; } } function writeCachedArea( farmUuid: string, page: number, pageSize: number, value: CropZoningAreaResult, ): void { if (typeof window === "undefined") return; try { const payload: CachedAreaEntry = { expiresAt: Date.now() + AREA_CACHE_TTL_MS, value, }; localStorage.setItem( getAreaCacheKey(farmUuid, page, pageSize), JSON.stringify(payload), ); } catch { // Ignore storage quota and serialization errors. } } function logAreaRequest( phase: "cache-hit" | "request" | "response", payload: Record, ): void { if (typeof window === "undefined") return; console.log(`[crop-zoning][area][${phase}]`, payload); } export const cropZoningService = { getProducts(): Promise<{ products: Product[] }> { return unwrap( apiClient.get>( `${PREFIX}/products/`, ), ); }, getZonesInitial(body: { zones: FeatureCollection; products?: string[]; }): Promise { return unwrap( apiClient.post>( `${PREFIX}/zones/initial/`, body, ), ); }, getZoneDetails(zoneId: string): Promise { return unwrap( apiClient.get>( `${PREFIX}/zones/${zoneId}/details/`, ), ); }, getArea( farmUuid: string, options?: { page?: number; pageSize?: number; useCache?: boolean }, ): Promise { const page = options?.page ?? 1; const pageSize = options?.pageSize ?? 10; const useCache = options?.useCache ?? true; if (useCache) { const cached = readCachedArea(farmUuid, page, pageSize); if (cached) { logAreaRequest("cache-hit", { farmUuid, page, pageSize, pagination: cached.pagination ?? null, taskStatus: cached.task?.status ?? null, zonesCount: cached.zones?.length ?? 0, }); return Promise.resolve(cached); } } const params = new URLSearchParams({ farm_uuid: farmUuid }); params.set("page", String(page)); params.set("page_size", String(pageSize)); const endpoint = `${PREFIX}/area/?${params.toString()}`; logAreaRequest("request", { farmUuid, page, pageSize, endpoint, }); return unwrap( apiClient.get>(endpoint), ).then((response) => { if ("task_id" in response) { logAreaRequest("response", { farmUuid, page, pageSize, taskId: response.task_id, status: response.status, }); return normalizeTaskInitResponse(response); } const normalized = normalizeAreaResult(response); const taskStatus = normalized.task?.status?.toLowerCase(); logAreaRequest("response", { farmUuid, page, pageSize, taskStatus: normalized.task?.status ?? null, pagination: normalized.pagination ?? null, zonesCount: normalized.zones?.length ?? 0, hasArea: Boolean(normalized.area), }); if ( normalized.area && taskStatus !== "pending" && taskStatus !== "processing" && taskStatus !== "failure" && taskStatus !== "failed" ) { writeCachedArea(farmUuid, page, pageSize, normalized); } return normalized; }); }, getAreaStatus( taskId: string, ): Promise> { return unwrap( apiClient.get< ApiResponse> >(`${PREFIX}/area/status/${taskId}/`), ).then((response) => ({ ...response, status: normalizeRecommendationTaskStatus(response.status), result: response.result ? normalizeAreaResult(response.result) : undefined, })); }, /** نیاز آبی هر منطقه — برای لایهٔ نیاز آبی */ getZonesWaterNeed(body: { zones: FeatureCollection; }): Promise<{ zones: ZoneWaterNeedData[] }> { return unwrap( apiClient.post>( `${PREFIX}/zones/water-need/`, body, ), ); }, /** کیفیت خاک هر منطقه — برای لایهٔ کیفیت خاک */ getZonesSoilQuality(body: { zones: FeatureCollection; }): Promise<{ zones: ZoneSoilQualityData[] }> { return unwrap( apiClient.post>( `${PREFIX}/zones/soil-quality/`, body, ), ); }, /** ریسک کشت هر منطقه — برای لایهٔ ریسک کشت */ getZonesCultivationRisk(body: { zones: FeatureCollection; }): Promise<{ zones: ZoneCultivationRiskData[] }> { return unwrap( apiClient.post>( `${PREFIX}/zones/cultivation-risk/`, body, ), ); }, };