Parts inventory: catalog, search, and stock tracking
FastAPI + SQLite behind a single-page frontend, deployed as a container on mainserver behind Caddy. One flexible parts table plus key/value specs so components, filament, fasteners and tooling share a schema. Categories and locations are nestable trees whose filters and sidebar counts both roll up through descendants. Category spec templates pre-fill the properties worth recording for each kind of part, which is what makes manual entry tolerable. Every quantity change is logged. Search is FTS5 with prefix matching across names, MPNs, spec values, tags, category and location. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""SQLite storage for the parts inventory.
|
||||
|
||||
One flexible `parts` table carries the fields every kind of stock shares
|
||||
(name, quantity, unit, location, cost). Anything type-specific — resistance,
|
||||
filament diameter, thread pitch — lives in `part_specs` as key/value rows, so a
|
||||
0603 resistor, a spool of PLA and a box of M3 screws coexist without a schema
|
||||
fork. Categories carry a `spec_template` naming the keys worth filling in for
|
||||
that kind of part, which is what keeps manual entry from being a blank page.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
|
||||
DB_PATH = os.environ.get("PARTS_DB", os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "parts.db"))
|
||||
|
||||
_init_lock = threading.Lock()
|
||||
_initialised = False
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
unit TEXT NOT NULL DEFAULT 'pcs',
|
||||
spec_template TEXT NOT NULL DEFAULT '[]',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS categories_unique
|
||||
ON categories(name COLLATE NOCASE, COALESCE(parent_id, 0));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES locations(id) ON DELETE SET NULL,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS locations_unique
|
||||
ON locations(name COLLATE NOCASE, COALESCE(parent_id, 0));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS parts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
location_id INTEGER REFERENCES locations(id) ON DELETE SET NULL,
|
||||
manufacturer TEXT NOT NULL DEFAULT '',
|
||||
mpn TEXT NOT NULL DEFAULT '',
|
||||
quantity REAL NOT NULL DEFAULT 0,
|
||||
unit TEXT NOT NULL DEFAULT 'pcs',
|
||||
min_quantity REAL,
|
||||
cost_each REAL,
|
||||
datasheet_url TEXT NOT NULL DEFAULT '',
|
||||
product_url TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS parts_category ON parts(category_id);
|
||||
CREATE INDEX IF NOT EXISTS parts_location ON parts(location_id);
|
||||
CREATE INDEX IF NOT EXISTS parts_name ON parts(name COLLATE NOCASE);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS part_specs (
|
||||
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (part_id, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS part_tags (
|
||||
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (part_id, tag_id)
|
||||
);
|
||||
|
||||
-- Every quantity change is recorded, so "where did those 40 headers go" has an
|
||||
-- answer instead of just a smaller number than you remembered.
|
||||
CREATE TABLE IF NOT EXISTS stock_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
|
||||
delta REAL NOT NULL,
|
||||
quantity_after REAL NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS stock_log_part ON stock_log(part_id, id DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS parts_fts USING fts5(
|
||||
name, description, manufacturer, mpn, specs, tags, category, location,
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def connect():
|
||||
# FastAPI runs a sync `yield` dependency and its endpoint on different
|
||||
# threadpool threads, so the connection must not be thread-pinned. Each
|
||||
# request still gets its own short-lived connection.
|
||||
conn = sqlite3.connect(DB_PATH, timeout=15.0, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA busy_timeout = 15000")
|
||||
return conn
|
||||
|
||||
|
||||
def init():
|
||||
"""Create the schema and seed starter categories/locations once."""
|
||||
global _initialised
|
||||
with _init_lock:
|
||||
if _initialised:
|
||||
return
|
||||
os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True)
|
||||
conn = connect()
|
||||
try:
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
from .seed import seed
|
||||
seed(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
_initialised = True
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session():
|
||||
conn = connect()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""FastAPI dependency yielding a per-request connection."""
|
||||
with session() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
# --- search index -----------------------------------------------------------
|
||||
|
||||
def reindex_part(conn, part_id: int):
|
||||
"""Rebuild one part's row in the FTS index.
|
||||
|
||||
The index is a plain (self-contained) FTS5 table rather than an
|
||||
external-content one: it costs a duplicate copy of some short text, and in
|
||||
exchange a delete is just `DELETE ... WHERE rowid = ?` instead of the
|
||||
contentless table's delete-with-original-values dance.
|
||||
"""
|
||||
conn.execute("DELETE FROM parts_fts WHERE rowid = ?", (part_id,))
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT p.name, p.description, p.manufacturer, p.mpn,
|
||||
COALESCE(c.name, '') AS category,
|
||||
COALESCE(l.name, '') AS location
|
||||
FROM parts p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
LEFT JOIN locations l ON l.id = p.location_id
|
||||
WHERE p.id = ?
|
||||
""",
|
||||
(part_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
specs = conn.execute(
|
||||
"SELECT key, value FROM part_specs WHERE part_id = ?", (part_id,)
|
||||
).fetchall()
|
||||
spec_text = " ".join(f"{s['key']} {s['value']}" for s in specs)
|
||||
tags = conn.execute(
|
||||
"SELECT t.name FROM tags t JOIN part_tags pt ON pt.tag_id = t.id WHERE pt.part_id = ?",
|
||||
(part_id,),
|
||||
).fetchall()
|
||||
tag_text = " ".join(t["name"] for t in tags)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO parts_fts(rowid, name, description, manufacturer, mpn, specs, tags, category, location)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
part_id, row["name"], row["description"], row["manufacturer"], row["mpn"],
|
||||
spec_text, tag_text, row["category"], row["location"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def reindex_all(conn):
|
||||
conn.execute("DELETE FROM parts_fts")
|
||||
for (pid,) in conn.execute("SELECT id FROM parts").fetchall():
|
||||
reindex_part(conn, pid)
|
||||
|
||||
|
||||
def fts_query(text: str) -> str:
|
||||
"""Turn free-typed text into a safe FTS5 prefix query.
|
||||
|
||||
Each whitespace-separated term is quoted (so `10k`, `0.1uF` and `M3x8` can't
|
||||
be read as operators) and given a `*` so search feels live while typing.
|
||||
"""
|
||||
terms = [t for t in text.replace('"', " ").split() if t]
|
||||
return " ".join(f'"{t}"*' for t in terms)
|
||||
Reference in New Issue
Block a user