05 — Production Cycle Workflows
Domain owner: Field Agent (primary), Administrator (review/oversight) Mobile entry point:
ProductionCycleMenuScreenviaAppRoutes.productionCycleMenu
The Production Cycle domain tracks the full lifecycle of a crop from planting through harvest. Field agents record production data during farmer visits, including crop selection, planting details, growth inputs (fertilizers, pesticides, seeds), and harvest declarations. The entire domain follows Offline-first Pattern A — all reads come from local SQLite (instant, no network), all writes go to local SQLite with sync_status='pending', and a background sync layer pushes pending records to the server.
Domain Summary
graph LR
subgraph "Production Cycle Domain"
WF01["WF-PROD-01\nProduction Cycle Capture"]
WF02["WF-PROD-02\nGrowth Monitoring Visit"]
WF03["WF-PROD-03\nHarvest Recording"]
WF04["WF-PROD-04\nReview & History"]
WF05["WF-PROD-05\nProduction Outlook"]
end
AGENT((Field Agent)) --> WF01
AGENT --> WF02
AGENT --> WF03
ADMIN((Administrator)) --> WF04
ADMIN --> WF05
AGENT --> WF04
WF01 -->|sync| SYNC[[Sync Engine]]
WF02 -->|sync| SYNC
WF03 -->|sync| SYNC
SYNC -->|push/pull| API[Backend API]
API --> DB[(PostgreSQL)]
WF05 -->|GET /outlook| API
Key Files
| Layer | File | Purpose |
|---|---|---|
| Entry Point | mobile/.../presentation/production_cycle/production_cycle_menu_screen.dart | Menu: "New Production Record" / "Production History" |
| Service | mobile/.../data/services/production_cycle_service.dart | Offline-first singleton (Pattern A) |
| Draft | mobile/.../data/services/production_cycle_draft_service.dart | SharedPreferences draft persistence |
| Local DB | mobile/.../data/local/production_cycle_local_service.dart | SQLite CRUD for visits, cycles, inputs, harvests |
| Sync | mobile/.../data/services/sync/production_cycle_sync.dart | PUSH (pending->server) and PULL (server->local) |
| Models | mobile/.../data/local/models/local_production_cycle.dart | Local model with sync management fields |
| Models | mobile/.../data/local/models/local_production_input.dart | Input model (Fertilizer/Pesticide/Seed/Other) |
| Models | mobile/.../data/local/models/local_production_harvest.dart | Harvest model with quantity, quality grade |
| DTOs | mobile/.../data/models/production_cycle_models.dart | API DTOs: ProductionCycleListItem, ProductionDetail, ProductionOutlook |
| Backend | backend/Almafrica.API/Controllers/ProductionsController.cs | REST API at api/productions |
Lifecycle States
Planned --> Planted --> Growing --> Ready --> Harvested
\ /
+------> Cancelled <-------+
WF-PROD-01: Production Cycle Capture
| Property | Value |
|---|---|
| Workflow ID | WF-PROD-01 |
| Trigger | Agent taps "New Production Record" on ProductionCycleMenuScreen |
| Frequency | On-demand, during farmer visits |
| Offline Support | Full — visit created locally, cycle saved to SQLite, draft auto-saved to SharedPreferences, sync deferred to background |
Workflow Diagram
graph TD
T((Agent taps\n"New Production Record")) --> NAV_VISIT[Navigate to\nStartVisitScreen]
NAV_VISIT --> LOAD_FARMERS[Load farmers from\nFarmerLocalService.instance]
LOAD_FARMERS --> PULL_CHECK{PullScopeResolver:\nfarmers fresh?}
PULL_CHECK -->|stale| PULL_FARMERS[Pull farmers\nfrom server]
PULL_CHECK -->|fresh| SHOW_LIST[Show farmer\nselection list]
PULL_FARMERS --> SHOW_LIST
SHOW_LIST --> SELECT_FARMER[Agent selects\na farmer]
SELECT_FARMER --> GEN_VISIT_ID["Generate local visit ID\nvisit_DateTime.now()\n.microsecondsSinceEpoch"]
GEN_VISIT_ID --> START_VISIT["ProductionCycleService\n.startVisit()\npushToServer: false"]
START_VISIT --> SAVE_VISIT_LOCAL[[Save LocalFarmerVisit\nto SQLite\nsync_status=pending]]
SAVE_VISIT_LOCAL --> SET_ACTIVE["ActiveVisitProvider\n.setActiveVisit()"]
SET_ACTIVE --> NAV_CAPTURE[Navigate to\nProductionCycleCaptureScreen]
NAV_CAPTURE --> RESTORE_DRAFT{Draft exists in\nSharedPreferences?}
RESTORE_DRAFT -->|yes| LOAD_DRAFT["ProductionCycleDraftService\n.loadDraft()"]
RESTORE_DRAFT -->|no| SHOW_FORM[Show empty\ncapture form]
LOAD_DRAFT --> SHOW_FORM
SHOW_FORM --> MASTER_DATA["DropdownLoadingMixin\nloads crop categories,\nunits, soil types"]
MASTER_DATA --> FILL_FORM["Agent fills form:\n- Crop category/crop\n- Area planted\n- Plot identifier\n- Soil/Terrain/Tenure\n- Planting date\n- Expected harvest dates\n- Estimated yield"]
FILL_FORM --> AUTO_DRAFT["Auto-save draft\n350ms debounce\nProductionCycleDraftService\n.saveDraft()"]
AUTO_DRAFT --> ADD_INPUTS{Add production\ninputs?}
ADD_INPUTS -->|yes| INPUT_FORM["Inline input form:\ntype, quantity, unit,\napplied date, notes"]
INPUT_FORM --> ADD_INPUTS
ADD_INPUTS -->|no| ADD_PHOTO{Add planting\nphoto?}
ADD_PHOTO -->|yes| PICK_PHOTO["ImagePicker:\ncamera or gallery"]
PICK_PHOTO --> ADD_PHOTO
ADD_PHOTO -->|no| SUBMIT_CHOICE{Save as Draft\nor Mark Planted?}
SUBMIT_CHOICE -->|draft| SAVE_DRAFT["LocalProductionCycle.create()\nisDraft: true\nstatus: Planned"]
SUBMIT_CHOICE -->|planted| SAVE_PLANTED["LocalProductionCycle.create()\nisDraft: false\nstatus: Planted"]
SAVE_DRAFT --> SAVE_LOCAL[[ProductionCycleLocalService\n.saveCycle()\nsync_status=pending]]
SAVE_PLANTED --> SAVE_LOCAL
SAVE_LOCAL --> SAVE_INPUTS_LOCAL[[Save LocalProductionInput\nrecords to SQLite]]
SAVE_INPUTS_LOCAL --> CLEAR_DRAFT["ProductionCycleDraftService\n.clearDraft()"]
CLEAR_DRAFT --> END_VISIT["ProductionCycleService\n.endVisit()"]
END_VISIT --> NAVIGATE_BACK[Navigate back\nto menu]
NAVIGATE_BACK -.->|background| BG_SYNC["ProductionCycleSyncService\n.pushPendingCycles()"]
Node Descriptions
| Node | Type | Implementation |
|---|---|---|
| Navigate to StartVisitScreen | Action | Navigator.pushNamed(AppRoutes.productionCycleStartVisit) in production_cycle_menu_screen.dart |
| Load farmers | Data | FarmerLocalService.instance — reads from SQLite farmers table |
| PullScopeResolver check | Decision | PullScopeResolver determines if farmer cache is stale and triggers a pull if needed |
| Generate local visit ID | Action | 'visit_${DateTime.now().microsecondsSinceEpoch}' in start_visit_screen.dart |
| Start visit | Action | ProductionCycleService.instance.startVisit(farmerId, agentId, localVisitId, pushToServer: false) |
| Save visit to SQLite | Database | ProductionCycleLocalService.saveVisit(LocalFarmerVisit) inserts into farmer_visits table |
| Set active visit | Action | ActiveVisitProvider.setActiveVisit() — provider state for visit context banner |
| Restore draft | Decision | ProductionCycleDraftService.loadDraft() checks SharedPreferences key production_cycle_capture_draft_v1 |
| DropdownLoadingMixin | Action | Loads crop categories, measurement units, soil types from master data providers |
| Auto-save draft | Action | _draftSaveDebounce — 350ms debounce Timer triggers ProductionCycleDraftService.saveDraft(formData) |
| Inline input form | Action | _LocalInput objects collected in _pendingInputs list — Seed, Fertilizer, Pesticide, or Other |
| Image picker | Action | ImagePicker from image_picker package — camera or gallery source |
| Create LocalProductionCycle | Action | LocalProductionCycle.create(...) factory — sets syncStatus: SyncStatus.pending |
| Save cycle to SQLite | Database | ProductionCycleLocalService.saveCycle(cycle) inserts into production_cycles table |
| Save inputs to SQLite | Database | ProductionCycleLocalService.saveInput(LocalProductionInput) for each collected input |
| Clear draft | Action | ProductionCycleDraftService.clearDraft() removes SharedPreferences key |
| End visit | Action | ProductionCycleService.endVisit() marks LocalFarmerVisit.ended_at and sets sync_status=pending |
| Background sync | Action | ProductionCycleSyncService.pushPendingCycles() — runs via SyncManager |
Data Transformations
| Step | Input | Output | Transform |
|---|---|---|---|
| Form -> Local model | Form field values | LocalProductionCycle | LocalProductionCycle.create() maps form fields to model properties; generates UUID id, sets createdAt, syncStatus=pending |
| Input form -> Local input | Input form fields | LocalProductionInput | Maps inputType, type-specific IDs (fertilizerTypeId, pesticideTypeId, seedSourceId), quantity, unit, applied date |
| Local model -> API payload | LocalProductionCycle | Map<String, dynamic> | toCreatePayload() builds DTO for POST /api/productions; resolves farmerId to server GUID |
| Draft -> SharedPreferences | Form state map | JSON string | ProductionCycleDraftService.saveDraft() serializes to JSON via SharedPreferences |
Error Handling
| Error | Handler | Recovery |
|---|---|---|
| SQLite insert failure | ProductionCycleLocalService.saveCycle() returns null | Snackbar error shown; draft preserved in SharedPreferences for retry |
| Master data not loaded | DropdownLoadingMixin | Shows loading indicator; retries fetch; falls back to cached master data |
| Photo picker failure | try/catch in capture screen | Graceful degradation — cycle saved without photo; photo can be added later |
| Visit start failure (offline) | pushToServer: false path | Visit saved locally with sync_status=pending; sync layer pushes later |
Cross-References
- WF-FARM-01 (Farmer Registration) — farmer selection list reads from same local farmer cache
- WF-SYNC-03 (Production Cycle Sync) —
ProductionCycleSyncService.pushPendingCycles()handles push of locally-saved cycles - WF-PROD-02 — growth monitoring adds inputs to existing cycles created here
WF-PROD-02: Growth Monitoring Visit
| Property | Value |
|---|---|
| Workflow ID | WF-PROD-02 |
| Trigger | Agent opens an existing production cycle from history or detail screen and adds inputs |
| Frequency | On-demand, multiple times per production cycle throughout growing season |
| Offline Support | Full — inputs saved to local SQLite, synced in background |
Workflow Diagram
graph TD
T((Agent opens\nexisting cycle)) --> LOAD_DETAIL["ProductionCycleService\n.getProductionDetail(id)"]
LOAD_DETAIL --> READ_LOCAL[[Read from SQLite:\ncycle + inputs + harvests]]
READ_LOCAL --> SHOW_DETAIL[Show\nProductionCycleDetailScreen]
SHOW_DETAIL --> ADD_INPUT[Agent taps\n"Add Input"]
ADD_INPUT --> INPUT_SHEET["Show bottom sheet:\ninput type selector"]
INPUT_SHEET --> SELECT_TYPE{Input type?}
SELECT_TYPE -->|Seed| SEED_FORM["Seed form:\nseedSourceId, quantity,\nunit, applied date"]
SELECT_TYPE -->|Fertilizer| FERT_FORM["Fertilizer form:\nfertilizerTypeId, quantity,\nunit, applied date"]
SELECT_TYPE -->|Pesticide| PEST_FORM["Pesticide form:\npesticideTypeId, quantity,\nunit, applied date"]
SELECT_TYPE -->|Other| OTHER_FORM["Other form:\nname, quantity,\nunit, applied date"]
SEED_FORM --> VALIDATE[Validate required\nfields]
FERT_FORM --> VALIDATE
PEST_FORM --> VALIDATE
OTHER_FORM --> VALIDATE
VALIDATE --> SAVE_INPUT[[ProductionCycleLocalService\n.saveInput(LocalProductionInput)\nsync_status=pending]]
SAVE_INPUT --> REFRESH[Refresh detail\nscreen]
REFRESH --> UPDATE_STATUS{Update cycle\nstatus?}
UPDATE_STATUS -->|yes| STATUS_CHANGE["ProductionCycleLocalService\n.updateCycle()\nstatus: Growing"]
UPDATE_STATUS -->|no| DONE[Return to\ndetail view]
STATUS_CHANGE --> DONE
DONE -.->|background| BG_SYNC["ProductionCycleSyncService\n._pushPendingInputs()"]
Node Descriptions
| Node | Type | Implementation |
|---|---|---|
| Load production detail | Data | ProductionCycleService.getProductionDetail(id) reads from ProductionCycleLocalService |
| Read from SQLite | Database | _local.getCycleByAnyId(id) + _local.getInputsByCycle(id) + _local.getHarvestsByCycle(id) |
| Show detail screen | Action | ProductionCycleDetailScreen displays cycle info, inputs list, harvests list, audit trail |
| Add Input bottom sheet | Action | Modal bottom sheet in production_cycle_detail_screen.dart with input type selection |
| Input type forms | Action | Type-specific form fields: fertilizerTypeId, pesticideTypeId, seedSourceId, or free-text name for Other |
| Save input to SQLite | Database | ProductionCycleLocalService.saveInput(LocalProductionInput) — sets sync_status=pending |
| Update cycle status | Action | ProductionCycleLocalService.updateCycle() — transitions status (e.g., Planted -> Growing) |
| Background sync | Action | ProductionCycleSyncService._pushPendingInputs() iterates pending inputs and calls POST /api/productions/{id}/inputs |
Data Transformations
| Step | Input | Output | Transform |
|---|---|---|---|
| Bottom sheet form -> Local input | Form fields | LocalProductionInput | Maps type, type-specific ID, quantity, unitId, appliedDate; generates UUID id; sets syncStatus=pending |
| Local input -> API payload | LocalProductionInput | Map<String, dynamic> | toCreatePayload() builds DTO for POST /api/productions/{id}/inputs |
Input displayName | Type + type-specific fields | String | Getter resolves name from inputType and specific type name fields |
Error Handling
| Error | Handler | Recovery |
|---|---|---|
| Cycle not found locally | getProductionDetail() returns failure | Error screen shown; user navigates back |
| Input save failure | saveInput() returns null | Snackbar error; form state preserved for retry |
| Status update conflict | updateCycle() checks current status | Only valid transitions allowed (e.g., cannot go from Harvested back to Planted) |
| Sync push failure for input | _pushPendingInputs() catches per-input | markInputAsFailed() sets error; retry on next sync cycle |
Cross-References
- WF-PROD-01 — creates the production cycle that this workflow adds inputs to
- WF-PROD-03 — harvest recording is the next lifecycle step after growth monitoring
- WF-SYNC-03 —
_pushPendingInputs()syncs inputs viaPOST /api/productions/{id}/inputs
WF-PROD-03: Harvest Recording
| Property | Value |
|---|---|
| Workflow ID | WF-PROD-03 |
| Trigger | Agent taps "Declare Harvest" on production cycle detail screen |
| Frequency | Typically once per production cycle at harvest time |
| Offline Support | Full — harvest saved to local SQLite, status updated locally, synced in background |
Workflow Diagram
graph TD
T((Agent taps\n"Declare Harvest")) --> SHOW_SHEET["Show harvest\nbottom sheet"]
SHOW_SHEET --> FILL_HARVEST["Agent fills:\n- Harvest date\n- Quantity\n- Unit\n- Quality grade\n- Notes"]
FILL_HARVEST --> VALIDATE{Form\nvalid?}
VALIDATE -->|no| SHOW_ERRORS[Show validation\nerrors]
SHOW_ERRORS --> FILL_HARVEST
VALIDATE -->|yes| CREATE_HARVEST["Create\nLocalProductionHarvest"]
CREATE_HARVEST --> SAVE_HARVEST[[ProductionCycleLocalService\n.saveHarvest()\nsync_status=pending]]
SAVE_HARVEST --> UPDATE_STATUS["ProductionCycleLocalService\n.updateCycle()\nstatus: Harvested"]
UPDATE_STATUS --> SAVE_STATUS[[Update cycle in SQLite\nsync_status=pending]]
SAVE_STATUS --> REFRESH[Refresh\ndetail screen]
REFRESH -.->|background| PUSH_HARVEST["ProductionCycleSyncService\n._pushPendingHarvests()"]
PUSH_HARVEST --> API_CALL["POST /api/productions\n/{id}/harvest"]
API_CALL --> MARK_SYNCED[[markHarvestAsSynced()]]
PUSH_HARVEST -.->|error| MARK_FAILED[[markHarvestAsFailed()\nincrement retryCount]]
Node Descriptions
| Node | Type | Implementation |
|---|---|---|
| Show harvest bottom sheet | Action | Modal bottom sheet in production_cycle_detail_screen.dart |
| Harvest form | Action | Fields: harvest date picker, quantity (numeric), unit dropdown, quality grade (A/B/C/D), notes text field |
| Create LocalProductionHarvest | Action | Constructor with productionCycleId, serverProductionCycleId, harvestDate, quantity, unitId, qualityGrade, notes |
| Save harvest to SQLite | Database | ProductionCycleLocalService.saveHarvest(LocalProductionHarvest) — inserts into production_harvests table |
| Update cycle status | Database | ProductionCycleLocalService.updateCycle() — sets status='Harvested', sync_status='pending' |
| Push pending harvests | Action | ProductionCycleSyncService._pushPendingHarvests() — iterates pending harvests |
| API call | Action | ProductionCycleService.apiDeclareHarvest(serverId, payload) -> POST /api/productions/{id}/harvest |
| Mark synced/failed | Database | markHarvestAsSynced() or markHarvestAsFailed() with error message and retry count |
Data Transformations
| Step | Input | Output | Transform |
|---|---|---|---|
| Bottom sheet form -> Local harvest | Form fields | LocalProductionHarvest | Maps harvest date, quantity, unitId, qualityGrade, notes; links to productionCycleId (local) and serverProductionCycleId |
| Local harvest -> API payload | LocalProductionHarvest | Map<String, dynamic> | toCreatePayload() builds DTO for POST /api/productions/{id}/harvest |
| Status transition | Current cycle status | 'Harvested' | Cycle status updated from Growing/Ready to Harvested; sync_status set to pending |
Error Handling
| Error | Handler | Recovery |
|---|---|---|
| Harvest save failure | saveHarvest() returns null | Snackbar error; bottom sheet stays open for retry |
| Duplicate harvest declaration | Backend validation | API returns 409 Conflict; sync marks as failed with clear error |
| Server unreachable | Push fails silently | Harvest remains in local SQLite with sync_status=pending; retried on next sync |
| Invalid quantity/date | Form validation | Inline validation errors before save attempt |
Cross-References
- WF-PROD-02 — growth monitoring precedes harvest in the lifecycle
- WF-PROD-01 — the production cycle being harvested was created in this workflow
- WF-SYNC-03 —
_pushPendingHarvests()syncs viaPOST /api/productions/{id}/harvest
WF-PROD-04: Production Cycle Review & History
| Property | Value |
|---|---|
| Workflow ID | WF-PROD-04 |
| Trigger | Agent or admin opens "Production History" from menu; admin reviews cycles on detail screen |
| Frequency | On-demand |
| Offline Support | Full — history reads from local SQLite; review actions saved locally and synced |
Workflow Diagram
graph TD
T((Agent/Admin taps\n"Production History")) --> LOAD_LIST["ProductionCycleService\n.getProductionCyclesForCurrentUser()"]
LOAD_LIST --> SCOPE_CHECK{User scope?}
SCOPE_CHECK -->|admin| ALL_CYCLES[[Read all cycles\nfrom SQLite]]
SCOPE_CHECK -->|agent| AGENT_CYCLES[["Filter cycles by\nagent's farmers\nor created_by_id"]]
ALL_CYCLES --> SHOW_LIST[Show\nProductionCycleHistoryScreen]
AGENT_CYCLES --> SHOW_LIST
SHOW_LIST --> FILTER_STATUS["Status filter chips:\nAll, Planned, Planted,\nHarvested, Cancelled"]
SHOW_LIST --> SEARCH["Search by crop name\nor farmer name"]
SHOW_LIST --> SYNC_COUNTS["Display synced/pending\ncounts"]
FILTER_STATUS --> FILTERED_LIST[Filtered list\nof cycle cards]
SEARCH --> FILTERED_LIST
FILTERED_LIST --> TAP_CARD[Agent/Admin taps\na cycle card]
TAP_CARD --> LOAD_DETAIL["ProductionCycleService\n.getProductionDetail(id)"]
LOAD_DETAIL --> READ_DETAIL[[Read cycle +\ninputs + harvests\nfrom SQLite]]
READ_DETAIL --> SHOW_DETAIL[Show\nProductionCycleDetailScreen]
SHOW_DETAIL --> IS_ADMIN{User is\nadmin?}
IS_ADMIN -->|yes| REVIEW_ACTIONS["Admin review actions:\n- Lock/Unlock cycle\n- Add review notes\n- Approve/Flag"]
IS_ADMIN -->|no| AGENT_ACTIONS["Agent actions:\n- Edit cycle\n- Add input\n- Declare harvest\n- Cancel cycle"]
REVIEW_ACTIONS --> SAVE_REVIEW[[Update cycle in SQLite\nisLocked, needsReview\nsync_status=pending]]
AGENT_ACTIONS --> EDIT_CYCLE{Edit cycle?}
EDIT_CYCLE -->|yes| NAV_EDIT["Navigate to\nCaptureScreen\nwith editingDetail"]
EDIT_CYCLE -->|no| STATUS_ACTION{Status action?}
STATUS_ACTION -->|cancel| CANCEL["Update status:\nCancelled"]
STATUS_ACTION -->|other| OTHER_WF[See WF-PROD-02\nor WF-PROD-03]
CANCEL --> SAVE_STATUS[[Update cycle in SQLite\nstatus: Cancelled\nsync_status=pending]]
Node Descriptions
| Node | Type | Implementation |
|---|---|---|
| Load cycles for current user | Data | ProductionCycleService.getProductionCyclesForCurrentUser() — admin sees all, agent sees own farmers |
| User scope check | Decision | AgentContext.instance.getIdentity() determines admin vs agent; _filterCyclesForCurrentUser() applies scoping |
| Read all/filtered cycles | Database | ProductionCycleLocalService.getAllCycles() or filtered by getCyclesByAgent(agentId) |
| Status filter chips | Action | ProductionCycleHistoryScreen — filters: All, Planned, Planted, Harvested, Cancelled |
| Search | Action | Client-side filter on cropName and farmerName fields |
| Sync counts display | Action | Shows count of synced vs pending records from local database |
| Load detail | Data | ProductionCycleService.getProductionDetail(id) -> getCycleByAnyId() + getInputsByCycle() + getHarvestsByCycle() |
| Admin review actions | Action | Lock/unlock toggle, review notes text field, approve/flag in production_cycle_detail_screen.dart |
| Edit cycle navigation | Action | Navigator.pushNamed(AppRoutes.productionCycleCapture, arguments: editingDetail) |
| Cancel cycle | Action | ProductionCycleLocalService.updateCycle() with status: 'Cancelled', sync_status: 'pending' |
Data Transformations
| Step | Input | Output | Transform |
|---|---|---|---|
| Local cycles -> List items | List<LocalProductionCycle> | List<ProductionCycleListItem> | cycle.toApiJson() then ProductionCycleListItem.fromJson() — normalizes local model to DTO |
| Detail assembly | Cycle + inputs + harvests | ProductionDetail | _buildProductionDetail() in service combines cycle, inputs list, and harvests list into composite DTO |
| Review update | Admin form fields | Updated LocalProductionCycle | Sets isLocked, needsReview flags; sync_status=pending |
| Status cancellation | User action | Updated LocalProductionCycle | Sets status='Cancelled'; sync_status=pending |
Error Handling
| Error | Handler | Recovery |
|---|---|---|
| No cycles in local DB | getAllCycles() returns empty | Empty state UI with message and refresh option |
| Cycle not found by ID | getCycleByAnyId() returns null | Error shown on detail screen; user navigates back |
| Edit conflict (locked cycle) | isLocked flag check | Edit button hidden/disabled when cycle is locked by admin |
| Status transition invalid | Business logic in detail screen | Only valid status transitions exposed as action buttons |
Cross-References
- WF-PROD-01 — cycles listed here were created in the capture workflow
- WF-PROD-02 / WF-PROD-03 — detail screen provides entry points to add inputs and declare harvests
- WF-SYNC-03 — status changes and review actions sync via
pushPendingCycles() - WF-FARM-01 — farmer names displayed on cycle cards come from the local farmer cache
WF-PROD-05: Production Outlook (Analytics Dashboard)
| Property | Value |
|---|---|
| Workflow ID | WF-PROD-05 |
| Trigger | Admin/Manager navigates to Production Outlook screen |
| Frequency | On-demand |
| Offline Support | Partial — API-first with SharedPreferences cache fallback; no local SQLite aggregation |
Workflow Diagram
graph TD
T((Admin opens\nProduction Outlook)) --> CALL_API["ProductionCycleService\n.getProductionOutlook()"]
CALL_API --> API_REQUEST["GET /api/productions\n/outlook"]
API_REQUEST --> API_RESULT{API\nresponse?}
API_RESULT -->|200 OK| CACHE_RESPONSE["Cache response in\nSharedPreferences\nproduction_outlook_cache_v1"]
API_RESULT -->|error| LOAD_CACHE{Cached outlook\navailable?}
CACHE_RESPONSE --> PARSE["Parse\nProductionOutlook\n.fromJson()"]
LOAD_CACHE -->|yes| PARSE_CACHED["Parse cached\nProductionOutlook"]
LOAD_CACHE -->|no| SHOW_ERROR[Show error\nstate]
PARSE --> SHOW_DASHBOARD[Show\nProductionOutlookScreen]
PARSE_CACHED --> SHOW_DASHBOARD
SHOW_DASHBOARD --> KPI_GRID["KPI Grid:\n- Total cycles\n- Total area (ha)\n- Estimated yield\n- Harvested quantity"]
SHOW_DASHBOARD --> STATUS_CHART["Status Distribution\nPie Chart\n(fl_chart)"]
SHOW_DASHBOARD --> YIELD_CHART["Yield by Crop\nBar Chart\n(fl_chart)"]
SHOW_DASHBOARD --> GEO_SECTION["Geography Section:\n- By province\n- By territory"]
Node Descriptions
| Node | Type | Implementation |
|---|---|---|
| Call outlook API | Action | ProductionCycleService.getProductionOutlook() — API-first, cache fallback |
| GET /api/productions/outlook | Data | ApiClient.instance.get('/api/productions/outlook') — backend aggregates across all production cycles |
| Cache response | Action | _cacheProductionOutlook(payload) — stores JSON in SharedPreferences key production_outlook_cache_v1 |
| Load cache fallback | Decision | _getCachedProductionOutlook() — reads from SharedPreferences when API unavailable |
| Parse ProductionOutlook | Action | ProductionOutlook.fromJson(payload) — includes OutlookByCrop, OutlookByGeography, OutlookByStatus |
| KPI Grid | Output | Four metric cards: total cycles, total area, estimated yield, harvested quantity |
| Status Distribution Pie | Output | fl_chart PieChart widget showing Planned/Planted/Growing/Ready/Harvested/Cancelled distribution |
| Yield by Crop Bar | Output | fl_chart BarChart widget showing estimated vs actual yield per crop |
| Geography Section | Output | Province and territory breakdown with cycle counts and area totals |
Data Transformations
| Step | Input | Output | Transform |
|---|---|---|---|
| API response -> DTO | Map<String, dynamic> | ProductionOutlook | ProductionOutlook.fromJson() parses top-level KPIs + nested OutlookByCrop, OutlookByGeography, OutlookByStatus lists |
| DTO -> Charts | ProductionOutlook | Chart data | OutlookByStatus list maps to pie chart sections; OutlookByCrop maps to bar chart groups |
| Response -> Cache | Map<String, dynamic> | JSON string | Serialized to SharedPreferences for offline fallback |
Error Handling
| Error | Handler | Recovery |
|---|---|---|
| API unreachable | try/catch in getProductionOutlook() | Falls back to _getCachedProductionOutlook() from SharedPreferences |
| Cache miss + API failure | Both paths return null | Error state shown with retry button |
| Invalid API response | response.data is! Map<String, dynamic> check | Falls back to cache; logs error |
| Chart rendering error | fl_chart error handling | Graceful degradation — individual charts may show error state without crashing screen |
Cross-References
- WF-PROD-01 through WF-PROD-03 — the data aggregated here originates from cycle capture, input recording, and harvest declaration
- Backend
ProductionsController.GetOutlook()—GET /api/productions/outlookwithPermission.ViewProductionsauthorization
Sync Architecture (Cross-Cutting)
The production cycle sync layer is documented in detail under WF-SYNC-03, but the key push/pull mechanics specific to this domain are summarized here.
Push Flow (ProductionCycleSyncService.pushPendingCycles())
graph TD
START((SyncManager\ntriggers push)) --> PUSH_VISITS["_pushPendingVisits()\nPush farmer visits first"]
PUSH_VISITS --> GET_PENDING["_local.getRetryableCyclesByPriority()\nOrdered by retry count ASC,\ncreated_at ASC"]
GET_PENDING --> LOOP{More pending\ncycles?}
LOOP -->|yes| RESOLVE_FARMER["_resolveServerFarmerId()\nMap local farmer ID\nto server GUID"]
RESOLVE_FARMER --> HAS_SERVER_ID{Cycle has\nserver ID?}
HAS_SERVER_ID -->|yes| UPDATE["_service.apiUpdateProductionCycle()\nPUT /api/productions/{id}"]
HAS_SERVER_ID -->|no| DEDUP{Duplicate\ndetection}
DEDUP -->|duplicate found| MERGE["Mark local as synced\nwith existing server ID"]
DEDUP -->|no duplicate| CREATE["_service.apiCreateProductionCycle()\nPOST /api/productions"]
UPDATE --> MARK_SYNCED[[markAsSynced()]]
MERGE --> MARK_SYNCED
CREATE --> MARK_SYNCED
MARK_SYNCED --> LOOP
UPDATE -.->|error| MARK_FAILED[[markAsFailed()\nincrementRetryCount()]]
CREATE -.->|error| MARK_FAILED
MARK_FAILED --> LOOP
LOOP -->|no| PUSH_INPUTS["_pushPendingInputs()"]
PUSH_INPUTS --> PUSH_HARVESTS["_pushPendingHarvests()"]
PUSH_HARVESTS --> PUSH_PHOTOS["_pushPendingPlantingPhotos()"]
PUSH_PHOTOS --> UPDATE_SYNC_TIME["UnifiedSyncStatusService\n.updateSyncTime()"]
Pull Flow (ProductionCycleSyncService.pullCyclesForFarmers())
graph TD
START((SyncManager\ntriggers pull)) --> FOR_EACH_FARMER["Iterate farmer\nserver IDs"]
FOR_EACH_FARMER --> API_FETCH["GET /api/productions\n?farmerId={id}"]
API_FETCH --> UPSERT[[ProductionCycleLocalService\n.upsertManyFromApi()\nConflict detection]]
UPSERT --> PULL_DETAIL["Pull detail for\neach new cycle"]
PULL_DETAIL --> PULL_INPUTS[[Upsert inputs\nfrom API]]
PULL_INPUTS --> PULL_HARVESTS[[Upsert harvests\nfrom API]]
PULL_HARVESTS --> FOR_EACH_FARMER
Status Normalization
The sync layer normalizes production cycle statuses using _syncableStatusAliases to handle multilingual and enum-style values:
| Aliases | Normalized Value |
|---|---|
planned, planified, planifie, planifie, 0 | Planned |
planted, plante, plante, 1 | Planted |
growing, en croissance, 2 | Growing |
ready, pret, pret, 3 | Ready |
harvested, harvest, recolte, recolte, 4 | Harvested |
cancelled, canceled, annule, annule, 5 | Cancelled |
Backend API Surface
All endpoints are under api/productions in ProductionsController.cs.
| Method | Endpoint | Permission | Purpose |
|---|---|---|---|
GET | / | ViewProductions | Paginated list with filters (farmerId, cropId, status, province, territory, dateRange) |
GET | /export | ViewProductions | Excel export of filtered production cycles |
GET | /farmer/{farmerId} | ViewProductions | Cycles for a specific farmer |
GET | /outlook | ViewProductions | Aggregated analytics (KPIs, by-crop, by-geography, by-status) |
GET | /sync?lastSyncAt= | ViewProductions | Delta sync — returns records modified since timestamp |
GET | /suggestions/delivery | ViewProductions | Delivery suggestions for harvested cycles |
GET | /{id} | ViewProductions | Full detail with inputs, harvests, audit trail |
POST | / | CreateProductions | Create new production cycle |
POST | /{id}/photo | CreateProductions | Upload planting photo (chunked upload) |
PUT | /{id} | EditProductions | Update production cycle |
PATCH | /{id}/status | EditProductions | Update status (Planned/Planted/Growing/Ready/Harvested/Cancelled) |
DELETE | /{id} | DeleteProductions | Soft delete production cycle |
POST | /{id}/harvest | CreateProductions | Declare harvest with quantity, quality grade |
GET | /{id}/inputs | ViewProductions | List inputs for a cycle |
POST | /{id}/inputs | CreateProductions | Add production input |
PUT | /{id}/inputs/{inputId} | EditProductions | Update production input |
DELETE | /{id}/inputs/{inputId} | DeleteProductions | Remove production input |
SQLite Tables
The local storage layer uses four tables managed by DatabaseHelper:
| Table | Constant | Key Columns |
|---|---|---|
farmer_visits | DatabaseHelper.tableFarmerVisits | id, farmer_id, agent_id, server_id, started_at, ended_at, status, sync_status, sync_error, retry_count |
production_cycles | DatabaseHelper.tableProductionCycles | id, farmer_id, crop_id, visit_id, area_planted, planting_date, status, is_draft, is_locked, needs_review, sync_status, server_id, sync_error, retry_count |
production_inputs | DatabaseHelper.tableProductionInputs | id, production_cycle_id, server_production_cycle_id, input_type, fertilizer_type_id, pesticide_type_id, seed_source_id, quantity, unit_id, applied_date, sync_status |
production_harvests | DatabaseHelper.tableProductionHarvests | id, production_cycle_id, server_production_cycle_id, harvest_date, quantity, unit_id, quality_grade, notes, sync_status |