04 — Client & Order Lifecycle
Domain Overview
The Client & Order Lifecycle domain covers the end-to-end journey from client onboarding through order fulfillment. In the Almafrica platform, clients are business buyers (not farmers) who purchase agricultural produce. The lifecycle begins when a field agent registers a new client via the mobile app's multi-step form, continues through crop demand capture and admin approval, and culminates in order creation, fulfillment, and delivery.
This domain is deeply offline-first: client registration, crop demand capture, and order creation all save to local SQLite first, then sync to the backend via the queue-based sync engine. Documents use chunked uploads for reliability on poor networks.
Key Actors
| Actor | Actions |
|---|---|
| Field Agent | Creates clients, captures crop demands, manages client documents |
| Admin/Manager | Approves/rejects clients, creates and processes orders |
| CSA (Customer Service Agent) | Views orders, manages delivery logistics |
| Client | Views their own orders (restricted access via JWT ClientId claim) |
Domain Summary Diagram
graph TB
subgraph "Client Onboarding"
CO_START((Agent taps<br/>Add Client)) --> CO_STEP1[Step 1: Client Info]
CO_STEP1 --> CO_STEP2[Step 2: Crop Demands]
CO_STEP2 --> CO_DOCS[Step 3: Documents]
CO_DOCS --> CO_DRAFT[[Save Draft to SQLite]]
CO_DRAFT --> CO_SUBMIT[Submit & Queue Sync]
end
subgraph "Approval"
CO_SUBMIT --> AP_REVIEW{Admin Review}
AP_REVIEW -->|Approve| AP_APPROVED[Status: Approved]
AP_REVIEW -->|Reject| AP_REJECTED[Status: Rejected]
end
subgraph "Demand & Orders"
AP_APPROVED --> CD_ADD[Add Crop Demands]
AP_APPROVED --> ORD_CREATE[Create Order]
ORD_CREATE --> ORD_FULFILL[Fulfill Order]
ORD_FULFILL --> ORD_DELIVER[Deliver]
end
subgraph "Documents"
CO_DOCS --> DOC_LOCAL[[Local File Storage]]
DOC_LOCAL --> DOC_UPLOAD[Chunked Upload to CDN]
end
style CO_START fill:#e1f5fe
style AP_APPROVED fill:#e8f5e9
style AP_REJECTED fill:#ffebee
Workflows
WF-CLI-01: Client Onboarding (Multi-Step)
Trigger: Agent taps "Add Client" button on AgentHomeScreen
Frequency: On-demand (per client registration)
Offline Support: Yes -- full offline creation with draft persistence and queue-based sync
Workflow Diagram
graph TD
TRIGGER((Agent taps<br/>Add Client)) --> CHECK_DRAFT{Draft exists?}
CHECK_DRAFT -->|Yes| RESTORE[Restore draft from<br/>SharedPreferences]
CHECK_DRAFT -->|No| FRESH[Initialize empty<br/>ClientFormData]
RESTORE --> STEP1[Step 1: Client Info<br/>ClientInfoStep]
FRESH --> STEP1
STEP1 --> VALIDATE1{Step 1 valid?<br/>isStep1Valid}
VALIDATE1 -->|No| STEP1
VALIDATE1 -->|Yes| AUTO_SAVE1[Auto-save draft<br/>ClientFormDraftService.saveDraft]
AUTO_SAVE1 --> STEP2[Step 2: Crop Demands<br/>CropDemandStep]
STEP2 --> VALIDATE2{Step 2 valid?<br/>isStep2Valid}
VALIDATE2 -->|No| STEP2
VALIDATE2 -->|Yes| AUTO_SAVE2[Auto-save draft]
AUTO_SAVE2 --> DOCS[Documents Section<br/>ClientDocumentsFormSection]
DOCS --> SUBMIT{Tap Submit}
SUBMIT --> SAVE_LOCAL[[ClientDraftService<br/>saveClientLocally]]
SAVE_LOCAL --> QUEUE[SyncQueueService<br/>enqueueEntity]
QUEUE --> SAVE_DEMANDS[[ClientCropDemandDraftService<br/>saveDrafts]]
SAVE_DEMANDS --> UPLOAD_DOCS[ClientDocumentService<br/>uploadDocument per doc]
UPLOAD_DOCS --> CLEAR[ClientFormDraftService<br/>clearDraft]
CLEAR --> TRIGGER_SYNC[SyncManager<br/>requestSubmitSync]
TRIGGER_SYNC --> ONLINE{Online?}
ONLINE -->|Yes| API_CREATE[POST /api/clients<br/>ClientsController.Create]
ONLINE -->|No| PENDING>Status: Pending Sync<br/>Will retry on reconnect]
API_CREATE --> RECONCILE[[Reconcile local<br/>with server ID]]
RECONCILE --> DONE>Client Created<br/>Status: PendingApproval]
API_CREATE -.->|Error| RETRY[Mark failed<br/>retry on next sync]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | AddClientScreenMultiStep | Agent initiates client registration | N/A |
| 2 | IF | ClientFormDraftService.hasDraft() | Check for existing draft in SharedPreferences | Returns false on error |
| 3 | Function | ClientFormDraftService.loadDraft() | Restore form data, profile photo, documents from draft | Falls back to empty form |
| 4 | Form | ClientInfoStep | Collects: company name, contact person/role, phone, email, province/territory/village, address, GPS coords, years in operation, preferred contact method, business type, registration number, collection point, payment method, bank details, currency, profile photo | Field-level validation, GPS auto-capture via LocationService |
| 5 | Validation | ClientFormData.isStep1Valid() | Requires: company name, contact name, role, phone, province, address, years in operation, GPS (non-zero), preferred contact | Blocks progression to Step 2 |
| 6 | Function | ClientFormDraftService.saveDraft() | Persists form state to SharedPreferences as JSON (schema version 2) | Logs error, continues |
| 7 | Form | CropDemandStep | Collects per-crop: crop selection, sourcing origin, provinces/countries, purchase frequency, avg volume, price range, purchase priorities, common issues, notes | Separate validation form key for add-crop section |
| 8 | Form | ClientDocumentsFormSection | File picker (PDF, JPG, PNG, GIF), max 10MB, document type selection dialog | Size validation, type restriction |
| 9 | Database | ClientDraftService.saveClientLocally() | INSERT into clients table with UUID, sync_status=pending, approval_status=PendingApproval | Schema-aware column detection cached |
| 10 | Queue | SyncQueueService.enqueueEntity() | Enqueues client entity for background sync | Persists to sync_queue table |
| 11 | Database | ClientCropDemandDraftService.saveDrafts() | Batch INSERT into client_crop_demand_drafts with client local ID | Batch commit for performance |
| 12 | Function | ClientDocumentService.uploadDocument() | Copies file to local storage, creates LocalClientDocument, attempts immediate upload if online | Falls back to pending status |
| 13 | API | POST /api/clients | ClientsController.Create -- validates via CreateClientRequestDtoValidator, creates Identity user, creates Client entity | Returns 400 with validation errors |
| 14 | Function | ClientService._reconcileAfterSync() | Updates local record with server ID, links crop demand drafts to server client ID | Handles ID mapping |
Data Transformations
- Input:
ClientFormData(company name, contact person, GPS, crop demands, documents) - Processing:
ClientFormData.toMap()serializes to flat JSON for draft;toApiPayload()on crop demands strips local-only fields;ClientDraftServicebuildsLocalClientmodel from form data - Output:
LocalClientrow in SQLite (offline) orClientResponseDTO from backend (online)
Error Handling
- Draft corruption:
_normalizeDraftShape()handles schema migration between draft versions, normalizespaymentAccountNumber/paymentAccountaliasing, and repairslocalPath/filePathinconsistencies in document entries - GPS failure: Falls back to manual coordinate entry if
LocationServicecannot acquire position - Sync failure: Client remains in local SQLite with
sync_status=failed;SyncManagerretries on next connectivity event - Server validation error: HTTP 400 response stored in
sync_errorcolumn; client shown to agent with error badge
Cross-References
- Triggers: WF-CLI-02 (crop demands can be added inline), WF-CLI-06 (documents uploaded during onboarding)
- Triggered by: Agent home screen navigation
- Related: WF-SYNC-01 (sync engine processes the queue)
WF-CLI-02: Client Crop Demand Creation
Trigger: Agent adds crop demand during client onboarding (Step 2) or via standalone ClientCropDemandFormScreen
Frequency: On-demand (per crop demand entry)
Offline Support: Yes -- saved as local draft, synced via CropDemandsSync
Workflow Diagram
graph TD
TRIGGER((Agent adds<br/>Crop Demand)) --> CONTEXT{During onboarding<br/>or standalone?}
CONTEXT -->|Onboarding| INLINE[CropDemandStep<br/>inline form]
CONTEXT -->|Standalone| STANDALONE[ClientCropDemandFormScreen<br/>full form]
INLINE --> SELECT_CROP[CropSelectionWidget<br/>category + crop picker]
STANDALONE --> SELECT_CROP
SELECT_CROP --> SOURCING[Sourcing Origin<br/>Local / Imported / Both]
SOURCING --> LOCAL_CHECK{Local sourcing?}
LOCAL_CHECK -->|Yes| PROVINCES[Select sourcing<br/>provinces]
LOCAL_CHECK -->|No| IMPORT_COUNTRIES[Select import<br/>countries]
PROVINCES --> VOLUME[Average Volume<br/>value + unit]
IMPORT_COUNTRIES --> VOLUME
VOLUME --> FREQUENCY[Purchase Frequency<br/>Weekly/Biweekly/Monthly/Quarterly]
FREQUENCY --> PRICE_RANGE[Price Range<br/>min/max + unit + currency]
PRICE_RANGE --> PRIORITIES[Purchase Priorities<br/>multi-select from config]
PRIORITIES --> ISSUES[Common Issues<br/>multi-select from config]
ISSUES --> NOTES[Notes field]
NOTES --> VALIDATE{isValid?}
VALIDATE -->|No| SELECT_CROP
VALIDATE -->|Yes| RESOLVE[ClientMasterDataResolver<br/>resolve all IDs]
RESOLVE --> RESOLVE_OK{All IDs resolved?}
RESOLVE_OK -->|No| ERROR>Missing master data<br/>Sync and retry]
RESOLVE_OK -->|Yes| SAVE_LOCAL[[ClientCropDemandDraftService<br/>saveDraftWithPhoto]]
SAVE_LOCAL --> TRIGGER_SYNC[SyncManager<br/>requestSubmitSync]
TRIGGER_SYNC --> SYNC{Online + client synced?}
SYNC -->|Yes| API_POST[POST /api/clients/{id}<br/>/crop-demands]
SYNC -->|No| QUEUED>Queued for sync<br/>syncStatus: pending]
API_POST --> MARK_SYNCED[[markAsSynced<br/>or keep for photo upload]]
API_POST -.->|Error| MARK_FAILED[[markAsFailed<br/>stored in sync_error]]
MARK_SYNCED --> PHOTO{Pending photo?}
PHOTO -->|Yes| PHOTO_UPLOAD[Upload photo<br/>to CDN]
PHOTO -->|No| DONE>Crop Demand<br/>Created]
PHOTO_UPLOAD --> DONE
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | CropDemandStep / ClientCropDemandFormScreen | User initiates crop demand entry | Separate form key prevents validation conflict with main form |
| 2 | Form | CropSelectionWidget | Category-filtered crop picker from cached master data | Falls back to text search |
| 3 | Function | ClientMasterDataResolver | Resolves local/display IDs to server GUIDs for: measurement units, currencies, countries, purchase priorities, common issues | Throws if critical IDs missing |
| 4 | Database | ClientCropDemandDraftService.saveDraftWithPhoto() | INSERT into client_crop_demand_drafts with UUID, payload JSON, optional pending photo path | Returns null on error |
| 5 | API | POST /api/clients/{clientId}/crop-demands | ClientCropDemandService.CreateClientCropDemandAsync -- backend derives cropCategoryId from crop entity | 400 on validation failure |
| 6 | Function | ClientCropDemandDraftService.markAsSynced() | Deletes draft if no pending photo; keeps draft with server ID if photo upload needed | Handles photo lifecycle |
| 7 | API | POST /api/clients/{id}/crop-demands/{id}/photo | IClientCropDemandService.UpdateCropDemandPhotoAsync | Retried by CropDemandsSync.uploadPendingPhotos() |
Data Transformations
- Input:
ClientCropDemandData(cropId, sourcingOrigin, provinces/countries, frequency, volume, price range, priorities, issues, notes) - Processing:
ClientCropDemandData.toApiPayload()stripscropCategoryId(backend derives it);ClientMasterDataResolvermaps display values to server GUIDs;_mergeServerDemandsWithPendingDrafts()overlays local edits on cached server data - Output:
LocalClientCropDemandrow inclient_crop_demand_draftstable;ClientCropDemandDtofrom API
Error Handling
- Master data missing: Throws descriptive exception (e.g., "Missing measurement unit ... Sync master data and retry") -- UI shows error message
- 403 Forbidden: Client may have been reassigned; silently skips pull, returns cached data
- Cache invalidation:
_invalidatedClientIdsset forces fresh read after local update, bypassing stale cache - Offline merge:
_mergeServerDemandsWithPendingDrafts()deduplicates byserverDemandId, overlaying local pending edits on top of cached server data
Cross-References
- Triggers: WF-CLI-06 (photo upload for crop demand)
- Triggered by: WF-CLI-01 (inline during onboarding), standalone from client details screen
- Related: WF-SYNC-03 (crop demand sync via
CropDemandsSync)
WF-CLI-03: Client Approval Workflow
Trigger: Admin reviews pending client on web dashboard or via API Frequency: Event-driven (per client submission) Offline Support: No -- approval requires backend connectivity
Workflow Diagram
graph TD
TRIGGER((Admin reviews<br/>pending client)) --> FETCH[GET /api/clients/{id}<br/>ClientsController.GetById]
FETCH --> REVIEW{Decision?}
REVIEW -->|Approve| APPROVE[POST /api/clients/{id}/approve<br/>ClientsController.ApproveClient]
REVIEW -->|Reject| REJECT[POST /api/clients/{id}/reject<br/>ClientsController.RejectClient]
APPROVE --> AUDIT1[AuditScopeFactory<br/>Begin approve action]
AUDIT1 --> FIND_CLIENT1[[context.Clients.FindAsync]]
FIND_CLIENT1 --> CLIENT_FOUND1{Client found?}
CLIENT_FOUND1 -->|No| NOT_FOUND1>404: Client not found]
CLIENT_FOUND1 -->|Yes| IDENTIFY1[CurrentUserService<br/>GetAuthUserId]
IDENTIFY1 --> LOOKUP_ADMIN1[UserManager<br/>FindByIdAsync]
LOOKUP_ADMIN1 --> DOMAIN_APPROVE[client.Approve<br/>authUserId]
DOMAIN_APPROVE --> SAVE1[[context.SaveChangesAsync]]
SAVE1 --> LOG1[Logger: Client approved<br/>by admin email]
LOG1 --> SUCCESS1>Result.Success<br/>Status: Approved]
REJECT --> AUDIT2[AuditScopeFactory<br/>Begin reject action]
AUDIT2 --> FIND_CLIENT2[[context.Clients.FindAsync]]
FIND_CLIENT2 --> CLIENT_FOUND2{Client found?}
CLIENT_FOUND2 -->|No| NOT_FOUND2>404: Client not found]
CLIENT_FOUND2 -->|Yes| IDENTIFY2[CurrentUserService<br/>GetAuthUserId]
IDENTIFY2 --> LOOKUP_ADMIN2[UserManager<br/>FindByIdAsync]
LOOKUP_ADMIN2 --> VALIDATE_REASON{Reason provided?}
VALIDATE_REASON -->|No| REASON_ERROR>400: Reason required]
VALIDATE_REASON -->|Yes| DOMAIN_REJECT[client.Reject<br/>authUserId + reason]
DOMAIN_REJECT --> SAVE2[[context.SaveChangesAsync]]
SAVE2 --> LOG2[Logger: Client rejected<br/>with reason]
LOG2 --> SUCCESS2>Result.Success<br/>Status: Rejected]
SUCCESS1 --> MOBILE_SYNC[Mobile sync pulls<br/>updated approval status]
SUCCESS2 --> MOBILE_SYNC
MOBILE_SYNC --> MOBILE_DISPLAY[Agent sees status<br/>change on client list]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | Admin Dashboard / ClientsController | Admin initiates review | N/A |
| 2 | API | GET /api/clients/{id} | Fetches full client details with navigation properties | 404 if not found |
| 3 | API | POST /api/clients/{id}/approve | ClientsController.ApproveClient delegates to IClientApprovalService | 403 if not authorized |
| 4 | API | POST /api/clients/{id}/reject | ClientsController.RejectClient requires reason in body | 400 if reason missing |
| 5 | Function | ClientApprovalService.ApproveClientAsync() | Wraps in audit scope, calls client.Approve(authUserId) domain method, persists | Result.Failure on not found |
| 6 | Function | ClientApprovalService.RejectClientAsync() | Validates reason, calls client.Reject(authUserId, reason) domain method | ArgumentException caught and returned as failure |
| 7 | Database | context.SaveChangesAsync() | Persists status change to PostgreSQL | Transaction rollback on failure |
| 8 | Sync | Mobile sync pull | Agent's SyncManager pulls updated client status on next sync cycle | Status reflected in local approval_status column |
Data Transformations
- Input: Client ID + admin decision (approve or reject with reason)
- Processing:
Client.Approve(authUserId)/Client.Reject(authUserId, reason)domain methods mutate entity state, settingApprovalStatus,ApprovedByUserId,ApprovalDate(or rejection equivalents) - Output: Updated
Cliententity in PostgreSQL; status propagated to mobile via next sync
Error Handling
- Concurrent modification: EF Core concurrency tokens prevent conflicting updates
- Invalid state transition: Domain entity validates current state before allowing approve/reject
- Mobile staleness: Agent may see stale
PendingApprovaluntil next sync;ClientService._syncSingleClient()reconciles on pull - Rejected client editing:
ClientRemovedFromQueueNotificationalerts agent if client is locked post-approval
Cross-References
- Triggers: WF-CLI-04 (approved clients can have orders created)
- Triggered by: WF-CLI-01 (client submission creates pending approval)
- Related: WF-AUTH-01 (admin permissions checked via
[Authorize])
WF-CLI-04: Order Creation
Trigger: Admin/CSA creates order for a client via CreateOrderScreen
Frequency: On-demand (per order)
Offline Support: Yes -- local-first with sync queue
Workflow Diagram
graph TD
TRIGGER((Admin creates<br/>order)) --> SELECT_CLIENT[Select client<br/>from approved clients list]
SELECT_CLIENT --> ADD_ITEMS[Add Order Items<br/>OrderItemData per crop]
ADD_ITEMS --> ITEM_FORM[Per item: crop selection<br/>quantity + unit + price<br/>expected delivery date]
ITEM_FORM --> MORE_ITEMS{More items?}
MORE_ITEMS -->|Yes| ITEM_FORM
MORE_ITEMS -->|No| ORDER_DETAILS[Order Details<br/>delivery address + method<br/>logistics partner + currency<br/>notes]
ORDER_DETAILS --> SUBMIT{Submit Order}
SUBMIT --> BUILD_LOCAL[Build local Order object<br/>with LOCAL- prefix number]
BUILD_LOCAL --> CALC_TOTAL[Calculate totalAmount<br/>sum of qty * unitPrice]
CALC_TOTAL --> SAVE_LOCAL[[OrderLocalService<br/>saveOrderOffline<br/>sync_status: pending]]
SAVE_LOCAL --> ENQUEUE[[SyncQueueService<br/>enqueueEntity<br/>type: order, op: create]]
ENQUEUE --> ONLINE{Online?}
ONLINE -->|Yes| API_POST[POST /api/orders<br/>OrdersController.Create]
ONLINE -->|No| QUEUED>Order queued<br/>LOCAL-XXXXXXXX<br/>sync_status: pending]
API_POST --> STATUS{Response?}
STATUS -->|201| RECONCILE[[Reconcile local<br/>with server order]]
STATUS -->|4xx| MARK_FAILED[[markAsFailed<br/>sync_error stored]]
RECONCILE --> UPDATE_LOCAL[[Update server_id<br/>real order number<br/>move order items]]
UPDATE_LOCAL --> DELETE_OLD[[Delete old local<br/>ID row]]
DELETE_OLD --> DONE>Order Created<br/>status: Pending<br/>real order number]
MARK_FAILED -.-> RETRY[OrderSyncService<br/>retries on next sync]
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | CreateOrderScreen | User initiates order creation | N/A |
| 2 | Form | CreateOrderScreen | Collects: client, order items (crop, qty, unit, price, delivery date), delivery address, delivery method, logistics partner, currency, notes | Field validation |
| 3 | Function | OrderService.createOrder() | Builds Order object with LOCAL-{uuid} number, calculates total, generates UUIDs for items | N/A |
| 4 | Database | OrderLocalService.saveOrderOffline() | INSERT into orders + order_items tables with sync_status=pending | Returns null on error |
| 5 | Queue | SyncQueueService.enqueueEntity() | Enqueues with entityType=order, operation=create, priority 1 | Persisted to queue table |
| 6 | API | POST /api/orders | OrdersController.Create -- sets createdByUserId from JWT, delegates to IOrderService.CreateOrderAsync() | 400 on validation failure |
| 7 | Function | OrderService._reconcileCreatedOrder() | Saves server order, updates server_id on old local row, moves order items to server ID, deletes old local row | Handles ID mismatch |
| 8 | Sync | OrderSyncService.syncOrderQueueItem() | Background processor for queued creates/updates/deletes | Reconciles on success, throws on failure |
Data Transformations
- Input:
CreateOrderRequest(clientId, orderDate, expectedDeliveryDate, deliveryAddress, deliveryMethodId, logisticsPartnerId, currencyId, notes, list ofCreateOrderItemRequest) - Processing: Each
CreateOrderItemRequestspecifies eithercropId(existing crop) orcropTypeId(find-or-create); backend generates sequentialorderNumber; mobile assigns temporaryLOCAL-{uuid.substring(0,8)}number - Output:
Ordermodel with items in SQLite (offline) orOrderDtofrom API (online)
Error Handling
- Network timeout:
_isNetworkError()detects connection/send/receive timeouts; order stays queued - Server validation failure: Non-network
DioExceptiontriggersmarkAsFailed()with extracted error message - Duplicate submission: Queue processor de-duplicates by
entityId - Stale client ID:
_resolveServerId()maps local UUIDs to server IDs for clients created offline
Cross-References
- Triggers: WF-CLI-05 (order enters fulfillment pipeline)
- Triggered by: WF-CLI-03 (client must be approved to receive orders)
- Related: WF-STOCK-01 (stock checked during out-for-delivery transition)
WF-CLI-05: Order Fulfillment Flow
Trigger: Admin/CSA progresses order through status transitions Frequency: Event-driven (per status change) Offline Support: Yes -- optimistic local status update with sync queue
Workflow Diagram
graph TD
TRIGGER((Order status<br/>action)) --> CURRENT{Current Status}
CURRENT -->|Pending| CONFIRM[POST /api/orders/{id}/confirm<br/>OrderService.confirmOrder]
CURRENT -->|Confirmed| PROCESS[POST /api/orders/{id}/start-processing<br/>OrderService.startProcessing]
CURRENT -->|InProgress| OFD[POST /api/orders/{id}/out-for-delivery<br/>OrderService.markOutForDelivery]
CURRENT -->|OutForDelivery| DELIVER[POST /api/orders/{id}/delivered<br/>OrderService.markAsDelivered]
CURRENT -->|Any non-Delivered| CANCEL[POST /api/orders/{id}/cancel<br/>OrderService.cancelOrder]
CONFIRM --> OPTIMISTIC1[Update local order<br/>status=Confirmed<br/>sync_status=pending]
PROCESS --> OPTIMISTIC2[Update local order<br/>status=InProgress<br/>sync_status=pending]
OFD --> OPTIMISTIC3[Update local order<br/>status=OutForDelivery<br/>sync_status=pending]
DELIVER --> OPTIMISTIC4[Update local order<br/>status=Delivered<br/>sync_status=pending]
CANCEL --> OPTIMISTIC5[Update local order<br/>status=Cancelled<br/>cancellationReason stored]
OPTIMISTIC1 --> ENQUEUE1[[SyncQueueService<br/>orderStatusTransition<br/>action: confirm]]
OPTIMISTIC2 --> ENQUEUE2[[SyncQueueService<br/>orderStatusTransition<br/>action: start_processing]]
OPTIMISTIC3 --> ENQUEUE3[[SyncQueueService<br/>orderStatusTransition<br/>action: out_for_delivery]]
OPTIMISTIC4 --> ENQUEUE4[[SyncQueueService<br/>orderStatusTransition<br/>action: delivered]]
OPTIMISTIC5 --> ENQUEUE5[[SyncQueueService<br/>orderStatusTransition<br/>action: cancel]]
ENQUEUE3 --> STOCK_CHECK{Online?}
STOCK_CHECK -->|Yes| FEFO[Backend: FEFO stock<br/>allocation & deduction]
STOCK_CHECK -->|No| QUEUED_OFD>Queued for sync<br/>stock deduction deferred]
FEFO --> STOCK_OK{Stock sufficient?}
STOCK_OK -->|Yes| OFD_DONE>Status: OutForDelivery<br/>Stock deducted]
STOCK_OK -->|No| OFD_FAIL>400: Insufficient stock<br/>markAsFailed]
subgraph "Payment Tracking"
PAYMENT_TRIGGER((Record Payment)) --> PAYMENT_LOCAL[Update local totals<br/>totalPaid + balance]
PAYMENT_LOCAL --> PAYMENT_QUEUE[[SyncQueueService<br/>orderPayment]]
PAYMENT_QUEUE --> PAYMENT_API[POST /api/orders/{id}/payment<br/>RecordPaymentRequest]
end
subgraph "Status State Machine"
direction LR
S_PENDING[Pending<br/>0] -->|confirm| S_CONFIRMED[Confirmed<br/>1]
S_CONFIRMED -->|start_processing| S_INPROGRESS[InProgress<br/>2]
S_INPROGRESS -->|out_for_delivery| S_OFD[OutForDelivery<br/>3]
S_OFD -->|delivered| S_DELIVERED[Delivered<br/>4]
S_PENDING -->|cancel| S_CANCELLED[Cancelled<br/>5]
S_CONFIRMED -->|cancel| S_CANCELLED
S_INPROGRESS -->|cancel| S_CANCELLED
end
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | OrderDetailsScreen / OrdersListScreen | User triggers status transition | N/A |
| 2 | Function | OrderService._offlineStatusTransition() | Generic handler: reads existing order, applies optimistic status update, enqueues, attempts API call | Catches DioException, distinguishes network vs. server errors |
| 3 | Database | OrderLocalService.saveOrderOffline() | Overwrites order row with new status, sync_status=pending | Returns null on failure |
| 4 | Queue | SyncQueueService.enqueueEntity() | Enqueues with entityType=orderStatusTransition, priority 2 (higher than creates) | Persisted to queue |
| 5 | API | POST /api/orders/{id}/out-for-delivery | OrdersController.MarkAsOutForDelivery -- passes userId for stock out finalization; backend performs FEFO allocation (First Expiry First Out) to deduct warehouse stock | 400 if insufficient stock |
| 6 | API | POST /api/orders/{id}/delivered | OrdersController.MarkAsDelivered -- optional DeliveryDate parameter | 400 on invalid transition |
| 7 | API | POST /api/orders/{id}/cancel | OrdersController.Cancel -- optional reason | 400 on invalid transition |
| 8 | API | POST /api/orders/{id}/payment | OrdersController.RecordPayment -- requires Permission.ManagePayments | 400 on validation |
| 9 | Sync | OrderSyncService.syncStatusTransitionQueueItem() | Resolves server ID, calls correct endpoint based on action field, reconciles response | Throws on non-200 response |
Data Transformations
- Input: Order ID + action string (
confirm,start_processing,out_for_delivery,delivered,cancel) + optional reason/delivery date - Processing:
_offlineStatusTransition()builds optimisticOrder.copyWith(status: newStatus), enqueues payload{orderId, action, reason?}. Backend validates state machine transitions, performs stock deductions on out-for-delivery. - Output: Updated
Orderin local SQLite (immediate) and PostgreSQL (on sync); stock movements created in warehouse (for out-for-delivery)
Error Handling
- Invalid transition: Backend returns 400 if state machine rules violated (e.g., cannot go from Pending to Delivered directly); mobile
markAsFailed()stores error - Insufficient stock: Out-for-delivery fails if FEFO allocation cannot satisfy all order items; order remains InProgress
- Optimistic rollback: If sync fails, the local order retains the optimistic status but
sync_status=failedalerts the UI; next successful sync reconciles - Payment failures:
RecordPaymentRequestamount added optimistically tototalPaid; on API failure, local totals may drift until next sync pull
Cross-References
- Triggers: WF-STOCK-03 (out-for-delivery triggers FEFO stock deduction)
- Triggered by: WF-CLI-04 (order creation starts in Pending status)
- Related: WF-SYNC-01 (order sync via
OrderSyncService)
WF-CLI-06: Client Document Management
Trigger: Agent attaches document during client registration (Step 3) or from client details screen Frequency: On-demand (per document upload) Offline Support: Yes -- files stored locally first, uploaded when connectivity available
Workflow Diagram
graph TD
TRIGGER((Agent adds<br/>document)) --> FILE_PICK[FilePicker.platform.pickFiles<br/>PDF, JPG, PNG, GIF]
FILE_PICK --> SIZE_CHECK{File size<br/>< 10MB?}
SIZE_CHECK -->|No| ERROR>Error: File too large]
SIZE_CHECK -->|Yes| TYPE_SELECT[Document Type Dialog<br/>BusinessLicense, NationalId,<br/>TaxCertificate, BankStatement,<br/>ProofOfAddress, Other]
TYPE_SELECT --> COPY_LOCAL[Copy file to<br/>local storage directory]
COPY_LOCAL --> CREATE_RECORD[[ClientDocumentLocalDataSource<br/>insertDocument<br/>status: pending]]
CREATE_RECORD --> CHECK_SYNC{Client synced<br/>AND online?}
CHECK_SYNC -->|Yes| SIZE_ROUTE{File > 1MB?}
CHECK_SYNC -->|No| QUEUED>Queued for later sync<br/>status: pending]
SIZE_ROUTE -->|Yes - Chunked| CHUNK_UPLOAD[ChunkedUploadService<br/>uploadFile to CDN]
SIZE_ROUTE -->|No - Direct| DIRECT_UPLOAD[Direct multipart POST<br/>/api/clients/{id}/documents]
CHUNK_UPLOAD --> CHUNK_OK{Upload success?}
CHUNK_OK -->|Yes| REGISTER_URL[POST /api/clients/{id}<br/>/documents/register<br/>documentUrl + metadata]
CHUNK_OK -->|No| CHUNK_SIZE{File > 10MB?}
CHUNK_SIZE -->|Yes| FAIL>Upload failed<br/>too large for direct]
CHUNK_SIZE -->|No| DIRECT_UPLOAD
DIRECT_UPLOAD --> DIRECT_OK{Upload success?}
DIRECT_OK -->|Yes| UPDATE_SYNCED[[updateSyncStatus<br/>synced + serverId<br/>+ documentUrl]]
DIRECT_OK -->|No| UPDATE_FAILED[[updateSyncStatus<br/>failed + syncError]]
REGISTER_URL --> UPDATE_SYNCED
UPDATE_SYNCED --> DONE>Document uploaded<br/>URL stored]
subgraph "Background Sync"
SYNC_TRIGGER((SyncManager<br/>cycle)) --> GET_PENDING[getPendingDocuments]
GET_PENDING --> LOOP{More pending?}
LOOP -->|Yes| CHECK_CLIENT[Get client server ID]
CHECK_CLIENT --> HAS_SERVER{Server ID?}
HAS_SERVER -->|Yes| RETRY_UPLOAD[_uploadToServer]
HAS_SERVER -->|No| SKIP[Skip - client<br/>not synced yet]
RETRY_UPLOAD --> LOOP
SKIP --> LOOP
LOOP -->|No| SYNC_DONE>Sync complete<br/>count returned]
end
Node Descriptions
| # | n8n Node Type | Component | Function | Error Handling |
|---|---|---|---|---|
| 1 | Trigger | ClientDocumentsFormSection | User picks file via FilePicker | Size/type validation |
| 2 | Validation | _addDocument() | Checks file size <= 10MB, extension in [pdf, jpg, jpeg, png, gif] | Shows snackbar error |
| 3 | UI | Document type dialog | User selects from: BusinessLicense, NationalId, TaxCertificate, BankStatement, ProofOfAddress, Other | Required selection |
| 4 | Function | ClientDocumentLocalDataSource.copyFileToLocalStorage() | Copies original file to app's local document storage directory | Throws on copy failure |
| 5 | Database | ClientDocumentLocalDataSource.insertDocument() | INSERT LocalClientDocument with UUID, sync_status=pending | Error propagated |
| 6 | Function | ClientDocumentService._uploadToServer() | Routes to chunked or direct upload based on 1MB threshold | Falls back from chunked to direct for files under 10MB |
| 7 | Function | ChunkedUploadService.uploadFile() | Splits file into chunks, uploads to DigitalOcean Spaces via presigned URLs, returns CDN URL | Progress callback for UI |
| 8 | API | POST /api/clients/{id}/documents | ClientsController.UploadDocument -- direct multipart upload, 120s timeout | DioException with status code |
| 9 | API | POST /api/clients/{id}/documents/register | ClientsController.RegisterDocument -- registers chunked upload URL with backend | Returns ClientDocumentDto |
| 10 | Database | ClientDocumentLocalDataSource.updateSyncStatus() | Updates sync status, stores serverId and documentUrl from server response | N/A |
| 11 | Function | ClientDocumentService.syncPendingDocuments() | Iterates pending documents, skips if client not synced, uploads remaining | Returns count of synced docs |
Data Transformations
- Input: File path + document type + optional description
- Processing:
_normalizeDocumentType()maps legacy names (e.g.,TaxRegistrationtoTaxCertificate);_getMimeType()derives MIME from extension; chunked upload splits into chunks and reassembles at CDN; direct upload usesFormDatawithMultipartFile - Output:
LocalClientDocumentin SQLite withdocumentUrlpointing to CDN (DigitalOcean Spaces)
Error Handling
- Chunked upload failure: Falls back to direct upload if file size <= 10MB; otherwise fails permanently
- Client not yet synced: Documents queued as
pending;syncPendingDocuments()skips documents whose parent client has noserver_id; retried when client syncs - File not found:
_uploadToServer()checksFile(localFilePath).exists()before attempting upload; throws if missing - Retry mechanism:
retryDocumentSync()method allows manual retry from UI;syncPendingDocuments()runs during each sync cycle - Deduplication:
getClientDocuments()merges local docs (byclientLocalId) with server docs (byserverId), avoiding duplicates when both sources have the same document
Cross-References
- Triggers: None directly
- Triggered by: WF-CLI-01 (documents attached during onboarding), client details screen (post-creation document adds)
- Related: WF-SYNC-01 (sync cycle calls
syncPendingDocuments())
Domain Integration Map
graph LR
subgraph "Client Lifecycle"
CLI01[WF-CLI-01<br/>Onboarding]
CLI02[WF-CLI-02<br/>Crop Demands]
CLI03[WF-CLI-03<br/>Approval]
CLI06[WF-CLI-06<br/>Documents]
end
subgraph "Order Lifecycle"
CLI04[WF-CLI-04<br/>Order Creation]
CLI05[WF-CLI-05<br/>Fulfillment]
end
subgraph "External Domains"
SYNC[WF-SYNC-01<br/>Sync Engine]
STOCK[WF-STOCK-01<br/>Warehouse Stock]
AUTH[WF-AUTH-01<br/>Auth & Permissions]
end
CLI01 -->|submits| CLI03
CLI01 -->|inline| CLI02
CLI01 -->|attaches| CLI06
CLI03 -->|approves| CLI04
CLI04 -->|creates| CLI05
CLI05 -->|out-for-delivery| STOCK
CLI01 -.->|sync| SYNC
CLI02 -.->|sync| SYNC
CLI04 -.->|sync| SYNC
CLI05 -.->|sync| SYNC
CLI06 -.->|sync| SYNC
AUTH -.->|permissions| CLI03
AUTH -.->|permissions| CLI04
AUTH -.->|permissions| CLI05
Key Data Tables
| SQLite Table | Purpose | Key Columns |
|---|---|---|
clients | Local client records | id, server_id, company_name, approval_status, sync_status, assigned_agent_id |
client_crop_demand_drafts | Crop demand drafts and cache | id, client_local_id, client_server_id, payload (JSON), sync_status, pending_photo_path |
client_documents | Document metadata and sync tracking | id, client_local_id, server_id, document_type, local_file_path, document_url, sync_status |
orders | Order records with offline support | id, server_id, order_number, client_id, status (int), total_amount, sync_status |
order_items | Line items per order | id, order_id, crop_id, quantity, unit_price, total_price |
sync_queue | Pending sync operations | entity_type, entity_id, operation, payload_json, priority |
API Endpoints Summary
| Method | Endpoint | Permission | Controller |
|---|---|---|---|
POST | /api/clients | CreateClients | ClientsController.Create |
POST | /api/clients/{id}/approve | ApproveClients | ClientsController.ApproveClient |
POST | /api/clients/{id}/reject | ApproveClients | ClientsController.RejectClient |
POST | /api/clients/{id}/documents | CreateClients | ClientsController.UploadDocument |
POST | /api/clients/{id}/documents/register | CreateClients | ClientsController.RegisterDocument |
GET | /api/clients/{id}/crop-demands | ViewClients | ClientsController.GetCropDemands |
POST | /api/clients/{id}/crop-demands | CreateClients | ClientsController.AddCropDemand |
PUT | /api/clients/{id}/crop-demands/{demandId} | EditClients | ClientsController.UpdateCropDemand |
POST | /api/orders | CreateOrders | OrdersController.Create |
PUT | /api/orders/{id} | EditOrders | OrdersController.Update |
POST | /api/orders/{id}/confirm | ProcessOrders | OrdersController.Confirm |
POST | /api/orders/{id}/start-processing | ProcessOrders | OrdersController.StartProcessing |
POST | /api/orders/{id}/out-for-delivery | ProcessOrders | OrdersController.MarkAsOutForDelivery |
POST | /api/orders/{id}/delivered | ProcessOrders | OrdersController.MarkAsDelivered |
POST | /api/orders/{id}/cancel | CancelOrders | OrdersController.Cancel |
POST | /api/orders/{id}/payment | ManagePayments | OrdersController.RecordPayment |
GET | /api/orders/summary | ViewOrders | OrdersController.GetSummary |