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.md for the release history.

Scope

Stock per item and location

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:

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:

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:

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.

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:

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:

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.