Files
aura-crm/production/backend/migrations/005_inventory.sql
T

93 lines
5.4 KiB
SQL

-- +goose Up
-- Parts catalog. is_serialized distinguishes parts tracked by individual
-- physical unit (screens, batteries — each has its own serial/IMEI, staff
-- picks a specific one when consuming) from bulk consumables (screws, glue,
-- thermal paste — tracked by quantity only). min_stock drives the low-stock
-- view; there is no sale_price here on purpose — billing a part to a client
-- is a Phase 7 (касса/финансы) concern, this table is stock tracking only.
CREATE TABLE parts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sku TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
category TEXT,
unit TEXT NOT NULL DEFAULT 'pcs',
is_serialized BOOLEAN NOT NULL DEFAULT FALSE,
min_stock INT NOT NULL DEFAULT 0 CHECK (min_stock >= 0),
created_by_staff_id UUID NOT NULL,
created_by_staff_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX parts_category_idx ON parts (category);
-- One row per receiving event, own cost basis (purchase_price) — consumption
-- draws down qty_remaining oldest-batch-first (FIFO by received_at), so cost
-- of goods sold reflects what was actually paid for the units used, not a
-- blended average. qty_remaining is the mutable running balance;
-- qty_received is kept alongside as the immutable original count.
CREATE TABLE stock_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
part_id UUID NOT NULL REFERENCES parts(id) ON DELETE RESTRICT,
batch_no TEXT,
purchase_price NUMERIC(10, 2) NOT NULL,
qty_received INT NOT NULL CHECK (qty_received > 0),
qty_remaining INT NOT NULL CHECK (qty_remaining >= 0),
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
note TEXT,
created_by_staff_id UUID NOT NULL,
created_by_staff_name TEXT NOT NULL
);
CREATE INDEX stock_batches_part_id_received_idx ON stock_batches (part_id, received_at);
-- Individual serialized units within a batch — only populated for
-- is_serialized parts. Consuming a serialized part means picking a specific
-- row here (staff reads the serial off the physical unit), not a FIFO qty
-- draw, since it matters exactly which unit went into which repair.
CREATE TABLE stock_serials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
part_id UUID NOT NULL REFERENCES parts(id) ON DELETE RESTRICT,
batch_id UUID NOT NULL REFERENCES stock_batches(id) ON DELETE RESTRICT,
serial_number TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_stock' CHECK (status IN ('in_stock', 'consumed')),
UNIQUE (part_id, serial_number)
);
CREATE INDEX stock_serials_part_status_idx ON stock_serials (part_id, status);
-- The stock ledger — source of truth for every in/out event, and doubles as
-- the "parts used on this order/batch" list (queried by order_id or
-- cartridge_batch_id, no separate consumption table needed). Two real
-- nullable FKs rather than a polymorphic subject_type/subject_id column —
-- same rationale as batch_events in migrations/003_cartridges.sql: keep the
-- FK real. At most one of order_id/cartridge_batch_id is set, and only for
-- type IN ('consumption', 'reversal') — enforced in Go (internal/inventory),
-- not by a CHECK, since CHECK can't easily express "iff type = X" alongside
-- "at most one of two nullable columns" without duplicating that logic.
-- serial_id is set only when consuming/reversing a specific stock_serials
-- row. reverses_id links a 'reversal' row back to the 'consumption' row it
-- undoes — stock_batches.qty_remaining/stock_serials.status are still the
-- mutable running balance, reversal just moves them back and leaves both
-- the original and the reversal in the ledger (never edits/deletes the
-- original — audit trail over convenience).
CREATE TABLE stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
part_id UUID NOT NULL REFERENCES parts(id) ON DELETE RESTRICT,
batch_id UUID NOT NULL REFERENCES stock_batches(id) ON DELETE RESTRICT,
serial_id UUID REFERENCES stock_serials(id) ON DELETE RESTRICT,
type TEXT NOT NULL CHECK (type IN ('receipt', 'consumption', 'adjustment', 'reversal')),
qty INT NOT NULL, -- positive = stock in, negative = stock out
order_id UUID REFERENCES orders(id) ON DELETE SET NULL,
cartridge_batch_id UUID REFERENCES cartridge_batches(id) ON DELETE SET NULL,
reverses_id UUID REFERENCES stock_movements(id) ON DELETE SET NULL,
note TEXT,
staff_id UUID NOT NULL,
staff_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX stock_movements_part_id_idx ON stock_movements (part_id);
CREATE INDEX stock_movements_order_id_idx ON stock_movements (order_id) WHERE order_id IS NOT NULL;
CREATE INDEX stock_movements_cartridge_batch_id_idx ON stock_movements (cartridge_batch_id) WHERE cartridge_batch_id IS NOT NULL;
-- DB-level backstop against double-reversing the same consumption row —
-- internal/inventory.Reverse also locks (FOR UPDATE) the movement it's
-- reversing before checking this, but belt-and-suspenders is cheap here.
CREATE UNIQUE INDEX stock_movements_reverses_id_idx ON stock_movements (reverses_id) WHERE reverses_id IS NOT NULL;