diff --git a/backend/taxonomy.py b/backend/taxonomy.py index a79b9a1..beb6d1f 100644 --- a/backend/taxonomy.py +++ b/backend/taxonomy.py @@ -20,6 +20,23 @@ def _scoped_tags(tags: list[dict]) -> list[dict]: return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))] +def _normalize_category(c: dict) -> tuple: + """Map a Woo REST category row into woo_categories insert values. + + WooCommerce REST v3 returns `parent` (0 = top-level). Older fixtures may + already use `parent_id`. Never invent ids — only store what the payload carries. + """ + raw_parent = c.get("parent_id", c.get("parent")) + if raw_parent in (0, "0", None, ""): + parent_id = None + else: + parent_id = int(raw_parent) + woo_id = c.get("woo_id", c.get("id")) + if woo_id is not None: + woo_id = int(woo_id) + return (c["id"], c["name"], c["slug"], parent_id, woo_id) + + def _subset_hash(categories: list[dict], attributes: list[dict], terms_by_attr: dict, scoped_tags: list[dict]) -> str: subset = { @@ -40,14 +57,37 @@ def sync(client: WooClient, conn) -> dict: terms_by_attr = {a["id"]: client.get_attribute_terms(a["id"]) for a in attributes} scoped_tags = _scoped_tags(tags) + # Attributes/tags/terms are not referenced by slabs FKs — safe to rebuild. conn.execute("DELETE FROM woo_attribute_terms") conn.execute("DELETE FROM woo_attributes") - conn.execute("DELETE FROM woo_categories") conn.execute("DELETE FROM woo_tags") - conn.executemany( - "INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?)", - [(c["id"], c["name"], c["slug"], c.get("parent_id"), c.get("woo_id")) for c in categories], - ) + + # Categories: upsert then drop stale rows. A full DELETE fails when slabs or + # pricing_rules still reference species_id (live UAT IntegrityError on test-woo). + cat_rows = [_normalize_category(c) for c in categories] + cat_ids = [r[0] for r in cat_rows] + for row in cat_rows: + conn.execute( + "INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?) " + "ON CONFLICT(id) DO UPDATE SET " + "name=excluded.name, slug=excluded.slug, parent_id=excluded.parent_id, woo_id=excluded.woo_id", + row, + ) + if cat_ids: + placeholders = ",".join("?" * len(cat_ids)) + conn.execute( + f"DELETE FROM woo_categories WHERE id NOT IN ({placeholders}) " + "AND id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) " + "AND id NOT IN (SELECT species_id FROM pricing_rules)", + cat_ids, + ) + else: + conn.execute( + "DELETE FROM woo_categories " + "WHERE id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) " + "AND id NOT IN (SELECT species_id FROM pricing_rules)" + ) + conn.executemany( "INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)", [(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes], diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py index bc606b9..bc430e2 100644 --- a/backend/tests/test_review.py +++ b/backend/tests/test_review.py @@ -87,6 +87,47 @@ def test_price_suggestion_from_rate(env): assert body["price"] == 184.0 +def test_price_suggestion_tv8_quality_90(env): + """QUALITY-90 / TV-8: 0.96 bdft × $12.50 → 12.00.""" + client, conn = env + conn.execute( + "INSERT INTO pricing_rules(species_id,price_per_bdft,created_at,updated_at) VALUES (1,12.50,'t','t')" + ) + conn.execute( + "INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "s1", + "SLAB-01", + 114.0, + 1.75, + 30.0, + 0.96, + 18.0, + 24.3, + 24.3, + 1, + json.dumps([2]), + json.dumps([1]), + None, + None, + None, + None, + "A slab", + "manual", + "calibrated", + 1, + 1, + "t", + "t", + ), + ) + conn.commit() + r = client.post("/api/v1/slabs/s1/review", json={}) + body = r.json() + assert body["price_suggestion"] == 12.00 + assert body["price"] == 12.00 + + def test_knife_blank_carries_dims(env): client, conn = env conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (3,'Knife blanks','knife-blanks',NULL,102)") diff --git a/backend/tests/test_woo.py b/backend/tests/test_woo.py index ac6f432..d3ab93d 100644 --- a/backend/tests/test_woo.py +++ b/backend/tests/test_woo.py @@ -141,6 +141,75 @@ def test_anchor_stable_across_identical_syncs(db_dir): assert r1["taxonomy_anchor"] == r2["taxonomy_anchor"] +def test_sync_maps_woo_parent_field_to_parent_id(db_dir): + """Woo REST categories use `parent` (0 = top-level), not `parent_id`. + + Live UAT lost hierarchy when sync only read parent_id (SETTINGS-35 / METADATA-10). + Fixture ids are local to this test — not production store ids. + """ + cats = ( + 200, + {"X-WP-TotalPages": "1"}, + json.dumps( + [ + {"id": 10, "name": "Species", "slug": "species", "parent": 0}, + {"id": 11, "name": "Cedar", "slug": "cedar", "parent": 10}, + {"id": 20, "name": "Wood category", "slug": "wcat", "parent": 0}, + {"id": 21, "name": "Live edge wood", "slug": "liveedge", "parent": 20}, + ] + ).encode(), + ) + tags = (200, {"X-WP-TotalPages": "1"}, b"[]") + attrs = (200, {"X-WP-TotalPages": "1"}, b"[]") + c, _ = _client([cats, tags, attrs]) + taxonomy_mod.sync(c, db_mod.get_connection()) + conn = db_mod.get_connection() + rows = { + r[0]: {"name": r[1], "slug": r[2], "parent_id": r[3], "woo_id": r[4]} + for r in conn.execute("SELECT id, name, slug, parent_id, woo_id FROM woo_categories") + } + assert rows[10]["parent_id"] is None + assert rows[11]["parent_id"] == 10 + assert rows[20]["parent_id"] is None + assert rows[21]["parent_id"] == 20 + # woo_id falls back to Woo category id when the payload has no woo_id field + assert rows[11]["woo_id"] == 11 + + +def test_sync_with_slab_species_fk_does_not_fail(db_dir): + """Live UAT: DELETE woo_categories fails when a slab still points at species_id.""" + conn = db_mod.get_connection() + conn.execute( + "INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (11,'Old Cedar','old-cedar',NULL,11)" + ) + conn.execute( + "INSERT INTO slabs(id, status, species_id, server_rev, created_at, updated_at) " + "VALUES ('slab-fk-1', 'draft', 11, 1, 't', 't')" + ) + conn.commit() + + cats = ( + 200, + {"X-WP-TotalPages": "1"}, + json.dumps( + [ + {"id": 10, "name": "Species", "slug": "species", "parent": 0}, + {"id": 11, "name": "Cedar", "slug": "cedar", "parent": 10}, + ] + ).encode(), + ) + tags = (200, {"X-WP-TotalPages": "1"}, b"[]") + attrs = (200, {"X-WP-TotalPages": "1"}, b"[]") + c, _ = _client([cats, tags, attrs]) + taxonomy_mod.sync(c, conn) + rows = { + r[0]: r[3] + for r in conn.execute("SELECT id, name, slug, parent_id FROM woo_categories") + } + assert rows[11] == 10 # parent mapped; row kept for FK + assert conn.execute("SELECT species_id FROM slabs WHERE id='slab-fk-1'").fetchone()[0] == 11 + + def test_403_raises_forbidden_error(db_dir): def fake_request(url, method, data, headers): raise WooForbiddenError("request forbidden (403)") diff --git a/frontend/src/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte index 9817bb8..2569ef9 100644 --- a/frontend/src/create/settings/Settings.svelte +++ b/frontend/src/create/settings/Settings.svelte @@ -1,5 +1,6 @@