This commit is contained in:
2026-04-30 02:10:27 +03:30
parent 5d8ad57b2d
commit 46a50545bb
9 changed files with 1158 additions and 24 deletions
Binary file not shown.
+2 -2
View File
@@ -18,8 +18,8 @@ urlpatterns = [
path("api/soil/", include("soil.urls")),
path("api/crop-zoning/", include("crop_zoning.urls")),
path("api/yield-harvest/", include("yield_harvest.urls")),
path("api/crop-simulation/", include("yield_harvest.crop_simulation_urls")),
# path("api/yield-harvest/", include("yield_harvest.urls")),
path("api/yield-harvest/", include("yield_harvest.crop_simulation_urls")),
path("api/pest-detection/", include("pest_detection.urls")),
path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
+941
View File
@@ -0,0 +1,941 @@
# مرجع کامل ارتباط Backend با AI در ماژول Yield & Harvest
این سند قرارداد فعلی backend برای endpointهای ماژول `yield_harvest` را توضیح می‌دهد؛ هم از دید فرانت/کاربر، هم از دید payload ارسالی به سرویس AI.
این سند این endpointها را پوشش می‌دهد:
- `POST /api/yield-harvest/current-farm-chart/`
- `POST /api/yield-harvest/growth/`
- `GET /api/yield-harvest/growth/{task_id}/status/`
- `POST /api/yield-harvest/harvest-prediction/`
- `POST /api/yield-harvest/yield-prediction/`
- `GET /api/yield-harvest/yield-harvest-summary/`
---
## هدف این سند
این ماژول باید برای endpointهای farm-based تا حد ممکن فقط `farm_uuid` را از کاربر بگیرد و بقیه context لازم را خودش از دیتابیس بخواند.
مهم‌ترین قاعده این سند:
- فرانت نباید `plant_name` را برای endpointهای farm-based ارسال کند.
- backend باید `plant_name` را از `farm_hub.models.FarmHub` استخراج کند.
- منبع استخراج `plant_name` این است:
1. اولین محصول `farm.products` بر اساس `id`
2. اگر مزرعه محصول نداشت، اولین محصول `farm.farm_type.products` بر اساس `id`
پیاده‌سازی فعلی این رفتار در فایل زیر است:
- `yield_harvest/views.py`
مدل‌های منبع داده:
- `farm_hub/models.py`
---
## احراز هویت و سطح دسترسی
همه endpointهای این سند نیاز به JWT معتبر دارند.
### هدرهای متداول
```http
Authorization: Bearer <access_token>
Content-Type: application/json
Accept: application/json
```
### اعتبارسنجی مالکیت مزرعه
برای endpointهایی که `farm_uuid` می‌گیرند، backend فقط زمانی درخواست را قبول می‌کند که:
- مزرعه وجود داشته باشد
- و مالک آن مزرعه همان `request.user` باشد
اگر مزرعه برای کاربر جاری پیدا نشود:
```json
{
"code": 404,
"msg": "error",
"data": {
"farm_uuid": ["Farm not found."]
}
}
```
---
## الگوی کلی پاسخ‌ها
تقریباً تمام endpointهای این ماژول از envelope زیر استفاده می‌کنند:
```json
{
"code": 200,
"msg": "success",
"data": {}
}
```
### معنی فیلدهای envelope
| فیلد | نوع | توضیح |
|---|---|---|
| `code` | integer | کد منطقی پاسخ؛ معمولاً با HTTP status هم‌راستا است |
| `msg` | string | پیام کوتاه پاسخ |
| `data` | object / array / null | بدنه اصلی پاسخ |
### خطاهای متداول
| HTTP Status | `code` | توضیح |
|---|---|---|
| `400` | `400` | ورودی نامعتبر است |
| `404` | `404` | مزرعه برای کاربر جاری پیدا نشد |
| `500` | `500` | AI یا لایه محاسباتی upstream خطا داده است |
| `202` | `202` | تسک async با موفقیت در صف قرار گرفته است |
---
## قرارداد ورودی از دید Frontend
### اصل طراحی
برای endpointهای farm-based این ماژول، فرانت فقط باید `farm_uuid` را ارسال کند و نباید موارد زیر را از کاربر بگیرد:
- `plant_name`
- `crop_name` برای جریان‌های farm-based prediction
- هر context دیگری که backend می‌تواند از مزرعه استخراج کند
### استثناها
- `GET /api/yield-harvest/growth/{task_id}/status/` به `farm_uuid` نیاز ندارد؛ چون بر اساس `task_id` کار می‌کند.
- `GET /api/yield-harvest/yield-harvest-summary/` علاوه بر `farm_uuid` می‌تواند queryهای اختیاری هم داشته باشد، ولی در قرارداد فرانت ساده می‌توان فقط `farm_uuid` را فرستاد.
- endpoint رشد (`growth`) در لایه AI پارامترهای پیشرفته دارد، اما در قرارداد ساده frontend این سند، ورودی کاربر باید فقط `farm_uuid` باشد و backend باید context گیاه را از مزرعه بردارد.
---
## نگاشت endpointهای Backend به AI
| Backend Route | Method | AI Route | Method |
|---|---|---|---|
| `/api/yield-harvest/current-farm-chart/` | `POST` | `/api/crop-simulation/current-farm-chart/` | `POST` |
| `/api/yield-harvest/growth/` | `POST` | `/api/crop-simulation/growth/` | `POST` |
| `/api/yield-harvest/growth/{task_id}/status/` | `GET` | `/api/crop-simulation/growth/{task_id}/status/` | `GET` |
| `/api/yield-harvest/harvest-prediction/` | `POST` | `/api/crop-simulation/harvest-prediction/` | `POST` |
| `/api/yield-harvest/yield-prediction/` | `POST` | `/api/crop-simulation/yield-prediction/` | `POST` |
| `/api/yield-harvest/yield-harvest-summary/` | `GET` | `/api/crop-simulation/yield-harvest-summary/` | `GET` |
---
## منبع `plant_name` در Backend
### منبع داده
backend نام گیاه را از مدل `FarmHub` در `farm_hub/models.py` می‌گیرد.
### ترتیب انتخاب
1. `farm.products.order_by("id").first()`
2. اگر مورد 1 خالی بود: `farm.farm_type.products.order_by("id").first()`
### مثال مفهومی
اگر مزرعه این محصولات را داشته باشد:
```text
farm.products = ["خیار", "گوجه‌فرنگی"]
```
backend این مقدار را برای AI می‌فرستد:
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "خیار"
}
```
یعنی معیار فعلی، «اولین محصول بر اساس `id`» است، نه محصول انتخاب‌شده توسط کاربر.
---
## 1) POST `/api/yield-harvest/current-farm-chart/`
### کاربرد
دریافت نمودار وضعیت فعلی مزرعه بر اساس شبیه‌سازی رشد محصول.
### ورودی از فرانت
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
### نکته مهم
- `plant_name` از کاربر گرفته نمی‌شود.
- backend آن را از مزرعه استخراج می‌کند.
### payload ارسالی backend به AI
نمونه:
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "خیار"
}
```
### پاسخ موفق
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
"engine": "growth_projection",
"model_name": "growth_projection_v1",
"scenario_id": 12,
"simulation_warning": null,
"categories": ["2026-04-01", "2026-04-02"],
"series": [
{
"name": "تعداد برگ تخمینی",
"key": "leaf_count_estimate",
"data": [120.0, 140.0]
}
],
"summary": [
{
"title": "تعداد برگ تخمینی",
"subtitle": "وضعیت فعلی",
"amount": 140.0,
"unit": "leaf",
"avatarColor": "success",
"avatarIcon": "tabler-leaf"
}
],
"current_state": {
"date": "2026-04-02",
"leaf_count_estimate": 140.0,
"leaf_area_index": 0.0117,
"biomass_weight": 45.0,
"storage_organ_weight": 10.0,
"soil_moisture_percent": 41.2,
"development_stage": 0.35,
"gdd": 9.0
},
"metrics": {
"yield_estimate": 10.0
},
"daily_output": []
}
}
```
### توضیح فیلدهای پاسخ
| فیلد | نوع | توضیح |
|---|---|---|
| `farm_uuid` | string / null | شناسه مزرعه |
| `plant_name` | string | نام گیاهی که شبیه‌سازی برای آن انجام شده |
| `engine` | string / null | موتور شبیه‌سازی |
| `model_name` | string / null | نام مدل |
| `scenario_id` | integer / null | شناسه سناریو |
| `simulation_warning` | string / null | هشدار غیر بحرانی |
| `categories` | array[string] | محور زمانی نمودار |
| `series` | array[object] | سری‌های نمودار |
| `summary` | array[object] | کارت‌های خلاصه |
| `current_state` | object | وضعیت آخرین روز شبیه‌سازی |
| `metrics` | object | شاخص‌های محاسبه‌شده |
| `daily_output` | array[object] | خروجی خام روزانه |
### توضیح `series[]`
| فیلد | نوع | توضیح |
|---|---|---|
| `name` | string | عنوان سری |
| `key` | string | کلید فنی سری |
| `data` | array[number] | مقادیر سری |
### توضیح `summary[]`
| فیلد | نوع | توضیح |
|---|---|---|
| `title` | string | عنوان کارت |
| `subtitle` | string | زیرعنوان |
| `amount` | number | مقدار اصلی |
| `unit` | string | واحد |
| `avatarColor` | string | رنگ پیشنهادی UI |
| `avatarIcon` | string | آیکن پیشنهادی UI |
### توضیح `current_state`
| فیلد | نوع | توضیح |
|---|---|---|
| `date` | string | تاریخ آخرین رکورد |
| `leaf_count_estimate` | number | تعداد برگ تخمینی |
| `leaf_area_index` | number | شاخص سطح برگ |
| `biomass_weight` | number | وزن بیوماس |
| `storage_organ_weight` | number | وزن اندام ذخیره‌ای / محصول |
| `soil_moisture_percent` | number | درصد رطوبت خاک |
| `development_stage` | number | مرحله رشد |
| `gdd` | number | درجه-روز رشد |
---
## 2) POST `/api/yield-harvest/growth/`
### کاربرد
شروع شبیه‌سازی رشد به صورت async.
### قرارداد ساده فرانت
در قرارداد frontend این سند، فرانت فقط باید `farm_uuid` را بفرستد.
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
### نکته مهم
- `plant_name` نباید از کاربر گرفته شود.
- backend آن را از مزرعه استخراج می‌کند.
- `task_id` خروجی این endpoint، ورودی endpoint وضعیت است.
### payload ارسالی backend به AI
نمونه مفهومی:
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "خیار",
"dynamic_parameters": ["DVS", "LAI", "TAGP", "TWSO", "SM"]
}
```
نکته: upstream AI ممکن است پارامترهای پیشرفته بیشتری هم بپذیرد، ولی این‌ها نباید از کاربر نهایی گرفته شوند مگر این‌که قرارداد جداگانه‌ای برای expert mode تعریف شود.
### پاسخ موفق
```json
{
"code": 202,
"msg": "تسک شبیه سازی رشد در صف قرار گرفت.",
"data": {
"task_id": "growth-task-1",
"status_url": "/api/crop-simulation/growth/growth-task-1/status/",
"plant_name": "گوجه‌فرنگی"
}
}
```
### توضیح فیلدهای پاسخ
| فیلد | نوع | توضیح |
|---|---|---|
| `task_id` | string | شناسه تسک |
| `status_url` | string | آدرس بررسی وضعیت تسک |
| `plant_name` | string | نام گیاهی که شبیه‌سازی برای آن آغاز شده |
---
## 3) GET `/api/yield-harvest/growth/{task_id}/status/`
### کاربرد
بررسی وضعیت و نتیجه تسک async شبیه‌سازی رشد.
### ورودی
این endpoint از کاربر `farm_uuid` نمی‌گیرد.
### Path Parameter
| فیلد | نوع | اجباری | توضیح |
|---|---|---:|---|
| `task_id` | string | بله | شناسه تسک برگشتی از endpoint رشد |
### Query اختیاری
| فیلد | نوع | اجباری | توضیح |
|---|---|---:|---|
| `page` | integer | خیر | شماره صفحه stageها |
| `page_size` | integer | خیر | تعداد آیتم در هر صفحه |
### پاسخ در حالت `PENDING`
```json
{
"code": 200,
"msg": "success",
"data": {
"task_id": "growth-task-1",
"status": "PENDING",
"message": "تسک در صف یا یافت نشد."
}
}
```
### پاسخ در حالت `PROGRESS`
```json
{
"code": 200,
"msg": "success",
"data": {
"task_id": "growth-task-1",
"status": "PROGRESS",
"progress": {
"current": 2,
"total": 3,
"percent": 66.7
}
}
}
```
### پاسخ در حالت `SUCCESS`
```json
{
"code": 200,
"msg": "success",
"data": {
"task_id": "growth-task-1",
"status": "SUCCESS",
"result": {
"plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS"],
"engine": "growth_projection",
"model_name": "growth_projection_v1",
"scenario_id": null,
"simulation_warning": null,
"summary_metrics": {},
"stage_timeline": [],
"stages_page": [],
"pagination": {
"page": 1,
"page_size": 10,
"total_items": 0,
"total_pages": 0,
"has_next": false,
"has_previous": false
},
"daily_records_count": 0,
"default_page_size": 10
}
}
}
```
### پاسخ در حالت `FAILURE`
```json
{
"code": 200,
"msg": "success",
"data": {
"task_id": "growth-task-1",
"status": "FAILURE",
"error": "task crashed"
}
}
```
### توضیح فیلدهای status response
| فیلد | نوع | توضیح |
|---|---|---|
| `task_id` | string | شناسه تسک |
| `status` | string | وضعیت تسک: `PENDING`, `PROGRESS`, `SUCCESS`, `FAILURE` |
| `message` | string | پیام کمکی در برخی وضعیت‌ها |
| `progress` | object | وضعیت پیشرفت |
| `result` | object | نتیجه نهایی در حالت موفق |
| `error` | string | خطای نهایی در حالت failure |
### توضیح `progress`
| فیلد | نوع | توضیح |
|---|---|---|
| `current` | integer | مرحله فعلی |
| `total` | integer | کل مراحل |
| `percent` | float | درصد پیشرفت |
### توضیح `result`
| فیلد | نوع | توضیح |
|---|---|---|
| `plant_name` | string | نام گیاه |
| `dynamic_parameters` | array[string] | پارامترهای دینامیک |
| `engine` | string / null | موتور شبیه‌سازی |
| `model_name` | string / null | نام مدل |
| `scenario_id` | integer / null | شناسه سناریو |
| `simulation_warning` | string / null | هشدار محاسباتی |
| `summary_metrics` | object | شاخص‌های خلاصه |
| `stage_timeline` | array[object] | timeline کامل مراحل |
| `stages_page` | array[object] | آیتم‌های همین صفحه |
| `pagination` | object | اطلاعات صفحه‌بندی |
| `daily_records_count` | integer | تعداد رکوردهای روزانه |
| `default_page_size` | integer | اندازه صفحه پیش‌فرض |
### توضیح `pagination`
| فیلد | نوع | توضیح |
|---|---|---|
| `page` | integer | صفحه فعلی |
| `page_size` | integer | اندازه صفحه |
| `total_items` | integer | تعداد کل stageها |
| `total_pages` | integer | تعداد کل صفحه‌ها |
| `has_next` | boolean | آیا صفحه بعدی وجود دارد |
| `has_previous` | boolean | آیا صفحه قبلی وجود دارد |
---
## 4) POST `/api/yield-harvest/harvest-prediction/`
### کاربرد
پیش‌بینی زمان برداشت برای مزرعه.
### ورودی از فرانت
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
### payload ارسالی backend به AI
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "خیار"
}
```
### پاسخ موفق
```json
{
"code": 200,
"msg": "success",
"data": {
"date": "2026-05-14",
"dateFormatted": "14 May 2026",
"daysUntil": 43,
"description": "شبيه ساز نشان مي دهد حدود 43 روز ديگر تا برداشت باقي مانده است.",
"optimalWindowStart": "2026-05-11",
"optimalWindowEnd": "2026-05-17",
"gddDetails": {
"current_cumulative_gdd": 50.0,
"required_gdd_for_maturity": 1200.0,
"remaining_gdd": 1150.0,
"simulation_engine": "growth_projection"
}
}
}
```
### توضیح فیلدهای پاسخ
| فیلد | نوع | توضیح |
|---|---|---|
| `date` | string | تاریخ تخمینی برداشت به فرمت ISO |
| `dateFormatted` | string | تاریخ قابل نمایش |
| `daysUntil` | integer | تعداد روزهای باقیمانده |
| `description` | string | توضیح متنی |
| `optimalWindowStart` | string | شروع پنجره مناسب برداشت |
| `optimalWindowEnd` | string | پایان پنجره مناسب برداشت |
| `gddDetails` | object | جزئیات محاسبات GDD |
### توضیح `gddDetails`
| فیلد | نوع | توضیح |
|---|---|---|
| `current_cumulative_gdd` | number | GDD تجمعی فعلی |
| `required_gdd_for_maturity` | number | GDD مورد نیاز برای بلوغ |
| `remaining_gdd` | number | GDD باقی‌مانده |
| `estimated_days_to_harvest` | integer | روزهای برآوردی تا برداشت |
| `predicted_harvest_date` | string | تاریخ برآوردی برداشت |
| `predicted_harvest_window` | object | بازه برداشت |
| `daily_gdd_forecast` | array[object] | پیش‌بینی روزانه GDD |
| `simulation_engine` | string | موتور شبیه‌سازی |
| `simulation_model_name` | string | نام مدل |
| `simulation_warning` | string / null | هشدار محاسباتی |
| `scenario_id` | integer / null | شناسه سناریو |
---
## 5) POST `/api/yield-harvest/yield-prediction/`
### کاربرد
پیش‌بینی عملکرد مزرعه.
### ورودی از فرانت
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111"
}
```
### payload ارسالی backend به AI
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "خیار"
}
```
### پاسخ موفق
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
"predictedYieldTons": 5.4,
"predictedYieldRaw": 5400.0,
"unit": "تن",
"sourceUnit": "kg/ha",
"simulationEngine": "growth_projection",
"simulationModel": "growth_projection_v1",
"scenarioId": 12,
"simulationWarning": null,
"supportingMetrics": {
"yield_estimate": 5400.0
}
}
}
```
### توضیح فیلدهای پاسخ
| فیلد | نوع | توضیح |
|---|---|---|
| `farm_uuid` | string | شناسه مزرعه |
| `plant_name` | string / null | نام گیاه |
| `predictedYieldTons` | number | عملکرد بر حسب تن |
| `predictedYieldRaw` | number | مقدار خام عملکرد |
| `unit` | string | واحد نمایشی |
| `sourceUnit` | string | واحد منبع |
| `simulationEngine` | string / null | موتور شبیه‌سازی |
| `simulationModel` | string / null | نام مدل شبیه‌سازی |
| `scenarioId` | integer / null | شناسه سناریو |
| `simulationWarning` | string / null | هشدار محاسباتی |
| `supportingMetrics` | object | شاخص‌های پشتیبان |
### توضیح `supportingMetrics`
این object بسته به upstream می‌تواند شامل مواردی مانند این‌ها باشد:
| فیلد | نوع | توضیح |
|---|---|---|
| `yield_estimate` | number | برآورد خام عملکرد |
| `biomass` | number | بیوماس برآوردی |
| `max_lai` | number | بیشترین شاخص سطح برگ |
---
## 6) GET `/api/yield-harvest/yield-harvest-summary/`
### کاربرد
دریافت داشبورد کامل عملکرد و برداشت.
### ورودی ساده از فرانت
```http
GET /api/yield-harvest/yield-harvest-summary/?farm_uuid=11111111-1111-1111-1111-111111111111
```
### Queryهای اختیاری قابل پشتیبانی
| فیلد | نوع | اجباری | توضیح |
|---|---|---:|---|
| `farm_uuid` | UUID | بله | شناسه مزرعه |
| `season_year` | integer | خیر | سال زراعی |
| `crop_name` | string | خیر | نام محصول |
| `include_narrative` | boolean | خیر | در صورت `true` متن‌های توضیحی هم merge می‌شوند |
### نکته قرارداد فرانت
در جریان ساده frontend، ارسال فقط `farm_uuid` کافی است و backend بقیه context لازم را مدیریت می‌کند.
### پاسخ موفق
```json
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"season_highlights_card": {},
"yield_prediction": {},
"harvest_prediction_card": {},
"harvest_readiness_zones": {},
"yield_quality_bands": {},
"harvest_operations_card": {},
"yield_prediction_chart": {}
}
}
```
### توضیح top-level response
| فیلد | نوع | توضیح |
|---|---|---|
| `farm_uuid` | string | شناسه مزرعه |
| `season_highlights_card` | object | خلاصه مهم‌ترین KPIها |
| `yield_prediction` | object | خروجی پیش‌بینی عملکرد |
| `harvest_prediction_card` | object | تاریخ و وضعیت برداشت |
| `harvest_readiness_zones` | object | آمادگی برداشت در zoneها |
| `yield_quality_bands` | object | کیفیت برآوردی محصول |
| `harvest_operations_card` | object | عملیات پیشنهادی برداشت |
| `yield_prediction_chart` | object | نمودار عملکرد و بیوماس |
### توضیح `season_highlights_card`
| فیلد | نوع | توضیح |
|---|---|---|
| `title` | string | عنوان کارت |
| `subtitle` | string | توضیح کوتاه |
| `total_predicted_yield` | number / null | عملکرد پیش‌بینی‌شده |
| `yield_unit` | string | واحد عملکرد |
| `target_harvest_date` | string / null | تاریخ هدف برداشت |
| `days_until_harvest` | integer / null | روز باقی‌مانده |
| `average_readiness` | number / null | میانگین آمادگی |
| `primary_quality_grade` | string / null | گرید کیفیت غالب |
| `estimated_revenue` | number / null | درآمد تخمینی |
| `soil_type` | string / null | نوع خاک |
### توضیح `yield_prediction`
| فیلد | نوع | توضیح |
|---|---|---|
| `predicted_yield_tons` | number | عملکرد بر حسب تن |
| `predicted_yield_raw` | number | عملکرد خام |
| `unit` | string | واحد نمایشی |
| `source_unit` | string | واحد منبع |
| `simulation_engine` | string / null | موتور شبیه‌سازی |
| `simulation_model` | string / null | نام مدل |
| `scenario_id` | integer / null | شناسه سناریو |
| `simulation_warning` | string / null | هشدار شبیه‌سازی |
| `secondary_kpis_estimated` | boolean | آیا KPIهای ثانویه تخمینی‌اند |
| `descriptionSource` | string | منبع توضیح |
| `farm_context` | object | context مزرعه |
| `supporting_metrics` | object | متریک‌های پشتیبان |
| `explanation` | string | توضیح متنی |
### توضیح `harvest_prediction_card`
| فیلد | نوع | توضیح |
|---|---|---|
| `harvest_date` | string | تاریخ ISO برداشت |
| `harvest_date_formatted` | string | تاریخ قابل نمایش |
| `days_until` | integer | روز باقی‌مانده |
| `optimal_window_start` | string | شروع بازه مناسب |
| `optimal_window_end` | string | پایان بازه مناسب |
| `description` | string | توضیح متنی |
| `descriptionSource` | string | منبع توضیح |
| `field_conditions` | object | شرایط فعلی مزرعه |
| `readiness_metrics` | object | جزئیات readiness/GDD |
### توضیح `harvest_readiness_zones`
| فیلد | نوع | توضیح |
|---|---|---|
| `observationDate` | string / null | تاریخ مشاهده |
| `vegetationHealthClass` | string / null | کلاس سلامت پوشش گیاهی |
| `meanNdvi` | number / null | NDVI میانگین |
| `ndviTrend` | number / null | روند NDVI |
| `averageReadiness` | number / null | میانگین آمادگی |
| `zones` | array[object] | فهرست zoneها |
| `source` | string | منبع داده |
| `summary` | string | توضیح خلاصه |
### توضیح هر zone در `zones[]`
| فیلد | نوع | توضیح |
|---|---|---|
| `zoneId` | string | شناسه zone |
| `zoneLabel` | string | نام نمایشی zone |
| `gridPosition` | object / null | موقعیت grid |
| `meanNdvi` | number | NDVI میانگین zone |
| `readiness` | integer | درصد آمادگی |
| `daysUntil` | integer | روز باقی‌مانده |
| `status` | string | وضعیت zone |
### توضیح `yield_quality_bands`
| فیلد | نوع | توضیح |
|---|---|---|
| `source` | string | منبع محاسبه |
| `is_estimated` | boolean | آیا مقادیر تخمینی‌اند |
| `protein_content` | object | درصد پروتئین |
| `moisture_percentage` | object | درصد رطوبت |
| `grade_distribution` | array[object] | توزیع گریدها |
| `primary_quality_grade` | string | گرید غالب |
| `quality_score` | number | امتیاز کیفیت |
| `summary` | string | خلاصه متنی |
### توضیح `harvest_operations_card`
| فیلد | نوع | توضیح |
|---|---|---|
| `stage_label` | string | برچسب مرحله عملیاتی |
| `phase_name` | string | نام فاز رشد |
| `days_until_harvest` | integer | روز باقی‌مانده |
| `current_dvs` | number | DVS فعلی |
| `summary` | string | خلاصه عملیاتی |
| `rules_source` | string | منبع قواعد |
| `field_context` | object | context مزرعه |
| `steps` | array[object] | گام‌های عملیاتی |
### توضیح هر step در `steps[]`
| فیلد | نوع | توضیح |
|---|---|---|
| `key` | string | کلید فنی step |
| `title` | string | عنوان عملیات |
| `status` | string | وضعیت step |
| `is_completed` | boolean | آیا انجام شده |
| `estimated_days` | integer | روز برآوردی |
| `note` | string | توضیح تکمیلی |
### توضیح `yield_prediction_chart`
| فیلد | نوع | توضیح |
|---|---|---|
| `series` | array[object] | سری‌های نمودار |
| `xAxis` | object | تنظیمات محور افقی |
| `meta` | object | متادیتای نمودار |
### توضیح `yield_prediction_chart.series[]`
| فیلد | نوع | توضیح |
|---|---|---|
| `name` | string | نام سری |
| `type` | string | نوع رسم مانند `line` یا `area` |
| `data` | array[[timestamp, value]] | داده‌های نمودار؛ timestamp بر حسب milliseconds |
### توضیح `yield_prediction_chart.meta`
| فیلد | نوع | توضیح |
|---|---|---|
| `unit` | string | واحد داده |
| `simulation_engine` | string | موتور شبیه‌سازی |
| `simulation_model` | string | مدل |
| `scenario_id` | integer / null | شناسه سناریو |
| `simulation_warning` | string / null | هشدار شبیه‌سازی |
| `field_context` | object | context مزرعه |
---
## خطاهای رایج با مثال
### نبودن `farm_uuid`
```json
{
"code": 400,
"msg": "error",
"data": {
"farm_uuid": ["This field is required."]
}
}
```
### پیدا نشدن مزرعه برای کاربر جاری
```json
{
"code": 404,
"msg": "error",
"data": {
"farm_uuid": ["Farm not found."]
}
}
```
### خطای upstream AI
```json
{
"code": 500,
"msg": "error",
"data": {
"code": 500,
"msg": "خطا در پیش بینی عملکرد: Plant not found.",
"data": null
}
}
```
نکته: در این وضعیت، envelope بیرونی از backend آمده و object داخلی معمولاً همان پاسخ upstream AI است.
---
## پیش‌فرض Swagger
برای endpointهای body-based این ماژول، `farm_uuid` در Swagger با مقدار پیش‌فرض زیر نمایش داده می‌شود:
```text
11111111-1111-1111-1111-111111111111
```
این رفتار برای endpointهای زیر برقرار است:
- `POST /api/yield-harvest/current-farm-chart/`
- `POST /api/yield-harvest/growth/`
- `POST /api/yield-harvest/harvest-prediction/`
- `POST /api/yield-harvest/yield-prediction/`
---
## جمع‌بندی اجرایی برای فرانت
### چیزی که فرانت باید بفرستد
- برای بیشتر endpointها فقط `farm_uuid`
- برای status فقط `task_id`
- در جریان ساده، `plant_name` هرگز از کاربر گرفته نشود
### چیزی که backend خودش مدیریت می‌کند
- پیدا کردن مزرعه متعلق به کاربر
- استخراج `plant_name` از `farm.products` یا `farm.farm_type.products`
- ارسال payload مناسب به AI
- normalize کردن پاسخ AI در envelope استاندارد backend
### چیزی که فرانت نباید به کاربر بسپارد
- انتخاب دستی `plant_name` در این flow
- ساخت payload مستقیم AI
- تفسیر business ruleهای انتخاب محصول
---
## مسیر فایل
این سند در مسیر زیر نگهداری می‌شود:
`docs/yield_harvest_ai_integration.md`
+2 -2
View File
@@ -16,8 +16,8 @@ CATALOG_SEED_DATA = {
{"name": "برنج", "planting_season": "بهار", "harvest_time": "اواخر تابستان", "soil": "رسی", "icon": "leaf", "growth_stage": "vegetative", "growth_stages": ["initial", "vegetative", "flowering", "maturity"]},
],
"گلخانه ای": [
{"name": "گوجه فرنگی", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "کوکوپیت", "icon": "tomato", "growth_stage": "fruiting", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]},
{"name": "گوجهفرنگی", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "کوکوپیت", "icon": "tomato", "growth_stage": "fruiting", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]},
{"name": "خیار", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "پرلیت", "icon": "leaf", "growth_stage": "fruiting", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]},
{"name": "فلفل دلمه ای", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "بستر هیدروپونیک", "icon": "pepper", "growth_stage": "flowering", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]},
{"name": "فلفل دلمهای", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "بستر هیدروپونیک", "icon": "pepper", "growth_stage": "flowering", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]},
],
}
+1 -1
View File
@@ -50,7 +50,7 @@ ADMIN_FARM_AREA_GEOJSON = {
def _get_default_catalog():
default_farm_type_name = "زراعی"
default_farm_type_name = "گلخانه ای"
created_products = []
for farm_type_name, products in CATALOG_SEED_DATA.items():
+19 -4
View File
@@ -59,19 +59,32 @@ class YieldHarvestSummarySerializer(serializers.Serializer):
class CropSimulationRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای اجرای شبیه‌سازی.")
plant_name = serializers.CharField(required=False, allow_blank=True, default="", help_text="نام گیاه یا محصول.")
farm_uuid = serializers.UUIDField(
required=True,
initial="11111111-1111-1111-1111-111111111111",
help_text="UUID مزرعه برای اجرای شبیه‌سازی.",
)
class GrowthSimulationRequestSerializer(serializers.Serializer):
plant_name = serializers.CharField(required=True, help_text="نام گیاه برای شروع شبیه‌سازی رشد.")
plant_name = serializers.CharField(
required=False,
allow_blank=True,
default="",
help_text="نام گیاه؛ اگر farm_uuid ارسال شود از محصول مزرعه استفاده می‌شود.",
)
dynamic_parameters = serializers.ListField(
child=serializers.CharField(),
required=True,
allow_empty=False,
help_text="لیست پارامترهای دینامیک موردنیاز مانند DVS یا LAI.",
)
farm_uuid = serializers.UUIDField(required=False, allow_null=True, help_text="UUID مزرعه؛ در صورت نبود باید weather ارسال شود.")
farm_uuid = serializers.UUIDField(
required=False,
allow_null=True,
initial="11111111-1111-1111-1111-111111111111",
help_text="UUID مزرعه؛ در صورت نبود باید weather ارسال شود.",
)
weather = serializers.JSONField(required=False, help_text="آب‌وهوا به‌صورت object یا array.")
soil_parameters = serializers.DictField(required=False, help_text="پارامترهای خاک.")
site_parameters = serializers.DictField(required=False, help_text="پارامترهای سایت.")
@@ -82,6 +95,8 @@ class GrowthSimulationRequestSerializer(serializers.Serializer):
def validate(self, attrs):
if not attrs.get("farm_uuid") and attrs.get("weather") in (None, "", [], {}):
raise serializers.ValidationError("At least one of 'farm_uuid' or 'weather' must be provided.")
if not attrs.get("farm_uuid") and not (attrs.get("plant_name") or "").strip():
raise serializers.ValidationError({"plant_name": ["This field is required when farm_uuid is not provided."]})
return attrs
+156 -4
View File
@@ -5,7 +5,7 @@ from django.test import TestCase
from rest_framework.test import APIClient, APIRequestFactory, force_authenticate
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType
from farm_hub.models import FarmHub, FarmType, Product
from .views import (
CurrentFarmChartView,
@@ -36,6 +36,8 @@ class CropSimulationViewTests(TestCase):
self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1")
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2")
self.product = Product.objects.create(farm_type=self.farm_type, name="گوجه‌فرنگی")
self.farm.products.add(self.product)
self.api_client.force_authenticate(user=self.user)
@patch("yield_harvest.views.external_api_request")
@@ -100,6 +102,41 @@ class CropSimulationViewTests(TestCase):
self.assertEqual(response.status_code, 202)
self.assertEqual(response.json()["data"]["task_id"], "growth-task-123")
@patch("yield_harvest.views.external_api_request")
def test_growth_yield_harvest_route_queues_simulation_task(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=202,
data={
"data": {
"task_id": "growth-task-123",
"status_url": "/api/crop-simulation/growth/growth-task-123/status/",
"plant_name": "گوجه‌فرنگی",
}
},
)
response = self.api_client.post(
"/api/yield-harvest/growth/",
{
"plant_name": "wheat",
"dynamic_parameters": ["DVS", "LAI"],
"farm_uuid": str(self.farm.farm_uuid),
},
format="json",
)
self.assertEqual(response.status_code, 202)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/growth/",
method="POST",
payload={
"plant_name": "گوجه‌فرنگی",
"dynamic_parameters": ["DVS", "LAI"],
"farm_uuid": str(self.farm.farm_uuid),
},
)
def test_growth_requires_farm_uuid_or_weather(self):
request = self.factory.post(
"/api/yield-harvest/crop-simulation/growth/",
@@ -171,6 +208,18 @@ class CropSimulationViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["status"], "SUCCESS")
@patch("yield_harvest.views.external_api_request")
def test_growth_status_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"task_id": "growth-task-123", "status": "SUCCESS"}},
)
response = self.api_client.get("/api/yield-harvest/growth/growth-task-123/status/?page=1&page_size=10")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["status"], "SUCCESS")
def test_legacy_plant_simulator_routes_are_unavailable(self):
legacy_paths = [
"/api/yield-harvest/plant-simulator/config/",
@@ -193,7 +242,7 @@ class CropSimulationViewTests(TestCase):
"data": {
"result": {
"farm_uuid": str(self.farm.farm_uuid),
"plant_name": "wheat",
"plant_name": "گوجه‌فرنگی",
"scenario_id": 1,
"categories": ["day1"],
"series": {"biomass": [1.2]},
@@ -218,7 +267,7 @@ class CropSimulationViewTests(TestCase):
"ai",
"/api/crop-simulation/current-farm-chart/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"},
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@patch("yield_harvest.views.external_api_request")
@@ -237,6 +286,27 @@ class CropSimulationViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid))
@patch("yield_harvest.views.external_api_request")
def test_current_farm_chart_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"}}},
)
response = self.api_client.post(
"/api/yield-harvest/current-farm-chart/",
{"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"},
format="json",
)
self.assertEqual(response.status_code, 200)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/current-farm-chart/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@patch("yield_harvest.views.external_api_request")
def test_harvest_prediction_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
@@ -268,7 +338,7 @@ class CropSimulationViewTests(TestCase):
"ai",
"/api/crop-simulation/harvest-prediction/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": ""},
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@patch("yield_harvest.views.external_api_request")
@@ -287,6 +357,27 @@ class CropSimulationViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["daysUntil"], 96)
@patch("yield_harvest.views.external_api_request")
def test_harvest_prediction_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"date": "2026-07-15", "daysUntil": 96}}},
)
response = self.api_client.post(
"/api/yield-harvest/harvest-prediction/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
self.assertEqual(response.status_code, 200)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/harvest-prediction/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@patch("yield_harvest.views.external_api_request")
def test_yield_prediction_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
@@ -313,6 +404,12 @@ class CropSimulationViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["predictedYieldTons"], 8.4)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/yield-prediction/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@patch("yield_harvest.views.external_api_request")
def test_yield_prediction_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
@@ -330,6 +427,49 @@ class CropSimulationViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["predictedYieldTons"], 8.4)
@patch("yield_harvest.views.external_api_request")
def test_yield_prediction_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "predictedYieldTons": 8.4}}},
)
response = self.api_client.post(
"/api/yield-harvest/yield-prediction/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
self.assertEqual(response.status_code, 200)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/yield-prediction/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@patch("yield_harvest.views.external_api_request")
def test_yield_prediction_falls_back_to_farm_type_product_when_farm_products_are_empty(self, mock_external_api_request):
farm_without_products = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm fallback")
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"farm_uuid": str(farm_without_products.farm_uuid), "predictedYieldTons": 8.4}}},
)
response = self.api_client.post(
"/api/yield-harvest/yield-prediction/",
{"farm_uuid": str(farm_without_products.farm_uuid)},
format="json",
)
self.assertEqual(response.status_code, 200)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/crop-simulation/yield-prediction/",
method="POST",
payload={"farm_uuid": str(farm_without_products.farm_uuid), "plant_name": "گوجه‌فرنگی"},
)
@patch("yield_harvest.views.external_api_request")
def test_yield_harvest_summary_top_level_route_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
@@ -368,6 +508,18 @@ class CropSimulationViewTests(TestCase):
},
)
@patch("yield_harvest.views.external_api_request")
def test_yield_harvest_summary_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "yield_prediction_chart": {"series": []}}}},
)
response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid))
def test_crop_simulation_rejects_foreign_farm_uuid(self):
request = self.factory.post(
"/api/yield-harvest/crop-simulation/yield-prediction/",
+6 -6
View File
@@ -11,10 +11,10 @@ from .views import (
urlpatterns = [
path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
path("crop-simulation/current-farm-chart/", CurrentFarmChartView.as_view(), name="yield-harvest-current-farm-chart"),
path("crop-simulation/growth/", GrowthSimulationView.as_view(), name="yield-harvest-growth"),
path("crop-simulation/growth/<str:task_id>/status/", GrowthSimulationStatusView.as_view(), name="yield-harvest-growth-status"),
path("crop-simulation/harvest-prediction/", HarvestPredictionView.as_view(), name="yield-harvest-harvest-prediction"),
path("crop-simulation/yield-prediction/", YieldPredictionView.as_view(), name="yield-harvest-yield-prediction"),
path("crop-simulation/yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-crop-simulation-summary"),
path("current-farm-chart/", CurrentFarmChartView.as_view(), name="yield-harvest-current-farm-chart"),
path("growth/", GrowthSimulationView.as_view(), name="yield-harvest-growth"),
path("growth/<str:task_id>/status/", GrowthSimulationStatusView.as_view(), name="yield-harvest-growth-status"),
path("harvest-prediction/", HarvestPredictionView.as_view(), name="yield-harvest-harvest-prediction"),
path("yield-prediction/", YieldPredictionView.as_view(), name="yield-harvest-yield-prediction"),
path("yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-crop-simulation-summary"),
]
+31 -5
View File
@@ -179,6 +179,18 @@ class CropSimulationBaseView(APIView):
status=adapter_response.status_code,
)
@staticmethod
def _get_first_farm_product_name(farm):
first_product = farm.products.order_by("id").first()
if first_product is not None:
return (first_product.name or "").strip()
fallback_product = farm.farm_type.products.order_by("id").first()
if fallback_product is not None:
return (fallback_product.name or "").strip()
return ""
class CurrentFarmChartView(CropSimulationBaseView):
ai_path = "/api/crop-simulation/current-farm-chart/"
@@ -197,7 +209,10 @@ class CurrentFarmChartView(CropSimulationBaseView):
if error_response is not None:
return error_response
ai_payload = {"farm_uuid": str(farm.farm_uuid), "plant_name": payload.get("plant_name", "")}
ai_payload = {
"farm_uuid": str(farm.farm_uuid),
"plant_name": self._get_first_farm_product_name(farm),
}
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
if adapter_response.status_code >= 400:
@@ -226,7 +241,10 @@ class HarvestPredictionView(CropSimulationBaseView):
if error_response is not None:
return error_response
ai_payload = {"farm_uuid": str(farm.farm_uuid), "plant_name": payload.get("plant_name", "")}
ai_payload = {
"farm_uuid": str(farm.farm_uuid),
"plant_name": self._get_first_farm_product_name(farm),
}
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
if adapter_response.status_code >= 400:
@@ -255,7 +273,10 @@ class YieldPredictionView(CropSimulationBaseView):
if error_response is not None:
return error_response
ai_payload = {"farm_uuid": str(farm.farm_uuid), "plant_name": payload.get("plant_name", "")}
ai_payload = {
"farm_uuid": str(farm.farm_uuid),
"plant_name": self._get_first_farm_product_name(farm),
}
adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload)
if adapter_response.status_code >= 400:
@@ -278,8 +299,13 @@ class GrowthSimulationView(APIView):
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data.copy()
if payload.get("farm_uuid") is not None:
payload["farm_uuid"] = str(payload["farm_uuid"])
farm_uuid = payload.get("farm_uuid")
if farm_uuid is not None:
farm, error_response = CropSimulationBaseView._get_farm(request, farm_uuid)
if error_response is not None:
return error_response
payload["farm_uuid"] = str(farm.farm_uuid)
payload["plant_name"] = CropSimulationBaseView._get_first_farm_product_name(farm)
adapter_response = external_api_request(
"ai",