Derived state
Some of what users see is never stored in a record: stock levels, where a serial number is, whether a loan is overdue. Every client derives these values from the records with the rules on this page. Because the inputs are the same on every device once they have synced, the results are the same too. Servers MUST NOT store derived values as records; a server may compute them for its own read model or for an ERP.
Stock is the sum of all movements, per article and location.
Movement type |
Effect |
|---|---|
receipt |
adds quantity at to_location_id |
issue |
subtracts quantity at from_location_id |
transfer |
subtracts quantity at from_location_id and adds it at to_location_id |
adjustment |
adds quantity at to_location_id; quantity is signed (negative when the count was lower) |
Rules:
- A side whose location id is missing or null has no effect (an
issuewithoutfrom_location_idchanges nothing). - A movement whose
typeis not one of the four values, or whose article is unknown to the client (never received, or deleted with a tombstone), is skipped. Archived articles still count. - The order of movements does not matter for stock. Stock may become negative; that is a valid state on a local-first system (two devices issued the last unit offline) and is shown as such.
- The reference app shows a stock row only when its absolute value is above 0.0005, and only for locations it knows (a location record exists, archived or not). An article's total stock is the sum of the rows it shows.
- An article is low on stock when
min_stockis set and the total stock is below it.
Stock counts are booked as adjustment movements that store the difference: when a device counts 17 units where it computed 20, it writes quantity: -3, counted: 17. Concurrent counts on two devices therefore add their differences; each device should count again after syncing if both counted the same shelf.
Serial numbers
Section titled “Serial numbers”Articles with track_serials: true carry one serial number per unit in the serials of every movement. A serial number is identified by its article and the serial text compared case-insensitively (DR-1001 and dr-1001 are the same unit).
The state of a serial number is given by the latest movement that contains it. "Latest" is the order of booked_at, then hlc for movements with the same time; a movement without booked_at sorts first.
| Latest movement | State | Location |
|---|---|---|
receipt |
in stock | to_location_id |
transfer |
in stock | to_location_id |
adjustment |
in stock | to_location_id (also for a negative adjustment) |
issue |
issued | none |
An issued serial number that is among the outstanding serials of a loan (see below) is shown as "on loan" with the loan number, the borrower and the due date.
Booking checks on the device
Section titled “Booking checks on the device”The reference app checks a booking against its current data before writing the movement. These checks are not protocol rules (a server must accept any movement), but a client SHOULD apply them so that users do not book what is not there:
issueandtransfer: the quantity must not exceed the stock at the source location (with a tolerance of 0.0005), and every serial number must be in stock at that location.receipt: no serial number may already be in stock.- Articles with serial tracking: the number of serial numbers must equal the quantity, except for
adjustment. adjustment: the counted quantity must differ from the current stock.- A delivery note or loan with several lines is checked line by line against the stock left by the lines before.
Archived records
Section titled “Archived records”archived: true on an article, location or recipient hides it from lists and pickers. Archived records keep their names in the history: movements, delivery notes and loans that reference them still show them. The reference app archives instead of deleting once a movement references an article or location, and deletes (tombstone) only records without history.
Location tree
Section titled “Location tree”Locations form a tree through parent_id. The path of a location is the names from the root to the location, joined with " › ". A missing parent ends the path. Concurrent edits can create a cycle; readers MUST stop when they reach a location they have already visited.
A loan record lists the items handed out and every return. What is still out is derived from them.
Per item
Section titled “Per item”For each item of items, go through returns in the order of returned_at and take every return line whose position equals the item's position (lines with an unknown position are ignored):
| Value | Rule |
|---|---|
| returned quantity | sum of quantity of lines with condition ok or damaged |
| lost quantity | sum of quantity of lines with condition lost |
| outstanding quantity | quantity minus returned minus lost, at least 0 |
| outstanding serials | the item's serials that appear in no line's serials (compared exactly in the reference app) |
| outstanding accessories | the item's accessories that appear neither in any line's accessories nor in its lost_accessories, compared case-insensitively; empty when the lost quantity is at least the item's quantity (an item reported lost as a whole takes its accessories with it) |
| outstanding | outstanding quantity above 0.0005, or at least one outstanding accessory |
Per loan
Section titled “Per loan”| Value | Rule |
|---|---|
| status | closed when no item is outstanding; otherwise overdue when overdue days are above 0; otherwise open |
| overdue days | full calendar days from the day of due_at to today, in the time zone of the reading device, at least 0; 0 without due_at |
| total value | sum over all items of replacement_value times quantity; items without a value count 0 |
| outstanding value | sum over all items of replacement_value times outstanding quantity |
| accrued penalty | while something is outstanding and the loan has a penalty: the penalty amount for the overdue days and the total value (below); none when it is 0 |
due_at is the last day to return on. A loan due on 19 October is open all of 19 October and overdue (1 day) from 20 October at 00:00 local time. Because the day is computed in the reading device's time zone, two devices in different time zones may disagree by one day around midnight.
Penalty amount
Section titled “Penalty amount”The penalty of a loan is a scale of daily rates:
amount(overdue_days, total_value): if overdue_days <= 0: return 0 tiers = penalty.tiers sorted by after_days ascending total = 0 for day in 1 ... overdue_days: rate = per_day of the last tier in tiers with after_days < day, or 0 if none total = total + rate cap = infinity if cap_amount is set: cap = min(cap, cap_amount) if cap_percent is set and total_value > 0: cap = min(cap, total_value * cap_percent / 100) return min(total, cap)With the reference app's suggested scale (5 per day after 2 days of grace, 10 per day from the 15th late day, at most 30 percent of the total value) and a total value of 189.90:
| Overdue days | Amount |
|---|---|
| 1 to 2 | 0 |
| 3 | 5.00 |
| 10 | 40.00 |
| 15 | 56.97 (60 + 10 = 70, capped at 30 percent of 189.90) |
A percentage cap needs stated values to refer to: on a loan without replacement values (total value 0) it does not apply, and only cap_amount, if set, limits the penalty. The return protocol shows the penalty for the days between due_at and returned_at of that return, for information; the protocol does not book or invoice penalties.
Replacement value
Section titled “Replacement value”replacement_value is the value per unit the borrower agreed to replace at handover. Without it, the conditions say that the usual replacement value of a comparable item in corresponding condition applies at the time of the loss; documents print "usual value" in the value column and a note explaining this. Lost accessories never have a stated value and always fall under that note.
The machine-readable cases are in test-vectors/loan-state.json and test-vectors/penalty.json.