📱 Mobile client
Updated
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:
- A user in a superuser role (Administrator) may open every view.
- Otherwise the user gets the views of their roles, plus Sales agent when
a
SalesPersoncard names their login. - 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.
CachedPriceis 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.ResolvePriceis 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
CachedPricerow 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 queueingResolvePrice(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