PhoenixKit Warehouse
Warehouse module for PhoenixKit.
A drop-in PhoenixKit module — add it to a host app's deps and it is
auto-discovered, adding a Warehouse section to the admin panel. Like every
PhoenixKit module it has no endpoint, router, or Ecto repo of its own; it
borrows the host's via phoenix_kit.
Status: implemented. Stock keeping, the document set and their posting rules below are in use; see
CHANGELOG.mdfor the release history.
Scope
Stock per item and location
- One stock row per catalogue item and location (a PhoenixKit Locations
record of the configured warehouse location type):
quantity(never negative, enforced by a database check) and an optionalunit_value. - A default warehouse setting decides where a document lands when none is chosen. The admin UI also has a stock list, per-item minimums with a deficit view, and a turnover report (inflow and outflow per item over a date range).
Documents
Documents are created as drafts and take effect when posted (a transfer ships and then is received). Each has a number drawn from its own sequence, and a draft can be soft-deleted. Most also have a storage folder for attachments and a comment thread.
| Document | Effect on stock |
|---|---|
| Goods receipt | Adds each line's received_quantity (and sets unit_value when the line carries one). Can be created from a supplier order. |
| Goods issue | Subtracts each line's issued_quantity, never below zero; if any line is short the whole posting rolls back and the document stays a draft. Carries a reason: production (the default), sale, damage or other. Can be created from internal orders. |
| Inventory document (stocktake) | Sets the stock of each listed item to its counted_quantity (absolute, not a delta); other stock rows are untouched. Can be re-posted. |
| Transfer | Shipping subtracts from the source warehouse, receiving adds to the destination; cancelling an in-transit transfer credits the source back. |
| Internal order, supplier order | None. They record demand and procurement and feed the documents above. |
Posting semantics worth knowing:
- Posting is atomic and a compare-and-swap on status: the document row is locked and re-checked as a draft inside the transaction, so a repeated or stale post cannot apply stock twice.
- Every posted line records the previous quantity it replaced or changed. For an inventory document that quantity is read inside the posting transaction under a row lock, so the audit trail stays exact when other documents post concurrently.
- After posting, a goods receipt or goods issue keeps its lines; only the note and the storage folder can still be corrected.
Drafts, the trash and one draft per source
Every warehouse document (internal order, supplier order, goods receipt, goods
issue, stocktake, transfer) that is a draft can be moved to the trash (a
soft delete, deleted_at): Move to trash in the row menu of its list (the
card view too) and in its form, after a confirmation. A document that may not
go shows the item and the button disabled, with the reason (posted; for a
transfer: in transit, done or cancelled). The lists have an Active / Trash (N)
tab pair (?view=trash; the Trash tab shows while it holds something) with the
deletion date and who deleted the document, and a Restore action for drafts.
A trashed document opens read-only with a banner; editing, posting, links,
correction, the storage folder and the documents made from it are unavailable.
A posted document is never moved to the trash by the module and cannot be
restored from it (legacy rows stay where they are). Saving or posting a
document that was trashed meanwhile answers {:error, :deleted} (the forms
show a flash). A trashed goods issue holds none of its internal orders, so
another issue may import them meanwhile; restoring it is not refused, and the
flash names the internal orders the issues now exceed
(GoodsIssues.overcommitted_internal_orders/1).
For a draft transfer, Cancel and Move to trash differ: Cancel closes it
for good as a cancelled transfer that stays in the list, Move to trash hides
the draft and Restore brings it back as a draft. A transfer in transit is
cancelled (which credits the stock back to the source), not trashed. A transfer
in transit that older code moved to the trash can still be received or
cancelled, so its goods do not stay on the road.
Hosts that want one draft per source use these functions:
InternalOrders.replace_draft_for_source/3creates a draft and sends the live drafts whose only source is the same{type, uuid}to the trash in one transaction. Posted documents, combined drafts (several sources) and other sources are never touched.InternalOrders.list_for_source/2andmulti_source?/1help a host show what exists.SupplierOrders.generate_from_internal_order/3(andapply_plan/5, which writes a plan a person has seen) always keeps one draft per (internal order, supplier): what is still missing is appended to the live draft the supplier already has for the internal order - a draft of this order alone, else a combined draft that refers to it, the one with the highest number - and a new draft is made only when there is none. Nothing goes to the trash; the oldsupersede_drafts: trueoption is accepted and does nothing. The quantity appended is what the internal order needs minus what was issued for it, what is on hand and what its live supplier orders already bring, so a line the keeper reduced or removed is offered again; the keeper's other edits and the note stay.restore_internal_order/3andrestore_supplier_order/3bring a trashed draft back; withswap: truethe live one-source drafts of its source (pair) go to the trash, so the rule holds after the restore too.
The restore swap is opt-in by call. The admin UI follows one switch, off by default:
config :phoenix_kit_warehouse, one_draft_per_source: true
With it on, Restore passes swap: true. Moves to the
trash and back are logged as warehouse.<kind>.trashed|restored activities
(internal_order, supplier_order, goods_receipt, goods_issue,
inventory, transfer; after the transaction commits, subject to
:inventory_activity_logging).
Supplier orders from an internal order
Generate supplier orders on a posted internal order opens a preview first
and writes nothing until Generate is pressed. The window is drawn from the
plan (SupplierOrders.plan_from_internal_order/2) and assemble/2, so it
shows exactly what will be written:
- the supplier groups - a new order, or the live draft the supplier already has for this internal order, with the warning that what is missing will be added to it and, per line, "now N → N+k" in that draft;
- per line the item, SKU, required, issued, on hand, already ordered and to order, the reason for its supplier (primary supplier of the item, the only supplier of the item, the only supplier of the manufacturer, chosen by hand) and a Why with the line's explanation (its comment);
- the lines without a supplier or with several: a supplier is chosen per line
or for all ticked lines at once (candidates of the item first, then every
active supplier; the default is not to order); any automatic supplier can be
replaced as well. A choice is for this generation only. Such a line whose
item already lies in a live draft of this internal order with an active
supplier is preselected to that supplier ("as in
#SO-…"; of several such drafts, a draft of this order alone, then the highest number), so the missing rest goes into that draft - also whengenerate_from_internal_order/3runs without the window; "do not order" or another supplier can still be chosen; - the lines with nothing to order, with the reason (issued, covered by stock,
already ordered in
#SO-…, item not in the catalogue).
Generate calls SupplierOrders.apply_plan/5 with the plan's fingerprint and
the choices. When anything the plan decides on changed meanwhile (stock,
supplier links, the supplier orders of the internal order, an active
supplier), nothing is written and the window shows the new plan with the
message to check and press again; a choice is kept when its line still allows
it, the other lines are marked as changed. A second press of a plan already
written is such a stale plan, so it writes nothing twice.
One document per supplier: what is missing is appended to the supplier's live draft for the internal order (see above), never as a second draft, and nothing is moved to the trash. The quantity of a line is
to order = required - issued for this internal order - on hand at its
location - already ordered for it
where already ordered is what its live supplier orders still bring: a draft
counts its lines, a posted order what it has not delivered yet
(ordered - received), a combined order the share its source_refs give this
internal order. A line the keeper reduced or removed from the draft is
therefore offered again by the next generation. Not counted: reservations of
other internal orders and min_stock; a receipt to another location still
counts as delivered.
Generated supplier orders carry a source_refs entry to the internal order
with the ordered quantities per item, so the supplier order form shows them
under Source documents and an import from internal orders subtracts them.
The import does not subtract what was issued for the internal order, so
importing it into a draft generated from it orders the issued quantity again.
When that ref is removed, a generation still counts the supplier order for
the internal order by internal_order_uuid, by its actual lines.
A supplier that is a CRM company in the catalogue is matched to the local
supplier row projecting it (crm_company_uuid); a link that resolves to no
local, active supplier is no candidate.
The supplier order form writes a draft only if it is still the version the
form loaded (SupplierOrders.draft_version/1, the expected_version: option
of update_draft/3, post_supplier_order/3, add_source_ref/4,
remove_source_ref/4 and import_from_internal_orders/4): a draft a
generation appended to meanwhile answers {:error, :stale}, and the form
shows the current version instead of overwriting the appended lines.
Programmatic posting
For callers that are not a person at a form (a shop, a POS, an integration),
GoodsIssues.create_and_post/2, GoodsReceipts.create_and_post/2 and
Inventories.create_and_post/2 create and post in one transaction: a
call refused by validation or the stock check leaves no draft and consumes
no document number. A failure after the insert (a database error while
posting) rolls the draft back too, but the number sequence has advanced.
- Each takes the document attributes and the posting user's UUID.
- Duplicate item lines are refused (
{:error, {:duplicate_lines, uuids}}) rather than merged or dropped. A goods issue reports every short line at once ({:error, {:insufficient_stock, [%{item_uuid, requested, available}]}}). - All three accept an
external_ref, the document's number in the outside system. It is unique per document type among posted, non-deleted documents, and a repeat returns{:ok, :already_posted, existing_document}without writing anything, so a retried delivery is harmless. The lines of the repeat are not compared with the existing document's. Inventories.create_and_post/3takesskip_unchanged: true: a stocktake where every counted line already holds its quantity returns{:error, :unchanged}and writes nothing, so itsexternal_refis not recorded either and a later call with it runs as a new stocktake.
Linking documents to host records
Documents link to host-owned records (orders, sub-orders, ...) through
source_refs and the generic PhoenixKitWarehouse.SourceKinds registry, so
the package has no dependency on any specific "order" concept. Kinds come from
the host's config :phoenix_kit_warehouse, :source_kinds list and from any
registered PhoenixKit module that exports warehouse_source_kinds/0; the two
are merged by kind, and the host's definition wins on a conflict.
Admin API tools (shop API)
On a host that runs PhoenixKit core with the shop API and has switched the admin
API on (Settings -> API), this module offers five tools to API keys. A key sees a
tool only while the Warehouse module is enabled and only if the key's scopes and
its owner's warehouse permission allow it. The three write tools are offered
only on a core that can hold a dangerous call for confirmation; on an older core
only the two read tools are.
| Tool | Scope | What it does |
|---|---|---|
warehouse.list_locations |
warehouse.stock:read |
warehouse locations, the default one, and how far setup has got |
warehouse.get_stock |
warehouse.stock:read |
quantity and unit value per location and the total, with the product's unit, for up to 50 products |
warehouse.receive_stock |
warehouse.stock:receive |
one goods receipt with up to 500 lines |
warehouse.set_stock |
warehouse.stock:adjust |
one stocktake that sets the listed products to counted quantities |
warehouse.issue_stock |
warehouse.stock:issue |
one goods issue (write-off) with up to 500 lines |
How they behave, in short:
-
A product is named by a ref: exactly one of
id(the catalogue item UUID),skuorexternal_id. Only live (not trashed) catalogue items are accepted;skuandexternal_idneed a catalogue release with the shop API support. Refs of every kind resolve inside one catalogue:catalogue_idis required when the shop has more than one active catalogue (otherwise the only active one is used; the refusal lists the catalogue ids to choose from asallowed); a deleted catalogue is refused, an archived one can be read but not written. An ambiguous ref is reported with thecandidatesit matches, at the top level of its row error. -
Each write tool creates and posts one document in one transaction. If any line is wrong nothing is posted and every problem is reported at once; a product on two lines is refused (
duplicate_line);issue_stockreports every short line (insufficient_stock) and posts nothing. The answer is422 insufficient_stockwithposted: falseandcontext.lines[{id, ref, sku, requested, available}]:idis the product,refthe one you sent,requestedandavailabledecimal strings in the product's unit (theunitthatget_stockanswers). -
Through the HTTP/MCP executor the closed input schema refuses malformed shapes first, with
context.errors[{path, message}]and no rowcode; the row errors with acodeare for what the schema lets through. -
A call with more lines than core's
api_confirm_batch_oversetting (50 unless the host changed it) is held for confirmation: the first call answers a preview and aconfirm_token, the same call with the token posts it. This holds for a retry of a postedexternal_reftoo. -
dry_run: true(a control field every write tool takes) answers what the call would do (would_post,would_skip,unchangedfor a stocktake that changes nothing, or the same refusal a real call gives) and writes nothing. A stocktake is worth previewing: thefromof each line is the current stock. A preview that answersunchangedhas nothing to confirm: the confirmed call would post no document either. -
Every posted line answers the stock before (
from) and after (to). A confirmation code is bound to what the preview showed, including the current stock of every line: any other document that moves one of those products before the code is used invalidates it (409), and the call is previewed again. -
A posting that Postgres aborts to break a lock cycle with another document (a deadlock) is answered
500 internal_errorwithposted: falseand aretry-afterof 1 second; nothing was changed and the same call can be sent again. A call that carried a confirmation code (above the confirmation threshold) has spent it: send it again without the code. -
set_stockthat changes nothing (every listed product already holds its counted quantity) creates no document and burns no document number: it answersstatus: "unchanged"with theresults(action: "unchanged") and nodocument. A stocktake where at least one line changes is posted whole. After anunchangedanswer theexternal_refis not recorded (there is no document), so a later call with the same reference runs normally; useIdempotency-Keyfor replays. -
external_refcarries your own document number: sending it again answersskippedwith the existing document and moves no stock (for a posted document; anunchangedset_stockposts none, see above). Onissue_stocksource: {"kind": "external", "ref": ...}is the same reference (do not send both). The retry is answered from the posted document before the lines are checked (a product trashed or a default warehouse changed since does not matter). What a retry is compared on: the lines (the product and the quantity of each, and for a receipt theunit_valueof each), an explicitlocation_id, an explicitcatalogue_id, an explicitreason, and the shop order of thesource. Alocation_idorcatalogue_idleft out is not compared (the default warehouse may have moved), and neither isnote. A request that differs is refused as422 validation_failedwithposted: false, the posteddocumentandcontext.errors[{path, code, message}]; it is never answeredskipped, so a client that reads only the status cannot take a different request for one that went through. Nothing is posted. The lines of a retry are checked as in a first call: a line that a first call would refuse (a wrong quantity, an id that names no product, a product twice) is refused with the same errors, not answeredskipped. The codes:external_ref_lines_differ: the lines are not the posted ones.external_ref_basis_differs: a statedlocation_id,catalogue_id,reasonor shop order (source) is not the posted document's (the message names which).
When a product no longer resolves (trashed since), its ref must match a stored product identity. Quantities, receipt unit values and duplicate products are still checked. A matching retry is answered
skippedwith anexternal_ref_lines_unverifiedeffect, which a client must check. New documents retain productexternal_idrefs in their lines for this comparison. For older documents without that snapshot, use the product UUID if its external ID no longer resolves; an unknown external ID is refused. -
issue_stocktakes a requiredreason(sale,damage,production,other) and may link the write-off to a shop order withsource: {"kind": "shop_order", "id": ...}(needs the E-commerce module, enabled, which registers that source kind). An issue with neither anexternal_refnor an external source is not protected against a repeated request: if a call is repeated after a crash between the posting and the answer, the stock can be written off twice. Send a stable reference whenever a retry is possible. -
Quantities are decimal strings (or numbers) from 0 to 1000000000 with at most 6 decimal places; receipts and issues need more than zero, a stocktake accepts 0.
-
Stock is written only to active warehouse locations; without
location_idthe default warehouse is used, and without one the call fails and names the setting. -
Each posted document writes one Activity entry (
warehouse.goods_receipt.posted,warehouse.goods_issue.posted,warehouse.inventory.posted) withvia: api, the key prefix, the request id and, for an operation released from the approval queue, the approver. These entries follow the module's:inventory_activity_loggingswitch: a host that switched that log off gets no module entry for API documents either (core's ownapi.requestentry is separate and does not name the document), and a failed write of an entry never fails a document. The document itself still records its number, itsexternal_ref, its posting time and the key owner as its creator. -
The tools follow this module's own enabled state, not the shop's "Stock tracking" switch.
Photo inventory (AI)
In a draft stocktake a person can count from photos: Count from photos on
the Items tab uploads the photos of one place (one upload is one batch of one
place, up to warehouse_inventory_ai_max_photos, 10 by default, at most 20 MB
each, HEIC included), the AI proposes the catalogue items it sees and how many
(pieces counted where they can be seen, as in a transparent bag; closed
packages and their fill turned into the item's unit; an item kept in packs
counted in packs, never in pieces), and the person reviews
the proposal as a table, corrects it and applies the ticked rows. Nothing is
written to the lines without Apply; the document stays a draft and is
conducted as usual. What the module does around that:
- Where it runs. The call goes through
phoenix_kit_ai(PhoenixKitAI.describe_image/3, one call per batch, in a background task that writes its answer itself, so a closed page loses nothing). The button shows only while the ai module is on, an endpoint is chosen in the warehouse settings and the person may edit the draft (thewarehousepermission, or an Owner/Admin). Choosing the endpoint is choosing where money goes: the settings block is shown to, and saved by, a holder of theaipermission or an administrator only. The money limits are ai's. - Time and cost. One batch is one vision call: the photos and the whole live catalogue in, the counted objects out. Measured on a catalogue of about 600 items with a Gemini Flash model through OpenRouter: 33-75 seconds and about $0.04-0.07 per batch; most of the time goes on the model's output with its reasoning. Narrowing a batch to some catalogues makes it cheaper.
- Settings (
PhoenixKit.Settings):warehouse_inventory_ai_endpoint_uuid(empty: no button),warehouse_inventory_ai_prompt_uuid(the module's own prompt in the ai prompts, made on first use; a prompt edited by hand is never overwritten),warehouse_inventory_ai_max_photosandwarehouse_inventory_ai_max_items(3000 by default: the whole live catalogue goes into the request, and past this many items a person narrows it to some catalogues first). - The photos are stored in the stocktake's own Storage folder (visible on its Files tab) and leave the system for the AI provider: the panel says to photograph shelves, not people.
- The runs are kept in
phoenix_kit_warehouse_inventory_ai_runs(chain version 3): the photos, the place, the model, the checked answer, what was applied and by whom. One run per document reads at a time; a failed one can be retried on the same photos; an answer that comes after a run was closed is kept and shown only on request. Changing the warehouse of the stocktake or conducting it closes its open runs. - The lines carry who gave each number -
"counted_by":"stock"(seeded),"manual"(typed or added by a person),"ai"(applied from photos) - and, for the photo counts, the part of each place ("ai_parts"), so that a place photographed again replaces its own part while another place adds to it; lines no photo saw are not touched. Posting keeps these keys. - Two tabs. Every write of the lines from the stocktake page (Save draft,
Apply, Conduct, the warehouse change) passes the fingerprint of the warehouse
and lines the tab started from: a tab whose warehouse or lines another tab
has changed is refused instead of silently writing over them.
Inventories.update_draft/3checks it only when called withbase_fingerprint:; without the option it behaves as before.
Other modules
Requires phoenix_kit_catalogue (the items), phoenix_kit_locations (the
warehouses), phoenix_kit_billing (currency formatting),
phoenix_kit_comments (document comment threads) and phoenix_kit_ai (the
photo inventory; without the ai module switched on there is simply no button). A plan source for core's
mix phoenix_kit.media.reorganize is included.
Installation
Add to your host app's mix.exs:
{:phoenix_kit_warehouse, "~> 0.3"}
Then fetch deps, apply the module's tables, and enable it in Admin → Modules:
mix deps.get
mix phoenix_kit.update
Removing this module
There is deliberately no automated uninstall.
PhoenixKitWarehouse.Migrations.down/1 never drops any of its
phoenix_kit_warehouse_* tables or a row in them, for any target version —
a host that merely removes this dependency from mix.exs has not consented
to deleting live stock balances and document history, and a migration
whose result depended on which packages happen to be compiled in would be
nondeterministic (it would break core's manifest, chain hash, and squash
verification). Removing the data is therefore a deliberate, manual operator
step, in FK-safe order (children before parents):
-- Only after removing :phoenix_kit_warehouse from mix.exs, and only if you
-- actually want every stock balance and document gone for good.
-- The runs of the photo inventory first: they reference the inventory documents.
DROP TABLE phoenix_kit_warehouse_inventory_ai_runs;
DROP TABLE phoenix_kit_warehouse_goods_issues;
DROP TABLE phoenix_kit_warehouse_goods_receipts;
DROP TABLE phoenix_kit_warehouse_supplier_orders;
DROP TABLE phoenix_kit_warehouse_internal_orders;
DROP TABLE phoenix_kit_warehouse_inventory_documents;
DROP TABLE phoenix_kit_warehouse_stock;
DROP TABLE phoenix_kit_warehouse_transfers;
DROP TABLE phoenix_kit_warehouse_min_stock;
-- The document-number sequences are not OWNED BY their columns, so
-- dropping the tables leaves them behind.
DROP SEQUENCE phoenix_kit_warehouse_goods_issues_number_seq;
DROP SEQUENCE phoenix_kit_warehouse_goods_receipts_number_seq;
DROP SEQUENCE phoenix_kit_warehouse_supplier_orders_number_seq;
DROP SEQUENCE phoenix_kit_warehouse_internal_orders_number_seq;
DROP SEQUENCE phoenix_kit_warehouse_inventory_documents_number_seq;
DROP SEQUENCE phoenix_kit_warehouse_transfers_number_seq;
If you want to keep the tables (e.g. you plan to reinstall the module later) but stop this chain from tracking them, clear the version marker instead — it lives only on the anchor table:
COMMENT ON TABLE phoenix_kit_warehouse_stock IS NULL;
Development
See AGENTS.md for architecture, conventions, testing, and the
release checklist.
License
MIT — see LICENSE.