Skip to main content

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

ActorActions
Field AgentCreates clients, captures crop demands, manages client documents
Admin/ManagerApproves/rejects clients, creates and processes orders
CSA (Customer Service Agent)Views orders, manages delivery logistics
ClientViews 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 TypeComponentFunctionError Handling
1TriggerAddClientScreenMultiStepAgent initiates client registrationN/A
2IFClientFormDraftService.hasDraft()Check for existing draft in SharedPreferencesReturns false on error
3FunctionClientFormDraftService.loadDraft()Restore form data, profile photo, documents from draftFalls back to empty form
4FormClientInfoStepCollects: 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 photoField-level validation, GPS auto-capture via LocationService
5ValidationClientFormData.isStep1Valid()Requires: company name, contact name, role, phone, province, address, years in operation, GPS (non-zero), preferred contactBlocks progression to Step 2
6FunctionClientFormDraftService.saveDraft()Persists form state to SharedPreferences as JSON (schema version 2)Logs error, continues
7FormCropDemandStepCollects per-crop: crop selection, sourcing origin, provinces/countries, purchase frequency, avg volume, price range, purchase priorities, common issues, notesSeparate validation form key for add-crop section
8FormClientDocumentsFormSectionFile picker (PDF, JPG, PNG, GIF), max 10MB, document type selection dialogSize validation, type restriction
9DatabaseClientDraftService.saveClientLocally()INSERT into clients table with UUID, sync_status=pending, approval_status=PendingApprovalSchema-aware column detection cached
10QueueSyncQueueService.enqueueEntity()Enqueues client entity for background syncPersists to sync_queue table
11DatabaseClientCropDemandDraftService.saveDrafts()Batch INSERT into client_crop_demand_drafts with client local IDBatch commit for performance
12FunctionClientDocumentService.uploadDocument()Copies file to local storage, creates LocalClientDocument, attempts immediate upload if onlineFalls back to pending status
13APIPOST /api/clientsClientsController.Create -- validates via CreateClientRequestDtoValidator, creates Identity user, creates Client entityReturns 400 with validation errors
14FunctionClientService._reconcileAfterSync()Updates local record with server ID, links crop demand drafts to server client IDHandles 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; ClientDraftService builds LocalClient model from form data
  • Output: LocalClient row in SQLite (offline) or ClientResponse DTO from backend (online)

Error Handling

  • Draft corruption: _normalizeDraftShape() handles schema migration between draft versions, normalizes paymentAccountNumber/paymentAccount aliasing, and repairs localPath/filePath inconsistencies in document entries
  • GPS failure: Falls back to manual coordinate entry if LocationService cannot acquire position
  • Sync failure: Client remains in local SQLite with sync_status=failed; SyncManager retries on next connectivity event
  • Server validation error: HTTP 400 response stored in sync_error column; 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 TypeComponentFunctionError Handling
1TriggerCropDemandStep / ClientCropDemandFormScreenUser initiates crop demand entrySeparate form key prevents validation conflict with main form
2FormCropSelectionWidgetCategory-filtered crop picker from cached master dataFalls back to text search
3FunctionClientMasterDataResolverResolves local/display IDs to server GUIDs for: measurement units, currencies, countries, purchase priorities, common issuesThrows if critical IDs missing
4DatabaseClientCropDemandDraftService.saveDraftWithPhoto()INSERT into client_crop_demand_drafts with UUID, payload JSON, optional pending photo pathReturns null on error
5APIPOST /api/clients/{clientId}/crop-demandsClientCropDemandService.CreateClientCropDemandAsync -- backend derives cropCategoryId from crop entity400 on validation failure
6FunctionClientCropDemandDraftService.markAsSynced()Deletes draft if no pending photo; keeps draft with server ID if photo upload neededHandles photo lifecycle
7APIPOST /api/clients/{id}/crop-demands/{id}/photoIClientCropDemandService.UpdateCropDemandPhotoAsyncRetried by CropDemandsSync.uploadPendingPhotos()

Data Transformations

  • Input: ClientCropDemandData (cropId, sourcingOrigin, provinces/countries, frequency, volume, price range, priorities, issues, notes)
  • Processing: ClientCropDemandData.toApiPayload() strips cropCategoryId (backend derives it); ClientMasterDataResolver maps display values to server GUIDs; _mergeServerDemandsWithPendingDrafts() overlays local edits on cached server data
  • Output: LocalClientCropDemand row in client_crop_demand_drafts table; ClientCropDemandDto from 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: _invalidatedClientIds set forces fresh read after local update, bypassing stale cache
  • Offline merge: _mergeServerDemandsWithPendingDrafts() deduplicates by serverDemandId, 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 TypeComponentFunctionError Handling
1TriggerAdmin Dashboard / ClientsControllerAdmin initiates reviewN/A
2APIGET /api/clients/{id}Fetches full client details with navigation properties404 if not found
3APIPOST /api/clients/{id}/approveClientsController.ApproveClient delegates to IClientApprovalService403 if not authorized
4APIPOST /api/clients/{id}/rejectClientsController.RejectClient requires reason in body400 if reason missing
5FunctionClientApprovalService.ApproveClientAsync()Wraps in audit scope, calls client.Approve(authUserId) domain method, persistsResult.Failure on not found
6FunctionClientApprovalService.RejectClientAsync()Validates reason, calls client.Reject(authUserId, reason) domain methodArgumentException caught and returned as failure
7Databasecontext.SaveChangesAsync()Persists status change to PostgreSQLTransaction rollback on failure
8SyncMobile sync pullAgent's SyncManager pulls updated client status on next sync cycleStatus 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, setting ApprovalStatus, ApprovedByUserId, ApprovalDate (or rejection equivalents)
  • Output: Updated Client entity 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 PendingApproval until next sync; ClientService._syncSingleClient() reconciles on pull
  • Rejected client editing: ClientRemovedFromQueueNotification alerts 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 TypeComponentFunctionError Handling
1TriggerCreateOrderScreenUser initiates order creationN/A
2FormCreateOrderScreenCollects: client, order items (crop, qty, unit, price, delivery date), delivery address, delivery method, logistics partner, currency, notesField validation
3FunctionOrderService.createOrder()Builds Order object with LOCAL-{uuid} number, calculates total, generates UUIDs for itemsN/A
4DatabaseOrderLocalService.saveOrderOffline()INSERT into orders + order_items tables with sync_status=pendingReturns null on error
5QueueSyncQueueService.enqueueEntity()Enqueues with entityType=order, operation=create, priority 1Persisted to queue table
6APIPOST /api/ordersOrdersController.Create -- sets createdByUserId from JWT, delegates to IOrderService.CreateOrderAsync()400 on validation failure
7FunctionOrderService._reconcileCreatedOrder()Saves server order, updates server_id on old local row, moves order items to server ID, deletes old local rowHandles ID mismatch
8SyncOrderSyncService.syncOrderQueueItem()Background processor for queued creates/updates/deletesReconciles on success, throws on failure

Data Transformations

  • Input: CreateOrderRequest (clientId, orderDate, expectedDeliveryDate, deliveryAddress, deliveryMethodId, logisticsPartnerId, currencyId, notes, list of CreateOrderItemRequest)
  • Processing: Each CreateOrderItemRequest specifies either cropId (existing crop) or cropTypeId (find-or-create); backend generates sequential orderNumber; mobile assigns temporary LOCAL-{uuid.substring(0,8)} number
  • Output: Order model with items in SQLite (offline) or OrderDto from API (online)

Error Handling

  • Network timeout: _isNetworkError() detects connection/send/receive timeouts; order stays queued
  • Server validation failure: Non-network DioException triggers markAsFailed() 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 TypeComponentFunctionError Handling
1TriggerOrderDetailsScreen / OrdersListScreenUser triggers status transitionN/A
2FunctionOrderService._offlineStatusTransition()Generic handler: reads existing order, applies optimistic status update, enqueues, attempts API callCatches DioException, distinguishes network vs. server errors
3DatabaseOrderLocalService.saveOrderOffline()Overwrites order row with new status, sync_status=pendingReturns null on failure
4QueueSyncQueueService.enqueueEntity()Enqueues with entityType=orderStatusTransition, priority 2 (higher than creates)Persisted to queue
5APIPOST /api/orders/{id}/out-for-deliveryOrdersController.MarkAsOutForDelivery -- passes userId for stock out finalization; backend performs FEFO allocation (First Expiry First Out) to deduct warehouse stock400 if insufficient stock
6APIPOST /api/orders/{id}/deliveredOrdersController.MarkAsDelivered -- optional DeliveryDate parameter400 on invalid transition
7APIPOST /api/orders/{id}/cancelOrdersController.Cancel -- optional reason400 on invalid transition
8APIPOST /api/orders/{id}/paymentOrdersController.RecordPayment -- requires Permission.ManagePayments400 on validation
9SyncOrderSyncService.syncStatusTransitionQueueItem()Resolves server ID, calls correct endpoint based on action field, reconciles responseThrows 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 optimistic Order.copyWith(status: newStatus), enqueues payload {orderId, action, reason?}. Backend validates state machine transitions, performs stock deductions on out-for-delivery.
  • Output: Updated Order in 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=failed alerts the UI; next successful sync reconciles
  • Payment failures: RecordPaymentRequest amount added optimistically to totalPaid; 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 TypeComponentFunctionError Handling
1TriggerClientDocumentsFormSectionUser picks file via FilePickerSize/type validation
2Validation_addDocument()Checks file size <= 10MB, extension in [pdf, jpg, jpeg, png, gif]Shows snackbar error
3UIDocument type dialogUser selects from: BusinessLicense, NationalId, TaxCertificate, BankStatement, ProofOfAddress, OtherRequired selection
4FunctionClientDocumentLocalDataSource.copyFileToLocalStorage()Copies original file to app's local document storage directoryThrows on copy failure
5DatabaseClientDocumentLocalDataSource.insertDocument()INSERT LocalClientDocument with UUID, sync_status=pendingError propagated
6FunctionClientDocumentService._uploadToServer()Routes to chunked or direct upload based on 1MB thresholdFalls back from chunked to direct for files under 10MB
7FunctionChunkedUploadService.uploadFile()Splits file into chunks, uploads to DigitalOcean Spaces via presigned URLs, returns CDN URLProgress callback for UI
8APIPOST /api/clients/{id}/documentsClientsController.UploadDocument -- direct multipart upload, 120s timeoutDioException with status code
9APIPOST /api/clients/{id}/documents/registerClientsController.RegisterDocument -- registers chunked upload URL with backendReturns ClientDocumentDto
10DatabaseClientDocumentLocalDataSource.updateSyncStatus()Updates sync status, stores serverId and documentUrl from server responseN/A
11FunctionClientDocumentService.syncPendingDocuments()Iterates pending documents, skips if client not synced, uploads remainingReturns count of synced docs

Data Transformations

  • Input: File path + document type + optional description
  • Processing: _normalizeDocumentType() maps legacy names (e.g., TaxRegistration to TaxCertificate); _getMimeType() derives MIME from extension; chunked upload splits into chunks and reassembles at CDN; direct upload uses FormData with MultipartFile
  • Output: LocalClientDocument in SQLite with documentUrl pointing 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 no server_id; retried when client syncs
  • File not found: _uploadToServer() checks File(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 (by clientLocalId) with server docs (by serverId), 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 TablePurposeKey Columns
clientsLocal client recordsid, server_id, company_name, approval_status, sync_status, assigned_agent_id
client_crop_demand_draftsCrop demand drafts and cacheid, client_local_id, client_server_id, payload (JSON), sync_status, pending_photo_path
client_documentsDocument metadata and sync trackingid, client_local_id, server_id, document_type, local_file_path, document_url, sync_status
ordersOrder records with offline supportid, server_id, order_number, client_id, status (int), total_amount, sync_status
order_itemsLine items per orderid, order_id, crop_id, quantity, unit_price, total_price
sync_queuePending sync operationsentity_type, entity_id, operation, payload_json, priority

API Endpoints Summary

MethodEndpointPermissionController
POST/api/clientsCreateClientsClientsController.Create
POST/api/clients/{id}/approveApproveClientsClientsController.ApproveClient
POST/api/clients/{id}/rejectApproveClientsClientsController.RejectClient
POST/api/clients/{id}/documentsCreateClientsClientsController.UploadDocument
POST/api/clients/{id}/documents/registerCreateClientsClientsController.RegisterDocument
GET/api/clients/{id}/crop-demandsViewClientsClientsController.GetCropDemands
POST/api/clients/{id}/crop-demandsCreateClientsClientsController.AddCropDemand
PUT/api/clients/{id}/crop-demands/{demandId}EditClientsClientsController.UpdateCropDemand
POST/api/ordersCreateOrdersOrdersController.Create
PUT/api/orders/{id}EditOrdersOrdersController.Update
POST/api/orders/{id}/confirmProcessOrdersOrdersController.Confirm
POST/api/orders/{id}/start-processingProcessOrdersOrdersController.StartProcessing
POST/api/orders/{id}/out-for-deliveryProcessOrdersOrdersController.MarkAsOutForDelivery
POST/api/orders/{id}/deliveredProcessOrdersOrdersController.MarkAsDelivered
POST/api/orders/{id}/cancelCancelOrdersOrdersController.Cancel
POST/api/orders/{id}/paymentManagePaymentsOrdersController.RecordPayment
GET/api/orders/summaryViewOrdersOrdersController.GetSummary