1-- migrations/20260401090000_inventory_ledger.sql
2-- Assumes products and warehouses carry unique (organization_id, id) for composite keys.
3create type movement_type as enum (
4 'PURCHASE_RECEIPT', 'SALE', 'SALES_RETURN', 'PURCHASE_RETURN', 'TRANSFER_OUT', 'TRANSFER_IN',
5 'ADJUSTMENT_IN', 'ADJUSTMENT_OUT', 'DAMAGE', 'LOSS', 'FOUND', 'OPENING_BALANCE',
6 'STOCK_RESERVATION', 'STOCK_RELEASE'
7);
8create type stock_bucket as enum ('on_hand', 'reserved', 'damaged', 'in_transit');
9
10create table inventory_balances (
11 id uuid primary key default gen_random_uuid(),
12 organization_id uuid not null references organizations (id),
13 product_id uuid not null,
14 warehouse_id uuid not null,
15 on_hand integer not null default 0,
16 reserved integer not null default 0,
17 damaged integer not null default 0,
18 in_transit integer not null default 0,
19 avg_cost numeric(14, 4) not null default 0,
20 version bigint not null default 0,
21 updated_at timestamptz not null default now(),
22 constraint balances_on_hand_not_negative check (on_hand >= 0),
23 constraint balances_reserved_within_on_hand check (reserved >= 0 and reserved <= on_hand),
24 constraint balances_other_buckets_not_negative check (damaged >= 0 and in_transit >= 0),
25 constraint balances_one_per_product_warehouse unique (organization_id, product_id, warehouse_id),
26 foreign key (organization_id, product_id) references products (organization_id, id),
27 foreign key (organization_id, warehouse_id) references warehouses (organization_id, id)
28);
29
30create table inventory_movements (
31 id uuid primary key default gen_random_uuid(),
32 organization_id uuid not null references organizations (id),
33 seq bigint not null,
34 group_id uuid not null,
35 movement_type movement_type not null,
36 bucket stock_bucket not null,
37 product_id uuid not null,
38 warehouse_id uuid not null,
39 batch_id uuid references batches (id),
40 qty integer not null,
41 qty_before integer not null,
42 qty_after integer not null,
43 unit_cost numeric(14, 4) not null,
44 ref_type text not null,
45 ref_id uuid not null,
46 user_id uuid not null references users (id),
47 request_id text not null,
48 created_at timestamptz not null default now(),
49 constraint movements_qty_not_zero check (qty <> 0),
50 constraint movements_after_follows_before check (qty_after = qty_before + qty),
51 constraint movements_seq_per_org unique (organization_id, seq),
52 foreign key (organization_id, product_id) references products (organization_id, id),
53 foreign key (organization_id, warehouse_id) references warehouses (organization_id, id)
54);
55
56create index movements_product_history on inventory_movements (organization_id, product_id, warehouse_id, seq desc);
57create index movements_by_type on inventory_movements (organization_id, movement_type, created_at desc);
58create index movements_by_document on inventory_movements (organization_id, ref_type, ref_id);
59create index balances_by_warehouse on inventory_balances (organization_id, warehouse_id);
60
61-- The ledger is append-only: a correction is a new movement, never an edit.
62create function forbid_movement_changes() returns trigger language plpgsql as $$
63begin
64 raise exception 'inventory_movements is append-only (% is not allowed)', tg_op
65 using errcode = 'restrict_violation';
66end;
67$$;
68
69create trigger inventory_movements_append_only
70 before update or delete on inventory_movements
71 for each row execute function forbid_movement_changes();
72
73revoke update, delete, truncate on inventory_movements from inventory_app;
74
75alter table inventory_balances enable row level security;
76alter table inventory_movements enable row level security;