Supply Chain Management with uni-pydantic¶
BOM explosion, cost rollup, and supplier risk analysis using Pydantic models.
import os
import shutil
import tempfile
import uni_db
from uni_pydantic import UniNode, UniEdge, UniSession, Field, Relationship
1. Define Models¶
Parts, Suppliers, and Products with assembly relationships using type-safe Pydantic models.
class Part(UniNode):
"""A component part in the supply chain."""
__label__ = "Part"
name: str
sku: str = Field(index="hash", unique=True)
cost: float
# Relationships
used_in: list["Part"] = Relationship("ASSEMBLED_FROM", direction="incoming")
components: list["Part"] = Relationship("ASSEMBLED_FROM", direction="outgoing")
suppliers: list["Supplier"] = Relationship("SUPPLIED_BY", direction="outgoing")
class Supplier(UniNode):
"""A supplier of parts."""
__label__ = "Supplier"
name: str = Field(index="btree")
# Relationships
supplies: list[Part] = Relationship("SUPPLIED_BY", direction="incoming")
class Product(UniNode):
"""A finished product assembled from parts."""
__label__ = "Product"
name: str = Field(index="btree")
price: float
# Relationships
components: list[Part] = Relationship("ASSEMBLED_FROM", direction="outgoing")
class AssembledFrom(UniEdge):
"""Edge representing assembly relationship."""
__edge_type__ = "ASSEMBLED_FROM"
__from__ = (Product, Part)
__to__ = Part
class SuppliedBy(UniEdge):
"""Edge representing supplier relationship."""
__edge_type__ = "SUPPLIED_BY"
__from__ = Part
__to__ = Supplier
2. Setup Database and Session¶
db_path = os.path.join(tempfile.gettempdir(), "supply_chain_pydantic_db")
if os.path.exists(db_path):
shutil.rmtree(db_path)
db = uni_db.Uni.open(db_path)
# Create session and register models
session = UniSession(db)
session.register(Part, Supplier, Product, AssembledFrom, SuppliedBy)
session.sync_schema()
print(f"Opened database at {db_path}")
Opened database at /tmp/supply_chain_pydantic_db
3. Create Data¶
Two products sharing common parts, supplied by multiple vendors.
# 7 parts: resistors, capacitors, boards, screens, battery, processor
res10k = Part(name="Resistor 10K", sku="RES-10K", cost=0.05)
cap100uf = Part(name="Capacitor 100uF", sku="CAP-100UF", cost=0.08)
mbx1 = Part(name="Motherboard X1", sku="MB-X1", cost=50.0)
scr_oled = Part(name="OLED Screen", sku="SCR-OLED", cost=30.0)
bat4000 = Part(name="Battery 4000mAh", sku="BAT-4000", cost=15.0)
proc_arm = Part(name="ARM Processor", sku="PROC-ARM", cost=80.0)
scr_lcd = Part(name="LCD Screen", sku="SCR-LCD", cost=20.0)
# 3 suppliers
resistor_world = Supplier(name="ResistorWorld")
screen_tech = Supplier(name="ScreenTech")
core_components = Supplier(name="CoreComponents")
# 2 products
smartphone = Product(name="Smartphone X", price=599.0)
tablet = Product(name="TabletPro 10", price=799.0)
session.add_all(
[
res10k,
cap100uf,
mbx1,
scr_oled,
bat4000,
proc_arm,
scr_lcd,
resistor_world,
screen_tech,
core_components,
smartphone,
tablet,
]
)
session.commit()
print("Nodes created")
Nodes created
# Smartphone X assembly
session.create_edge(smartphone, "ASSEMBLED_FROM", mbx1)
session.create_edge(smartphone, "ASSEMBLED_FROM", scr_oled)
session.create_edge(smartphone, "ASSEMBLED_FROM", bat4000)
session.create_edge(smartphone, "ASSEMBLED_FROM", proc_arm)
session.create_edge(mbx1, "ASSEMBLED_FROM", res10k)
session.create_edge(mbx1, "ASSEMBLED_FROM", cap100uf)
# TabletPro 10 assembly (shares mbx1, bat4000, proc_arm)
session.create_edge(tablet, "ASSEMBLED_FROM", mbx1)
session.create_edge(tablet, "ASSEMBLED_FROM", scr_lcd)
session.create_edge(tablet, "ASSEMBLED_FROM", bat4000)
session.create_edge(tablet, "ASSEMBLED_FROM", proc_arm)
# Supply relationships
session.create_edge(res10k, "SUPPLIED_BY", resistor_world)
session.create_edge(cap100uf, "SUPPLIED_BY", resistor_world)
session.create_edge(scr_oled, "SUPPLIED_BY", screen_tech)
session.create_edge(scr_lcd, "SUPPLIED_BY", screen_tech)
session.create_edge(mbx1, "SUPPLIED_BY", core_components)
session.create_edge(bat4000, "SUPPLIED_BY", core_components)
session.create_edge(proc_arm, "SUPPLIED_BY", core_components)
session.commit()
print("Data ingested")
Data ingested
4. BOM Explosion¶
Which products are affected if RES-10K is defective? Traverses the assembly hierarchy upward.
query_bom = """
MATCH (defective:Part {sku: 'RES-10K'})
MATCH (product:Product)-[:ASSEMBLED_FROM*]->(defective)
RETURN product.name AS name, product.price AS price
ORDER BY product.price DESC
"""
results = session.cypher(query_bom)
print("Products affected by defective RES-10K:")
for r in results:
print(f" {r['name']} (${r['price']})")
assert len(results) == 2, f"Expected 2 affected products, got {len(results)}"
Products affected by defective RES-10K:
TabletPro 10 ($799.0)
Smartphone X ($599.0)
Bounded vs Unbounded Paths:
[*]performs unbounded traversal (defaults to 100 hops max), ideal for BOM explosion where you want every affected product regardless of depth. Use[*1..5]to cap traversal at a known depth, as shown in the queries below.
5. Full BOM Listing¶
Every part in Smartphone X with its cost, ordered by cost descending.
query_parts = """
MATCH (p:Product {name: 'Smartphone X'})-[:ASSEMBLED_FROM*1..5]->(part:Part)
RETURN part.name AS part_name, part.sku AS sku, part.cost AS cost
ORDER BY cost DESC
"""
results = session.cypher(query_parts)
print("Smartphone X BOM:")
for r in results:
print(f" {r['part_name']} ({r['sku']}): ${r['cost']}")
Smartphone X BOM:
ARM Processor (PROC-ARM): $80.0
Motherboard X1 (MB-X1): $50.0
OLED Screen (SCR-OLED): $30.0
Battery 4000mAh (BAT-4000): $15.0
Capacitor 100uF (CAP-100UF): $0.08
Resistor 10K (RES-10K): $0.05
6. Cost Rollup¶
Total BOM cost per product — GROUP BY product with SUM of part costs.
query_rollup = """
MATCH (p:Product)-[:ASSEMBLED_FROM*1..5]->(part:Part)
RETURN p.name AS product, SUM(part.cost) AS total_bom_cost
ORDER BY total_bom_cost DESC
"""
results = session.cypher(query_rollup)
print("BOM cost rollup per product:")
for r in results:
print(f" {r['product']}: ${r['total_bom_cost']:.2f}")
assert len(results) == 2, f"Expected 2 rows, got {len(results)}"
BOM cost rollup per product:
Smartphone X: $175.13
TabletPro 10: $165.13
7. Supply Chain Risk¶
Which supplier is critical to the most products?
query_risk = """
MATCH (p:Product)-[:ASSEMBLED_FROM*1..5]->(part:Part)-[:SUPPLIED_BY]->(s:Supplier)
RETURN s.name AS supplier, COUNT(DISTINCT p) AS products_at_risk,
COUNT(DISTINCT part) AS parts_supplied
ORDER BY products_at_risk DESC, parts_supplied DESC
"""
results = session.cypher(query_risk)
print("Supplier risk analysis:")
for r in results:
print(
f" {r['supplier']}: {r['products_at_risk']} product(s), {r['parts_supplied']} part(s)"
)
top = results[0]
assert top["supplier"] == "CoreComponents", (
f"Expected CoreComponents, got {top['supplier']}"
)
assert top["products_at_risk"] == 2, f"Expected 2, got {top['products_at_risk']}"
Supplier risk analysis:
CoreComponents: 2 product(s), 3 part(s)
ScreenTech: 2 product(s), 2 part(s)
ResistorWorld: 2 product(s), 2 part(s)
8. Query Builder Demo¶
Using the type-safe query builder to find expensive parts.
# Find all expensive parts using query builder
expensive_parts = (
session.query(Part)
.filter(Part.cost >= 20.0)
.order_by(Part.cost, descending=True)
.all()
)
print("Expensive Parts (>=$20):")
for part in expensive_parts:
print(f" - {part.name} ({part.sku}): ${part.cost:,.2f}")
Expensive Parts (>=$20):
- ARM Processor (PROC-ARM): $80.00
- Motherboard X1 (MB-X1): $50.00
- OLED Screen (SCR-OLED): $30.00
- LCD Screen (SCR-LCD): $20.00