ZuloOne
English
Sign in
Menu

📱 Mobile client

Updated

← Business process wiki

One Flutter app, roles as metadata views, offline-first. Sales Agent is the first role. The You tab switches among the role seeds this stand publishes (GET /api/mobile/roles). Warehouse covers picking and put-away: Draft PickTask and PutAwayTask plus the catalogue, confirmed with ConfirmPick / ConfirmPutAway. ScanBarcode on those tasks marks a picked line; it does not grow qty and it does not call pricing. Cells shows the stores those tasks already named: every StoreCell of those Stores (via StoreZone), not only FromCell / ToCell (StoreCell as Master — no New action). Cells group by StoreZone; Store and StoreCellType are sent too, so those names are not GUIDs. An item catalogue card shows the bins that contain it. On-hand on a cell is CachedStock with CellId (live Stock by bin); the van still sums cells into one item hint. Manager is the field desk on the same installation: Team / Routes / Visits / Tasks / Orders / Catalog, last GPS as CachedLastFix (Map opens the pin) and today's breadcrumb track as CachedTrack (Track opens the line in Google Maps — no second map engine on the phone). Tasks is AgentTask for the team window (open plus due in the desk day). The manager assigns with CreateAgentTask and edits Draft / Open VisitRoute stops with UpdateVisitRoute. The van Today shows a Tasks block (CompleteTask / CancelTask) next to route stops. A task is not a visit stop and does not write a payment. A submitted SalesOrder is the manager's approval queue: Manager pulls that set as replaceAll (not the desk day window) and runs ApproveOrder → ApproveSalesOrder or RejectOrder → RejectSalesOrder. Lines attach in one query per table part (chunked IN), not one round-trip per order. Newest Submitted first if the set hits the pull page. JWT is enough, like warehouse confirm — no OwnsDocument, no reprice. A rejection writes typed header strings from the card (ExecuteCommand.fields, not a hardcoded Dart field) before the office script; empty RejectReason is still CommandFailed. SalesSettings.ConfirmOrderOnSubmit (default off) makes van SubmitSalesOrder confirm immediately; the manager inbox then has no new rows for that order. JWT ERP roles (Administrator, …) are not these names; the partition, not the role claim, keeps data apart.

On the Manager Team tab the director chooses which register analytics to see (Revenue by customer / item / contract, Receivable by customer / contract). The phone does not compute sales or turnover; POST /api/mobile/desk/report runs the same register report the office totals screen uses. The pick is stored on the phone. The seed names the registers (deskReports on Manager.json); live metadata fills the analytics. A tenant without Sales simply offers none.

Repos

Piece Where
Flutter client sibling zuloone.mobile
Sync / manifest API zulo.one (/api/mobile/*)
Role views, visits, GPS log zuloone-workspace (Sales)
Sideload APK the customer cabinet (getzulo.com/cabinet → Apps) → getzulo/zuloone-app Releases

Field install steps (Android only, debug-signed until a release keystore exists): user wiki.

The phone fills lines from a cached price projection. Posting re-runs IPricingService on the server. Do not hardcode order fields in Dart.

The catalogue on all three views is the same screen. A master that carries ParentId (item group) is a folder rail: tap a folder to see what is filed under it, including its children; All is the first step back. Folder titles wrap to two lines. A flat master (brand, a tenant price group) stays a horizontal chip row. Every Item field that points at another Master the role already syncs is a filter — the stand sends references on the descriptor so a field named unlike its dictionary still appears. ABC class is a chip row only when the pull actually has more than one letter. You → Appearance is Light, Dark, or System — the catalogue uses the same colour scheme, so snow and night are both readable. You → Language follows the ERP user card (languageCode on login and refresh) until the agent picks English, Russian, Ukrainian or Arabic. The pick is a phone setting, like the theme: it survives sign-out. Office is the card again. Every request sends X-ZuloOne-Lang, so captions, item names and characteristic sheets arrive in that language; a language change re-pulls so the cache is not stuck in the previous one. The chrome (theme, language, You) switches immediately; the download continues in the background. Chrome the phone paints itself (You, order lines) follows the same pick; Arabic is RTL.

Sign-in takes the role descriptor and opens the van. The first pull is not a gate: a tenant with a large item catalogue, photos, prices and contracts used to pin the agent on Syncing data… until one POST /api/mobile/sync/pull finished (or the proxy cut it at ~100 s and the phone applied nothing). The shell now paints on the descriptor; the same progress bar under Sync names the table being saved. Tap Sync to watch a pull that is already running. A dropped wire is retried by the pump even when the outbox is empty — the first catalogue is not “nothing to push”. Resume of a held session is the same: last role first, pull behind it. The phone waits at most 95 s for that POST; a hang that used to stay on Downloading… for hours now fails and the pump retries. The stand itself aims to answer inside 70 s so the proxy never cuts an empty body.

A large first pull used to freeze the shell: the UI isolate decoded the JSON, inserted every row one statement at a time, and rebuilt the item search index by reading the whole Item table back. Bytes over 128 KB are decoded off the UI isolate, SQLite writes go in batches of 400, the search index updates only the rows that changed, and a progress bar under the role chrome names the entity being saved. Appearance and Language stay tappable while that runs. A true Android foreground service is not required for this — the freeze was CPU on the UI isolate, not the OS killing a backgrounded app.

A draft order fills related header references from the master the agent just picked. Picking an outlet copies the customer that outlet names, then the unique contract that fits (an outlet contract beats a customer-wide one) and that contract's payment and delivery terms. The phone does not hardcode those field names: it follows whichever synced masters point at each other. Location (ship-from cell) is not a synced reference on Sales agent, so it is not guessed. Dropdowns hide masters that contradict the already filled customer or outlet.

Draft order lines are product cards: picture, name × qty, unit price, line amount. Add items opens the catalogue; the quantity field is how many the catalogue + adds. Tap a line to change qty.

Legacy platform types MobileInterface / MobileService are obsolete and are not used.

Who opens which view#

The administrator decides which view a login opens in the app: the agent's van (Sales agent), the sales manager's desk (Sales manager) or the warehouse (Storekeeper). The phone does not let the user pick a view the office did not give.

There are two settings, and they combine:

Setting Where Values What it gives
«Mobile app view» on a security role Administrator → Access → «Roles» → open a role → tab «Roles» empty (default), Sales agent, Sales manager, Storekeeper every member of the role (directly, or through a user group) may open that view
«Login» on a sales agent card Sales → Dictionaries → SalesPerson → field «Login» empty (default), the user name or the e-mail of an existing user that login opens Sales agent, and the pull sends THIS agent's customers, outlets, contracts, prices, routes and orders

Rules, in order:

  1. A user in a superuser role (Administrator) may open every view.
  2. Otherwise the user gets the views of their roles, plus Sales agent when a SalesPerson card names their login.
  3. A stand where no role has a mobile view yet lets every login open every view. Once ONE role has a view, the rule above applies to everybody: a login with neither a role view nor an agent card is told at sign-in that it has no mobile view.

The phone opens the view it held last time if that view is still allowed. If not, it opens the agent view for a linked agent, or the first view of the login's roles. It re-checks at sign-in, when the app resumes, and on every sync. A manifest, pull or push for a view the login was not given answers 403 with the reason.

Example. Every stand seeds three security roles for the phone: «Agents» with Sales agent, «Sales managers» with Sales manager, «Warehouse» with Storekeeper. Office contours (Sales, Purchasing, Accountant, HR, Production) are separate roles with no mobile view. Assign the login; do not recreate the role.

User Roles SalesPerson card with this login Views on the phone Opens
olena Agents Olena Bondar Sales agent Sales agent, with Olena's 40 customers
petro Sales managers — Sales manager Sales manager
ivan Agents, Warehouse — Sales agent, Storekeeper Sales agent, with no customers, because no card names him
admin Administrator — all three Sales agent

The trap is Ivan's row. The role lets him open the van, but customers, prices and routes come from the agent card. Without the card the van shows only the catalogue, and Today says the login is not linked. The You tab names the linked agent card, or says that there is none.

The «Login» field is checked on save:

  • A name that matches no user is refused.
  • A login that another agent card already uses is refused. One login is one agent; otherwise the phone would not know whose customers to show.
  • Clearing the field unlinks the agent.
  • An agent card with an empty Login keeps its user link; the next save fills Login from the user record.

Sync API#

JWT on every call. The identity is the token subject, never the body. Login also returns a refreshToken; the phone stores it in secure storage (never SQLite) and posts POST /api/auth/refresh when a call answers 401. A user without a SalesPerson.UserId still gets the descriptor; pull returns the item catalogue only and agentId is null; Today says this login is not a field agent; push of a create is PartitionDenied.

A dropped connection or a dead JWT puts the outbox row back on the queue under the same idempotency key and stops the push. Only a business Failed rejects a row. The running app retries with backoff (2s × 4, cap 120s); a widget test never starts that pump.

Method Path Body Answer
POST /api/auth/refresh { refreshToken } { token, expires, refreshToken, user } — rotates the session; replay of the old token is 401
GET /api/mobile/roles — { roles, defaultRole, agentId, agentName, configured }: the views THIS login may open (see «Who opens which view»), SalesAgent first; the view to open when the held one is gone; the linked agent card; whether any role on the stand names a view. 404 on an older stand → the phone keeps the view it already has
POST /api/mobile/manifest { role, schemaHash } { schemaHash, descriptor } — hash match skips the descriptor
POST /api/mobile/sync/pull { role, watermarks, from?, asOf? } { agentId, changes, newWatermarks } — the payload by role is below
POST /api/mobile/sync/push { role, items[] } { results[] } — always 200; each item is Success or Failed. role selects the descriptor allow-list (ConfirmPick is Warehouse-only)
POST /api/mobile/desk/report { role, register, groupBy[], from?, asOf? } { register, groupBy, data[] } — Manager only. groupBy must be analytics the descriptor offered on that register. The stand runs the existing register report (income / outcome / closing). 400 if the role is not Manager or the slice is not on the desk

Unknown role on manifest/pull → 400. Push never 400s a whole batch: one rejected action must not drop the rest.

What a pull carries, by role#

Two kinds of payload, and the phone treats them differently:

  • The catalogue (Item) is a delta against the watermark the phone sent.
  • Sets are sent whole on every pull and replace the phone's table: the agent's customers, their outlets, addresses, contracts, payment and delivery terms, the catalogue's groups and brands, and the warehouse yard. A customer handed to another agent changes who is in the set without changing the customer's rows, so a delta would miss it. The same holds for a new item that names a brand the phone never had.
  • Photos do not ride in the first pull. Image/Binary columns (pack shots on the item, a photo on a line) are omitted from the JSON. A few thousand SKUs with pictures used to turn one POST into hundreds of megabytes of base64: the stand spent minutes reading them, the proxy cut the request, and the phone applied nothing. The catalogue shows the letter avatar until GET /api/mobile/picture?entity={name}&id={metaId} returns the first PNG/JPEG/GIF/WebP cell on that row. The phone asks as the agent scrolls (the visible row plus about eight ahead) and keeps the bytes on disk keyed by {entity}/{id}. Names, codes, groups, stock and prices still arrive.
  • Prices are time-boxed. CachedPrice is one pricing call per item × customer × contract. A pull spends at most 30 seconds on new ones: a request longer than 100 seconds is cut by the proxy, and then the phone applies nothing at all. Resolved prices are kept on the server for the agent for 15 minutes, so the next pull sends them again and continues where the last one stopped. Example: 400 items × 25 customers is 10,000 cells. At about 20 ms a cell, the book fills in over 7 pulls. Until the customer cell arrives, the line takes the catalogue price already on the phone. ResolvePrice is only queued when even that row is missing — not on every tap — so a slow book does not fill the rejection badge with «Resolve Price». A price changed in the office reaches the phone within 15 minutes.
  • Characteristics are time-boxed the same way. Each item's sheet is one call into the Common engine. A pull spends at most 15 seconds on new sheets; the rest wait for the next sync. The You tab says still filling on the next sync while any of prices, characteristics or a 100,000-row cap is incomplete.
  • The whole pull has a 70-second ceiling. After that the stand returns what it already built (the catalogue first) and skips the remaining projections rather than wiping them. The phone retries; Cloudflare never sees a 100-second empty cut.
  • The catalogue has its own price. The item card carries no price (see pricing), so the pull adds one CachedPrice row per item with no customer and no contract: the default sale price type (InventorySettings.DefaultSalePriceType) on today's window. These rows are resolved first — one call per item — so they arrive even when the customer book runs out of time. The Catalog tab shows that price next to the stock: «12.50 · 40 on hand». An order line prefers the customer × contract cell; if that cell is still filling, the line takes the catalogue row rather than queueing ResolvePrice (and a rejection badge) on every tap.
  • The picker shows the customer's price. Find items on an order shows each item's price for the order's customer and contract, at the quantity about to be added: the row the line will get. With quantity breaks 1 → 100.00 and 10 → 90.00, adding 12 shows 90.00. An item with no customer cell yet shows the catalogue price. No customer chosen yet means no prices in the picker, not catalogue prices. Standard cost and other Internal price types never reach the phone.
  • Starting over. Signing in to another tenant or as another user wipes the downloaded tables and the watermarks. Watermarks from another stand would hide every row older than them, so the catalogue and the customers never arrived. You → Download everything again does the same on demand. Drafts still waiting to be sent keep their tables.
  • Projections (CachedPrice, CachedStock, CachedReceivable, …) are computed every pull from the whole catalogue and the whole partition.

Sales agent. The partition plus CachedPrice, CachedReceivable and CachedStock; agentId is the SalesPerson or null.

The agent's customers are the union of three sets. The first is the customers assigned to the agent (Customer.SalesPerson). The second is the customers on the agent's VisitRoute stops of the last 7 days onward. The third is the customers on the agent's own SalesOrder rows in scope.

Orders in scope are those of the last 30 days, plus every order still in work (any subtype except Delivered and Cancelled), however old. Both the agent's own orders and those of every customer in the book are sent. They are sent whole each pull, as upserts: the same table holds the agent's unsent drafts.

The catalogue and every set are read page by page (2,000 rows a page).

A superuser with no SalesPerson card (the owner trying the app, or the office checking it) gets the whole book: every customer and every order in scope. Any other login without a card gets the catalogue only.

Example on the demo stand: agent «Dispatch Ivan» has 6 assigned customers; the book holds 13 because his orders name 7 more. Orders in scope number 19 (30-day window plus open orders: 8 Confirmed, 5 Draft, 6 Submitted). The partition includes the Address rows named by the agent's customers and trading outlets, and the PaymentTerm and DeliveryTerm named by those customers' contracts (an empty GUID is skipped). CachedReceivable is the live balance of the Receivable register for those customers, not a 30-day order sum. It is cut by contract, from the register report grouped by the Customer and SalesContract analytics. CachedStock is the live Stock quantity by item (cells summed) — a catalogue hint; the server still checks sufficiency at posting. CachedCampaign is the winning loyalty campaign by item (EarnRate and name). Its group is computed by the service; the phone does not choose the rate. No row means no campaign, and the tier rate is not substituted here. CachedAbc is the ABC/XYZ class from the AbcClassification slice for live item and customer profiles. One profile gives A/X; two add the profile name. The phone does not calculate Pareto; disabled profiles and empty classes do not enter the projection. CachedCharacteristics is the item's resolved template as the office card shows it: sections in template order, captions and values already formatted in the interface language, option colour, unit, and whether the line is a SKU key. The server walks the item group (ParentId) and Based on templates; the phone does not. Key values already sit in the item name (Sugar, White, 1 kg) — the van does not rebuild it. A line parameter (role 1) arrives on the same projection with Role = LineParameter and is not painted as an item property. Empty optional values are omitted. Warehouse and Manager seeds do not send this projection; a card only reads it when the descriptor names it.

Warehouse. The catalogue plus CachedStock (per CellId for every cell of the stores named by the draft tasks; summed by item only when there are no drafts) plus Draft PickTask and PutAwayTask, along with those stores' Store, StoreZone, StoreCell and StoreCellType. No sales partition.

Manager. SalesPerson, VisitRoute, AgentVisit, the named Customer, their CustomerOutlet and Address — as replaceAll sets. Plus orders in the Submitted subtype (replaceAll too, and not filtered by from/asOf; the customer set is the union of visits and orders), CachedLastFix (the last GPS slice) and CachedTrack for the desk window. from/asOf are the same half-open UTC range as GET /api/mobile/field-desk; an omitted from means today onward (for older phones). The manager has no van catalogue and no price cache.

What the phone says after a sync#

Every pull answers a summary, and the phone shows it after Sync and under You → Last sync. Example: «1 items · 3 customers · 2 orders · 24 more items hidden from this login by row security — agent Olena (login olena @ okrasheno.zulo.one)». The same line is in the tenant log as «Mobile pull for …».

Part Meaning What to do when it is wrong
items / customers / orders Rows the stand holds for this login Compare with the office
hidden by row security Items in the table that a record predicate on the login's role hides Administrator → Access → «Roles» → the role → row security: remove or widen the predicate on Item
catalogue only No SalesPerson card names this login and it is not a superuser Type the login on the agent card (field «Login»)
login @ host Who signed in, and to which tenant Sign in to the right tenant

Reinstalling the app starts from nothing. Android's automatic backup is turned off (allowBackup=false), so a reinstall does not restore the old database and watermarks.

Push actions#

Action What the server does
CreateSalesOrder Draft order in the agent's partition; re-resolves every line; PriceMismatch if both abs > 0.01 and % > SalesSettings.PriceMismatchPercent (missing/≤0 → 0.5). A quote the server cannot resolve is ValidationFailed, never stored. Empty idempotencyKey is ValidationFailed. Payload documentId of an existing Draft owned by the agent updates that document instead of creating a second one.
ExecuteCommand A document command offered to the role (SubmitSalesOrder, ApproveSalesOrder, RejectSalesOrder, CheckInVisit, CheckOutVisit, ConfirmPick, ConfirmPutAway); prices are rechecked before submit on SalesOrder. ApproveSalesOrder / RejectSalesOrder skip partition and reprice — JWT + Manager allow-list is enough. Optional fields writes typed header strings (not GUIDs, not Subtype) before the script. Warehouse tasks have no Agent column — JWT is enough
LogVisit AgentVisit in Open (not Draft); empty coords → GeoStatus=NoGeo; never writes DateTime.MinValue
ResolvePrice One CachedPrice row from IPricingService / ISalesLinePricing
SubmitGpsTrack One AgentGpsLog row per point

Idempotency: MobileCommandReceipt SliceLast by Key. A replayed key returns the stored result. Reject → the client must create a new key.

Shift GPS#

Background location is tied to a shift, not to the app being installed. StartShift opens the tracker, EndShift and signing out close it — a phone nobody is signed in to tracks nobody. The two commands carry no entity, so no record screen can offer them; the button lives in the role shell. Sign out is under You (avatar with the agent's initials): name, role, stand URL and shift. When the stand publishes more than one seed, You offers chips to switch the active view — that remanifests, clears pull watermarks, and ends a running shift before a role that cannot offer EndShift takes over. The user id is not shown. When the shift is off, the shell also names that dispatch will not see a track and offers Start shift there — check-in still works without it.

A position becomes a breadcrumb when one of these holds:

Trigger Rule
Movement further from the last breadcrumb than 50 m or its own accuracy, whichever is larger, at least 30 s after it, and not implying over 126 km/h
Heartbeat 60 s since the last breadcrumb, wherever the agent is and however poor the fix

The heartbeat matters: a shift with no breadcrumbs cannot be told from a phone in a drawer. The 30 s floor bounds the rate — the location chip reports far more often than a route needs, and every kept point is battery, storage and a server row.

Movement has to beat the error it was measured with, and that is the whole difference between a route and a scribble. A fix the chip stamps ±150 m lands anywhere inside a circle three hundred metres across; the next one lands somewhere else. Counted as movement, a phone on a table walks kilometres. It matters beyond the one row, too: a breadcrumb becomes the anchor the next fix is measured from, so a noisy point accepted as movement also hides the real step that follows it.

A poor fix is still recorded by the heartbeat, deliberately. The desk reads an agent as off shift after fifteen minutes without a point, so a phone that went quiet for want of a good fix would look like a phone in a drawer. The rule is: record what proves the phone is alive, draw only what the phone stands behind — see Field desk (office) for the drawing side.

Breadcrumbs batch into one SubmitGpsTrack ({ points: [ { lat, lng, accuracy?, speed?, battery?, recordedAt? } ] }, camelCase). Readings the device does not have are omitted, never zeroed — a zeroed accuracy reads as a perfect fix. An empty batch is never queued: the server answers ValidationFailed, and a rejected outbox row is not retried on the same key. recordedAt is stamped where the agent stood, not when the push found signal.

Batches leave on EndShift and on every Sync, so a shift that has run for hours does not wait for the agent to clock off to be visible.

StartShift asks the platform for permission before claiming the route is being recorded. Refused, it says the shift is open but NOT recording — an agent told their route is being logged will not check it again, and a silent empty track is indistinguishable from a phone in a drawer.

Android keeps the stream alive only through a foreground service with a visible notification (geolocator starts it); ACCESS_BACKGROUND_LOCATION is a second, separate grant the user makes in system settings. Without it the stream stops when the app is backgrounded and the shift simply records less — nothing is invented to fill the gap.

Photos and printing#

Photos (TakePhoto) are captured as JPEG at 80 % — legible for a proof-of-delivery shot, small enough for a village signal. The quality is one number owned by the media queue and handed down to the camera; the phone does not re-encode, so there is no second image engine on the device. Bytes that come back in a format other than JPEG are queued under their real content type rather than mislabelled.

Photos wait in the media queue, never on the command outbox: that outbox pushes one JSON command at a time and treats a rejection as history, which is right for a command and wrong for a two-megabyte body. Upload is best-effort and deferred — POST /api/data/{tableName}/{recordId}/attachments (multipart, field file) on each Sync. The table is the entity the descriptor put on the command; a photo taken against a local draft has no server record id yet and waits rather than being uploaded to a guess or thrown away. After the stand has the file, opening the visit (agent workspace or Manager card) lists GET /api/data/AgentVisit/{id}/attachments and downloads the images — not PDFs — into SQLite so the gallery is still available offline. The upload queue is emptied once the POST succeeds; this fetch is how the photo comes back. A tap on the thumb opens the same full-bleed viewer as a catalogue pack — the gallery is a proof shot, not a 72px stamp.

Printing (SharePdf) uses only the server pipeline: GET /api/metadata/print-forms/{id}/render/{documentId} (Bearer), then the OS share sheet. Online it is always a fresh render; the last render is cached as the fallback for a van with no signal. Offline with nothing cached, the agent gets a sentence — the phone never builds a document, because a second print engine would diverge from the office copy the day a template changes. The file name and content type are whatever the stand sent: the Zr engine renders HTML and the Xlsx engine a workbook, so nothing is renamed .pdf.

Which form is printed comes from the descriptor: layouts carries "<Entity>.printForm": "<print form metaId>", so an administrator publishing a SalesOrder form changes what the phone prints without a release. A build can pin a fallback with --dart-define=ZULOONE_PRINT_FORM_ID=<metaId>; the descriptor wins over it. With neither, the agent sees a message naming what is missing and nothing is rendered. SalesAgent.json publishes SalesOrder.printForm → OrderZr: the stand's Zr engine, the same sheet the office prints, with the customer and item names. A draft still has no id, so SharePdf stays off it.

Where a command is offered#

The descriptor is the allow-list; the screen decides only where a command can work. A local phone draft (ID = Draft) has no server id, so SharePdf and CheckOut are not on it. A numbered stand draft (Subtype = Draft, with a real document number) is still writable: PostOrder, SaveDraft and ScanBarcode stay live, and SharePdf is live too because the stand already gave an id. Posting that row sends CreateSalesOrder with documentId so the stand updates the same document instead of creating a second one. A posted record (Queued, or any subtype other than Draft) has no draft to edit, so those three stay off it. A button that exists only to apologise is worse than no button.

In the Warehouse role, ScanBarcode is offered in a different place: there is no New Pick, so the camera is on the numbered PickTask / PutAwayTask. A HID wedge (TSD keyboard) types into the Scan field on that same card — same tick as the camera, no Zebra SDK. A hit ticks the office-authored line; a SKU that is not on the task gives a warning, not a new row. ConfirmPick / ConfirmPutAway stay record-side as before.

Adding a line opens the catalogue sheet: a tap puts the current qty on the order and the sheet stays open for the next SKU. Done closes it.

CheckIn is bound to VisitRoute, but a visit is logged against a CUSTOMER and the route header has no customer — the customers are on the route's Stops. So the Today tab draws those stops on the day card (and the route record still has them). Each open stop carries its own Check in, passing the command the customer, outlet, route and stop number that stop already holds. Check-in then opens the visit workspace — the customer's contract and recent orders/visits already on the phone, write an order (header seeded from the same field map plus the live contract when the form lists Contract — payment and delivery terms ride along when those fields are on the order too; the contract's number and a visit subtype do not), take a photo, and Check out. The contract row names its payment and delivery terms from the dictionaries the pull sent — not the GUIDs. On the local stand Dispatch Ivan's day is a two-stop book: Khreshchatyk Kiosk (Front door) already Done, Podil Cafe (Porch) still Planned (11:30 – 13:00). Both customers have an active 2026 credit contract (Net 30 / DAP). The van catalogue is three sellable SKUs with Base sale prices — Coffee Blend 250g (₴185, barcode 4820001122334; 10+ is ₴170.20), Still Water 0.5L (₴18.50), Dark Chocolate 80g (₴62) — so a Sync fills the catalogue, CachedPrice and the visit card. Without those rows the pull sends an empty catalogue and no contract dictionaries. Photos taken on the visit stay on the workspace until they are uploaded to the stand. A recent order on that workspace opens the same card as Orders — so Share PDF is one tap, without leaving the visit for the list. An order created on this visit is already in that list when the agent comes back (Queued until the stand numbers it). Save Draft (and Back when the form already has lines) keeps the same order as Draft — opening it continues the lines; posting then turns it Queued. A stop window is a clock (09:00 – 11:00), even when the pull stored a DateTime. The visit workspace shows how long the agent has been there, and No GPS when the check-in had no fix. Check-out asks for an optional note (empty is a normal close); that is not Skip — Skip from Today still requires a reason and never checks in. An open stop also swipes: Check in one way, Skip the other. Skip reason and line quantity open as a sheet, not a blocking dialog. Lists paint a skeleton while SQLite is still reading. Check-in, skip and check-out buzz the phone. The customer card is a contact card: the name is the title, Call / Map are on the hero area, outlets and contracts that point at this customer are listed, and a GUID stays hidden. The same card is the field snapshot: last order and what was on it, what sells here from the lines already on the phone, the visit log, notes (Notes / skip reasons), and the credit limit when a CreditLimit cell is present. Outstanding is the live Receivable balance (CachedReceivable on pull) for this customer — not the sum of orders sitting on the phone. Those orders stay as last time / what sells here. A Today stop and the visit workspace offer the same Call / Map from a Phone field and the resolved Address line — the client does not hardcode Customer columns. Check-in opens the same book on the visit workspace (outstanding, last order, what sells here), so the agent does not have to leave the stop for the customer card. A picture on a catalogue row (a PNG/JPEG payload, fetched as the agent scrolls) is a thumbnail; tap opens the package image full-screen. The catalogue also names on hand from CachedStock when the pull sent a qty for that item — two cells add up, and a missing row is unknown, not a made-up zero. Warehouse Cells uses the same projection keyed by CellId so a bin card is the stock in that cell, not the van total; the catalogue card lists those bins. The phone does not refuse a line because of that number; stock sufficiency stays on the server. Catalogue search is name/barcode plus chips: every Item field that names another synced master (ItemGroup, Brand, …) becomes a filter, and the agent search also matches the formatted characteristic text from CachedCharacteristics. The same sheet opens when you add a line to an order. A tap on the tile opens the item card (photo, price, stock, properties by section); Add puts the line on the order. The role pulls those masters so the chips are names, not GUIDs. A stop that names an outlet shows the outlet's name, not its id. Today is a day card, not a list of framed tiles: the next stop is large with a filled Check in, done stops are muted with a check, and stacked open visits for the same customer collapse to one Continue row. Under 840 logical pixels the tabs sit in a floating glass dock; Start/End shift sits behind More so Sync and You stay on the bar. At Material expanded width tabs become a NavigationRail and the shift pin returns to the app bar — captions still come from the descriptor. Lists and forms cap at 720 wide so a tablet does not stretch one column. ThemeMode.system follows light/dark. Surfaces stay the same lifted cards. Check-out queues ExecuteCommand against the visit id (the LogVisit clientRef until the push remaps it). An optional skipReason on that payload is written to the visit before CheckOutVisit runs, and the stop is marked Done so the next pull does not reopen it. If the visit is already Closed (a replay after the first checkout landed), that is success, not UnknownCommand. Skip on an open stop asks for a reason and queues the same LogVisit with skipReason and checkedOut — the stand marks the stop Skipped. The stop is changed locally to InProgress on check-in, Done on check-out and Skipped on skip so Today will not offer a second Check in before the next pull; an unfinished visit stays under Open visits and reopens the same workspace. The cloud badge opens the Queued list (what is waiting to push), not a silent sync. The stop is named by the customer's name, not by the id on the row.

What survives a dead battery#

Nothing the agent did is in RAM only. The command outbox, the role descriptor and its schemaHash, the per-entity watermarks, the last agentId, the draft→server id map, the photo queue and order drafts (ID = Draft, with their lines) all live in SQLite, and the app comes back holding them. The JWT and refresh token live in secure storage, never SQLite. A command that was mid-push when the phone died comes back queued, not lost, and keeps its idempotency key — the server's receipt decides whether it was already applied. A dropped connection is the same: queued, same key, not a rejection.

CachedPrice is a projection, not a change list: a pull replaces the local set rather than merging into it, so a tier the office withdrew disappears from the van. CachedReceivable and CachedStock are the same kind of set. A ResolvePrice answer is written straight into that cache, so NeedsOnlinePrice clears without waiting for a full pull. Adding the same item again grows that line; a quantity change re-picks the MinQty tier. The phone still does not compute a price. Tapping a line names why it costs what it costs (source, explanation, list, quantity break); a missing cache says Needs a price, not the wire flag NeedsOnlinePrice.

When the server says no#

A rejection carries the server's own sentence, not just a code, and the agent can read it: the sync banner links to a Rejections screen listing every refused command with its message. A queued ExecuteCommand is named by the document command (Check Out Visit), not by the plumbing action. From there one is retried — requeued under a new idempotency key, because the receipt would replay the refusal on the old one — or dropped; Retry and Drop are on the row and on a swipe, same as Today's Check in / Skip. A CreateSalesOrder the server refused also drops the ExecuteCommand that was queued to post it, so the agent sees PriceMismatch rather than a second failure saying the document does not exist.

The descriptor is re-asked for on every Sync, not only at login, so a change an administrator makes reaches a phone that never signs out.

Field desk (office)#

The phone records the day. The office reads it on Ribbon → Sales → Field → Sales agents desk (/field-dispatch): four KPI tiles (on shift, route progress, tasks, orders), then the same map and agent column as before. The home dashboard widget Sales agents desk (Пульт торговых агентов) is the compact preview — same snapshot API (GET /api/mobile/field-desk with the same local-day from/asOf as the ribbon page): every SalesPerson, last AgentGpsLog fix, whether that fix is younger than 15 minutes (on shift), today's VisitRoute and AgentVisit. A visit is attributed to an agent through its route — AgentVisit has no Agent column. A visit without a route still appears in visits, listed as Visits without a route until an agent is selected; it is never counted on an agent card.

What the track draws#

The register keeps every fix the phone sent. The line is drawn from a filtered copy of it, because a heartbeat is honest storage and dishonest geometry:

Dropped from the line Why
Accuracy worse than 50 m a position that could be anywhere in a 300 m circle is not a position. A fix with no accuracy at all is kept — those predate the device sending one, and refusing them would blank those days
Over 126 km/h from the previous drawn fix, within a 5-minute window the backstop for a fix that lies about its own accuracy; a cell-tower position can be stamped ±20 m and still be a kilometre out. Beyond 5 minutes a distant fix is a gap, not a teleport, and is kept
— after 3 consecutive refusals the next fix is believed otherwise one bad fix that got through becomes an anchor that rejects the rest of the day, and the track stops dead at the error

The 50 m is measured, not guessed. On one representative day — 78 fixes, accuracy 3.6 m to 300 m, mean 105 m — the extent of the drawn track holds steady as the threshold moves, then falls off a cliff:

Limit Fixes drawn North–south East–west
15 m 16 455 m 154 m
30 m 30 455 m 182 m
50 m 32 455 m 182 m
100 m 49 501 m 465 m
none 78 566 m 581 m

Everything from 15 to 50 draws the same walk — a corridor along one street, which is what the agent did. At 100 the east–west spread nearly triples and the web is back. 50 is the top of that plateau: the most fixes kept for the same shape.

Filtering runs on read (FieldTrackFilter), so it also cleans every track already recorded — nothing is deleted from AgentGpsLog, and Agent GPS log still shows the raw rows. It runs before the 2000-point-per-agent cap, so a noisy day does not evict the real points.

On the map, a silence longer than 10 minutes breaks the stroke instead of joining it. The phone heartbeats about once a minute, so a ten-minute gap means it was not reporting — drawing a straight line across it asserts a road the agent may never have taken, and the longer the gap the more confident and the more wrong that line looks.

The Field ribbon column contains Sales agents desk (shared GeoMap control: KPI strip, last pin + the day's track), Sales dispatcher (SalesDesk), the raw register (Agent GPS log), today's assignments (VisitRoute) and the visit documents (AgentVisit). The Manager phone view opens the same last pin (Map) and the same breadcrumbs (Track) in Google Maps; it does not include a second map engine. A route card shows the SalesPerson from VisitRoute.Agent (the column is Agent; the dictionary is SalesPerson) and the day's stops without Check in. The team card lists that agent's routes (VisitRoute.Agent) and the visits attributed through those routes — AgentVisit has no Agent column, so a visit without a route never appears on the person card. A route lists its visits (AgentVisit.Route). The Visits list names the customer and the Routes list names the agent — a GUID is not a subtitle. A visit card names the agent through that route; a visit without a route does not invent one.

An order written on a visit stores it in the Visit field. After saving, this order appears among the visit's related documents: the parent is the visit, the child is the order. Visit printing (AgentVisitZr) lists the numbers of those orders and their subtype. There are no amounts on the visit form: money stays on the order. An order without a visit does not appear on the card. Moving the order to another visit moves the relation too.

The Visits list groups the day by the agent on the route and puts those without a Route under Visits without a route, the same caption as the office widget. Search follows that name — AgentVisit has no Agent column. Routes group the same way by VisitRoute.Agent. The list and the card name a skip reason and how long the stay lasted (check-out minus check-in), not a date stamp. The Manager phone picks the desk day with Today / Yesterday (seven days back, not tomorrow); that pull sends from/asOf, the same window as office Field desk, so yesterday's routes are not mixed with today's. Team is a roll-call: on shift first, then name, and the row names that day's visit and route counts — a visit without a route is never counted on the person. A naive last-fix timestamp is UTC, same as the SPA, so Kyiv does not add three hours. Opening a visit dials the customer the desk already pulled (Phone on that row) and maps the check-in Lat/Lng when the visit has them. A NoGeo visit still opens Map: the outlet address when the visit names one, otherwise the customer Address — not a guessed pin. The card shows the outlet name, not its GUID. Click a name on the widget or the dispatch form to see that agent's routes, visits and breadcrumb line (FieldDeskDay is shared). A click on a route or visit opens that document. The widget's Field desk button (or a double-click) opens the same map. A click on a visit square selects its agent; the map Follow control keeps the last pin centred when the next heartbeat arrives. The dispatch day picker asks GET /api/mobile/field-desk with from/asOf so yesterday's routes are not mixed with today's. Tile URL, attribution and the empty-view centre live in Administration → Map (/map-settings), not in the Sales model.

Last-fix age is UTC on both sides: the server stamps DateTimeKind.Utc so JSON carries a Z, and both the SPA and the Manager phone treat a naive ISO stamp as UTC. Without that, a Kyiv client reads "183 min ago" on a pin that is three minutes old. The phone ages the pin against the desk asOf, not wall clock, so yesterday is not "14 h ago".

Assignments are VisitRoute, not a second task document. An external CRM is out of scope; the visit already names the customer.

What's not there yet#

  • There is no iOS build and no App Store / Play Store distribution; installation is the Android APK from the site.
  • The phone does not compute final prices or stock sufficiency; posting rechecks both on the server.
  • The app does not contain a second map engine; Manager map actions open Google Maps.
  • The phone never renders print forms itself; it downloads a server-rendered form or uses the last cached render.
  • Pack photos are not in the pull JSON. The catalogue fetches one picture per visible row (GET /api/mobile/picture) and keeps it on the phone.

Same topic for other readers: User · Developer

Where next#

Was this page helpful?