Files
Frontend/src/libs/api/services/cropZoningService.ts
T
2026-04-02 23:44:24 +03:30

453 lines
11 KiB
TypeScript

/**
* 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<Polygon> | 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<T> {
status: string;
data: T;
}
interface CachedAreaEntry {
expiresAt: number;
value: CropZoningAreaResult;
}
async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
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<string, unknown>,
): void {
if (typeof window === "undefined") return;
console.log(`[crop-zoning][area][${phase}]`, payload);
}
export const cropZoningService = {
getProducts(): Promise<{ products: Product[] }> {
return unwrap(
apiClient.get<ApiResponse<{ products: Product[] }>>(
`${PREFIX}/products/`,
),
);
},
getZonesInitial(body: {
zones: FeatureCollection<Polygon>;
products?: string[];
}): Promise<ZonesInitialResponse> {
return unwrap(
apiClient.post<ApiResponse<ZonesInitialResponse>>(
`${PREFIX}/zones/initial/`,
body,
),
);
},
getZoneDetails(zoneId: string): Promise<ZoneDetailData> {
return unwrap(
apiClient.get<ApiResponse<ZoneDetailData>>(
`${PREFIX}/zones/${zoneId}/details/`,
),
);
},
getArea(
farmUuid: string,
options?: { page?: number; pageSize?: number; useCache?: boolean },
): Promise<CropZoningAreaResponse> {
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<ApiResponse<CropZoningAreaResponse>>(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<RecommendationTaskStatusResponse<CropZoningAreaResult>> {
return unwrap(
apiClient.get<
ApiResponse<RecommendationTaskStatusResponse<CropZoningAreaResult>>
>(`${PREFIX}/area/status/${taskId}/`),
).then((response) => ({
...response,
status: normalizeRecommendationTaskStatus(response.status),
result: response.result
? normalizeAreaResult(response.result)
: undefined,
}));
},
/** نیاز آبی هر منطقه — برای لایهٔ نیاز آبی */
getZonesWaterNeed(body: {
zones: FeatureCollection<Polygon>;
}): Promise<{ zones: ZoneWaterNeedData[] }> {
return unwrap(
apiClient.post<ApiResponse<{ zones: ZoneWaterNeedData[] }>>(
`${PREFIX}/zones/water-need/`,
body,
),
);
},
/** کیفیت خاک هر منطقه — برای لایهٔ کیفیت خاک */
getZonesSoilQuality(body: {
zones: FeatureCollection<Polygon>;
}): Promise<{ zones: ZoneSoilQualityData[] }> {
return unwrap(
apiClient.post<ApiResponse<{ zones: ZoneSoilQualityData[] }>>(
`${PREFIX}/zones/soil-quality/`,
body,
),
);
},
/** ریسک کشت هر منطقه — برای لایهٔ ریسک کشت */
getZonesCultivationRisk(body: {
zones: FeatureCollection<Polygon>;
}): Promise<{ zones: ZoneCultivationRiskData[] }> {
return unwrap(
apiClient.post<ApiResponse<{ zones: ZoneCultivationRiskData[] }>>(
`${PREFIX}/zones/cultivation-risk/`,
body,
),
);
},
};