Skip to content

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 issue without from_location_id changes nothing).
  • A movement whose type is 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_stock is 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.

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.

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:

  • issue and transfer: 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: 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.

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.

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
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.

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 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.