93 lines
5.4 KiB
SQL
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;
|