Supply Chain Management with Uni¶
This notebook demonstrates how to model a supply chain graph to perform BOM (Bill of Materials) explosion, cost rollup, and supplier risk analysis.
db_path = os.path.join(tempfile.gettempdir(), "supply_chain_db")
if os.path.exists(db_path):
shutil.rmtree(db_path)
db = uni_db.Uni.open(db_path)
session = db.session()
print(f"Opened database at {db_path}")
Opened database at /tmp/supply_chain_db
1. Define Schema¶
Parts, Suppliers, and Products with assembly and supply relationships.
(
db.schema()
.label("Part")
.property("name", "string")
.property("sku", "string")
.property("cost", "float64")
.index("sku", "hash")
.done()
.label("Supplier")
.property("name", "string")
.done()
.label("Product")
.property("name", "string")
.property("price", "float64")
.done()
.edge_type("ASSEMBLED_FROM", ["Product", "Part"], ["Part"])
.done()
.edge_type("SUPPLIED_BY", ["Part"], ["Supplier"])
.done()
.apply()
)
print("Schema created")
Schema created
2. Ingest Data¶
Two products sharing common parts, supplied by multiple vendors.
tx = session.tx()
with tx.bulk_writer().build() as bw:
# Parts: 7 components with different costs
part_vids = bw.insert_vertices(
"Part",
[
{"name": "Resistor 10K", "sku": "RES-10K", "cost": 0.05},
{"name": "Capacitor 100uF", "sku": "CAP-100UF", "cost": 0.08},
{"name": "Motherboard X1", "sku": "MB-X1", "cost": 50.0},
{"name": "OLED Screen", "sku": "SCR-OLED", "cost": 30.0},
{"name": "Battery 4000mAh", "sku": "BAT-4000", "cost": 15.0},
{"name": "ARM Processor", "sku": "PROC-ARM", "cost": 80.0},
{"name": "LCD Screen", "sku": "SCR-LCD", "cost": 20.0},
],
)
res10k, cap100uf, mbx1, scr_oled, bat4000, proc_arm, scr_lcd = part_vids
# Suppliers
sup_vids = bw.insert_vertices(
"Supplier",
[
{"name": "ResistorWorld"},
{"name": "ScreenTech"},
{"name": "CoreComponents"},
],
)
resistor_world, screen_tech, core_components = sup_vids
# Products
prod_vids = bw.insert_vertices(
"Product",
[
{"name": "Smartphone X", "price": 599.0},
{"name": "TabletPro 10", "price": 799.0},
],
)
smartphone, tablet = prod_vids
# Assembly: Smartphone X -> OLED screen, shared parts
bw.insert_edges(
"ASSEMBLED_FROM",
[
(smartphone, mbx1, {}),
(smartphone, scr_oled, {}),
(smartphone, bat4000, {}),
(smartphone, proc_arm, {}),
(mbx1, res10k, {}),
(mbx1, cap100uf, {}),
],
)
# Assembly: TabletPro 10 -> LCD screen, shared parts
bw.insert_edges(
"ASSEMBLED_FROM",
[
(tablet, mbx1, {}),
(tablet, scr_lcd, {}),
(tablet, bat4000, {}),
(tablet, proc_arm, {}),
],
)
# Supply relationships
bw.insert_edges(
"SUPPLIED_BY",
[
(res10k, resistor_world, {}),
(cap100uf, resistor_world, {}),
(scr_oled, screen_tech, {}),
(scr_lcd, screen_tech, {}),
(mbx1, core_components, {}),
(bat4000, core_components, {}),
(proc_arm, core_components, {}),
],
)
bw.commit()
tx.commit()
print("Data ingested")
Data ingested
3. 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.query(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.
4. 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.query(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
5. 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.query(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
6. 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
ORDER BY products_at_risk DESC
"""
results = session.query(query_risk)
print("Supplier risk analysis:")
for r in results:
print(f" {r['supplier']}: {r['products_at_risk']} product(s) at risk")
assert len(results) == 3, f"Expected 3 suppliers, got {len(results)}"
top = results[0]
assert top["products_at_risk"] == 2, f"Expected 2, got {top['products_at_risk']}"
Supplier risk analysis:
ScreenTech: 2 product(s) at risk
CoreComponents: 2 product(s) at risk
ResistorWorld: 2 product(s) at risk