diff --git a/backend/app.py b/backend/app.py index dcbf975..2862f53 100644 --- a/backend/app.py +++ b/backend/app.py @@ -262,5 +262,11 @@ app.include_router(slabs_router) from .photos import router as photos_router app.include_router(photos_router) +from .pricing import router as pricing_router +app.include_router(pricing_router) + +from .review import router as review_router +app.include_router(review_router) + # expose for tests __all__ = ["app", "__version__"] diff --git a/backend/pricing.py b/backend/pricing.py new file mode 100644 index 0000000..8fcdceb --- /dev/null +++ b/backend/pricing.py @@ -0,0 +1,99 @@ +"""Species pricing rules (SETTINGS-35): one flat $/bdft per synced species leaf, no tiers. + +Cites SETTINGS-35, REVIEW-25, API.md §9. +""" + +import re +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from .db import get_connection + +router = APIRouter(prefix="/api/v1/admin/pricing") + + +def _error(code: str, message: str, detail: Any = None) -> dict: + return {"error": {"code": code, "message": message, "detail": detail}} + + +def _row_to_rule(row) -> dict: + d = dict(row) + d["species_id"] = row["species_id"] + return d + + +@router.get("") +async def list_rules() -> list[dict]: + conn = get_connection() + rows = conn.execute( + "SELECT id, species_id, price_per_bdft, created_at, updated_at FROM pricing_rules ORDER BY species_id" + ).fetchall() + return [_row_to_rule(r) for r in rows] + + +@router.post("", status_code=201) +async def create_rule(payload: dict[str, Any]) -> dict: + species_id = payload.get("species_id") + rate = payload.get("price_per_bdft") + if species_id is None or rate is None: + return JSONResponse(status_code=400, content=_error("validation", "species_id and price_per_bdft required")) + if not isinstance(rate, (int, float)) or rate < 0: + return JSONResponse(status_code=400, content=_error("validation", "price_per_bdft must be a non-negative number")) + conn = get_connection() + if conn.execute("SELECT 1 FROM pricing_rules WHERE species_id=?", (species_id,)).fetchone(): + return JSONResponse(status_code=409, content=_error("duplicate", "rule exists for species", {"species_id": species_id})) + now = _now() + conn.execute( + "INSERT INTO pricing_rules(species_id, price_per_bdft, created_at, updated_at) VALUES (?,?,?,?)", + (species_id, round(float(rate), 2), now, now), + ) + conn.commit() + row = conn.execute("SELECT * FROM pricing_rules WHERE species_id=?", (species_id,)).fetchone() + return _row_to_rule(row) + + +@router.put("/{rule_id}") +async def update_rule(rule_id: int, payload: dict[str, Any]) -> dict: + conn = get_connection() + cur = conn.execute("SELECT * FROM pricing_rules WHERE id=?", (rule_id,)).fetchone() + if cur is None: + return JSONResponse(status_code=404, content=_error("not_found", "rule not found")) + rate = payload.get("price_per_bdft") + if rate is None or not isinstance(rate, (int, float)) or rate < 0: + return JSONResponse(status_code=400, content=_error("validation", "price_per_bdft must be a non-negative number")) + species_id = payload.get("species_id", cur["species_id"]) + if species_id != cur["species_id"]: + if conn.execute("SELECT 1 FROM pricing_rules WHERE species_id=? AND id<>?", (species_id, rule_id)).fetchone(): + return JSONResponse(status_code=409, content=_error("duplicate", "rule exists for species", {"species_id": species_id})) + conn.execute( + "UPDATE pricing_rules SET species_id=?, price_per_bdft=?, updated_at=? WHERE id=?", + (species_id, round(float(rate), 2), _now(), rule_id), + ) + conn.commit() + row = conn.execute("SELECT * FROM pricing_rules WHERE id=?", (rule_id,)).fetchone() + return _row_to_rule(row) + + +@router.delete("/{rule_id}", status_code=204) +async def delete_rule(rule_id: int): + conn = get_connection() + cur = conn.execute("DELETE FROM pricing_rules WHERE id=?", (rule_id,)) + conn.commit() + if cur.rowcount == 0: + return JSONResponse(status_code=404, content=_error("not_found", "rule not found")) + from fastapi import Response + + return Response(status_code=204) + + +def rate_for_species(conn, species_id: int) -> float | None: + row = conn.execute("SELECT price_per_bdft FROM pricing_rules WHERE species_id=?", (species_id,)).fetchone() + return row[0] if row else None + + +def _now() -> str: + from datetime import datetime, timezone + + return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() diff --git a/backend/review.py b/backend/review.py new file mode 100644 index 0000000..2b8c8b3 --- /dev/null +++ b/backend/review.py @@ -0,0 +1,183 @@ +"""Review to ready (S5): deterministic title/short description, price suggestion, +and server-derived `ready` status. Cites REVIEW-5/10/15/20/25/30/40/45/50, +METADATA-80/85/90, SETTINGS-35. +""" + +import json +import re +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from .db import get_connection +from .pricing import rate_for_species + +router = APIRouter(prefix="/api/v1/slabs") + +# Wood-category leaf slug -> (title pattern, noun, carries_dims). +# Patterns interpolate {figure}, {species}, {length}, {width}, {thickness}, {bdft}. +# Figure is Title-Case adjective; absent figure drops the slot. +_LEAF_PATTERNS = { + "live-edge-wood": ("{figure}{species} Live Edge Slab", "Live Edge Slab", False), + "dimensional-wood": ("{figure}{species} {live_edge}Dimensional", "Dimensional", False), + "knife-blanks": ("{figure}{species} Knife Blank ({dims})", "Knife Blank", True), + "bowl-blanks": ("{figure}{species} {live_edge}Bowl Blank ({dims})", "Bowl Blank", True), + "logs-and-caps": ("{figure}{species} Log Cap", "Log Cap", False), + "pen-blanks": ("{figure}{species} Pen Blank ({dims})", "Pen Blank", True), + "pepper-mill-blanks": ("{figure}{species} Pepper Mill Blank ({dims})", "Pepper Mill Blank", True), +} + +# Required fields for a slab to reach `ready` (REVIEW-5). Measurement fields +# (widths/sqft/bdft) are read-only client math but must be present. +_REQUIRED = [ + "sku", "length_in", "thickness_in", "sqft", "bdft", + "width_min_in", "width_max_in", "width_avg_in", + "species_id", "wood_category_ids", "edge_type_term_id", + "grade_term_id", "thickness_term_id", "moisture_term_id", + "price", "price_source", "title", "short_description", "description", + "content_source", +] + + +def _error(code: str, message: str, detail: Any = None) -> dict: + return {"error": {"code": code, "message": message, "detail": detail}} + + +def _split_length(length_in: float) -> tuple[int, float]: + ft = int(length_in // 12) + rem = round(length_in - ft * 12, 1) + return ft, rem + + +def _fmt1(v: float) -> str: + # 1 dp, trim trailing .0 + s = f"{v:.1f}" + return s[:-2] if s.endswith(".0") else s + + +def _fmt2(v: float) -> str: + # 2 dp, trim trailing zeros (thickness carries 1/8-inch precision) + s = f"{v:.2f}".rstrip("0").rstrip(".") + return s + + +def _dims(length_in: float, width_avg_in: float, thickness_in: float) -> str: + return f"{_fmt1(length_in)} x {_fmt1(width_avg_in)} x {_fmt2(thickness_in)} in" + + +def build_title(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str: + pattern, noun, carries_dims = _LEAF_PATTERNS.get( + primary_leaf.get("slug"), ("{figure}{species} Slab", "Slab", False) + ) + figure = figure.strip() if figure else "" + live_edge = "" + if primary_leaf["slug"] in ("dimensional-wood", "bowl-blanks"): + live_edge = "Live Edge " if slab.get("edge_type_term_id") else "" + title = pattern.format( + figure=figure + " " if figure else "", + species=species_name, + live_edge=live_edge, + noun=noun, + dims=_dims(slab["length_in"], slab["width_avg_in"], slab["thickness_in"]) if carries_dims else "", + ) + return re.sub(r"\s+", " ", title).strip() + + +def build_short_description(slab: dict, figure: str | None, species_name: str, primary_leaf: dict) -> str: + title = build_title(slab, figure, species_name, primary_leaf) + ft, rem = _split_length(slab["length_in"]) + length_str = f"{ft}ft {_fmt1(rem)}in" + bdft = round(slab["bdft"], 1) + line = f"{title}, {length_str} x {_fmt1(slab['width_avg_in'])}in x {_fmt2(slab['thickness_in'])}in, {_fmt1(bdft)} bdft" + return re.sub(r"\s+", " ", line).strip() + + +def _slab_dict(row) -> dict: + d = dict(row) + for f in ("wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"): + if d.get(f) is not None and isinstance(d[f], str): + try: + d[f] = json.loads(d[f]) + except (ValueError, TypeError): + d[f] = [] + return d + + +def _taxonomy_lookup(conn): + cats = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_categories").fetchall()} + terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()} + return cats, terms + + +def missing_required(slab: dict) -> list[str]: + return [f for f in _REQUIRED if slab.get(f) in (None, "", [])] + + +def compute_ready(slab: dict) -> tuple[bool, list[str]]: + missing = missing_required(slab) + return (len(missing) == 0), missing + + +def price_suggestion(conn, slab: dict) -> float | None: + if not slab.get("species_id") or slab.get("bdft") is None: + return None + rate = rate_for_species(conn, slab["species_id"]) + if rate is None: + return None + return round(slab["bdft"] * rate, 2) + + +@router.post("/{slab_id}/review") +async def review_slab(slab_id: str, payload: dict[str, Any] | None = None) -> dict: + payload = payload or {} + conn = get_connection() + row = conn.execute( + "SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,) + ).fetchone() + if row is None: + return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) + slab = _slab_dict(row) + # Apply any review edits (primary category declaration + prose). + editable = {"primary_wood_category_id", "title", "short_description", "description", "price", "price_source", "sku", "content_source"} + for k in editable: + if k in payload and payload[k] is not None: + if k == "primary_wood_category_id": + continue # not stored as a column; derived + slab[k] = payload[k] + cats, terms = _taxonomy_lookup(conn) + # Primary wood category: declared, else first wood_category_ids, else first category. + primary_id = payload.get("primary_wood_category_id") + wood_cats = slab.get("wood_category_ids") or [] + if not primary_id: + primary_id = wood_cats[0] if wood_cats else None + primary_leaf = cats.get(primary_id, {}) + species_name = cats.get(slab.get("species_id"), {}).get("name", "") + figure_term_ids = slab.get("figure_term_ids") or [] + figure = terms.get(figure_term_ids[0], {}).get("name") if figure_term_ids else None + + # Deterministic copy (only if not already overridden by owner). + if not payload.get("title"): + if primary_leaf and species_name: + slab["title"] = build_title(slab, figure, species_name, primary_leaf) + if not payload.get("short_description"): + if primary_leaf and species_name: + slab["short_description"] = build_short_description(slab, figure, species_name, primary_leaf) + # Price suggestion (REVIEW-25): bdft * species rate, empty if no rate; owner override sticks. + suggested = price_suggestion(conn, slab) + if payload.get("price") is not None: + price = payload["price"] + elif slab.get("price") not in (None, 0): + price = slab["price"] + else: + price = suggested + ready, missing = compute_ready({**slab, "price": price}) + return { + "ready": ready, + "missing": missing, + "title": slab.get("title"), + "short_description": slab.get("short_description"), + "price": price, + "price_suggestion": suggested, + "primary_wood_category_id": primary_id, + } diff --git a/backend/tests/test_pricing.py b/backend/tests/test_pricing.py new file mode 100644 index 0000000..00996e8 --- /dev/null +++ b/backend/tests/test_pricing.py @@ -0,0 +1,63 @@ +"""S5 pricing rules tests (SETTINGS-35, REVIEW-25).""" + +import base64 +import os +import shutil +import tempfile + +import pytest +from fastapi.testclient import TestClient + +import backend.db as db_mod + + +@pytest.fixture +def env(): + tmp = tempfile.mkdtemp(prefix="pricing_") + os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db") + os.environ["SLAB_IMAGES_DIR"] = tmp + os.environ["WOO_BASE_URL"] = "https://example.com" + os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() + db_mod._reset_for_tests() + from backend.app import app + + conn = db_mod.init_db() + for cid in (1, 7): + conn.execute("INSERT OR IGNORE INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (?,?,?,NULL,?)", (cid, f"Sp{cid}", f"sp{cid}", cid)) + conn.commit() + yield TestClient(app) + db_mod._reset_for_tests() + shutil.rmtree(tmp, ignore_errors=True) + + +def test_crud_cycle(env): + r = env.post("/api/v1/admin/pricing", json={"species_id": 1, "price_per_bdft": 12.5}) + assert r.status_code == 201, r.text + rule = r.json() + rid = rule["id"] + assert rule["price_per_bdft"] == 12.5 + + r = env.get("/api/v1/admin/pricing") + assert r.status_code == 200 + assert [x["species_id"] for x in r.json()] == [1] + + r = env.put(f"/api/v1/admin/pricing/{rid}", json={"price_per_bdft": 15.0}) + assert r.status_code == 200 + assert r.json()["price_per_bdft"] == 15.0 + + r = env.delete(f"/api/v1/admin/pricing/{rid}") + assert r.status_code == 204 + assert env.get("/api/v1/admin/pricing").json() == [] + + +def test_duplicate_species(env): + env.post("/api/v1/admin/pricing", json={"species_id": 7, "price_per_bdft": 1.0}) + r = env.post("/api/v1/admin/pricing", json={"species_id": 7, "price_per_bdft": 2.0}) + assert r.status_code == 409 + + +def test_validation(env): + r = env.post("/api/v1/admin/pricing", json={"species_id": 1}) + assert r.status_code == 400 + r = env.post("/api/v1/admin/pricing", json={"species_id": 1, "price_per_bdft": -5}) + assert r.status_code == 400 diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py new file mode 100644 index 0000000..bc606b9 --- /dev/null +++ b/backend/tests/test_review.py @@ -0,0 +1,97 @@ +"""S5 deterministic copy + ready derivation tests (METADATA-80/85/90, REVIEW-5/25).""" + +import base64 +import json +import os +import shutil +import tempfile + +import pytest +from fastapi.testclient import TestClient + +import backend.db as db_mod +from backend import review as review_mod + + +@pytest.fixture +def env(): + tmp = tempfile.mkdtemp(prefix="review_") + os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "r.db") + os.environ["SLAB_IMAGES_DIR"] = tmp + os.environ["WOO_BASE_URL"] = "https://example.com" + os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() + db_mod._reset_for_tests() + conn = db_mod.init_db() + # taxonomy: species + a live-edge-wood leaf + figure term + conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)") + conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)") + conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (1,'Figure','figure',200)") + conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,1,'Figured','figured',300)") + conn.commit() + from backend.app import app + + yield TestClient(app), conn + db_mod._reset_for_tests() + shutil.rmtree(tmp, ignore_errors=True) + + +def _slab(sku="SLAB-01", **over): + base = { + "id": "s1", "sku": sku, "length_in": 114.0, "thickness_in": 1.75, "sqft": 30.0, + "bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3, + "species_id": 1, "wood_category_ids": json.dumps([2]), "edge_type_term_id": None, + "figure_term_ids": json.dumps([1]), "grade_term_id": None, "thickness_term_id": None, + "moisture_term_id": None, "fig_tag_ids": "[]", "feat_tag_ids": "[]", + "price": None, "price_source": None, "title": None, "short_description": None, + "description": "A fine slab.", "content_source": "manual", "status": "calibrated", + "client_rev": 1, "server_rev": 1, "deleted_at": None, "created_at": "t", "updated_at": "t", + } + base.update(over) + return base + + +def test_title_live_edge_no_dims(env): + _, conn = env + 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,18.4,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 = env[0].post("/api/v1/slabs/s1/review", json={}) + assert r.status_code == 200, r.text + body = r.json() + assert body["title"] == "Figured Black Walnut Live Edge Slab" + assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in") + assert "18.4 bdft" in body["short_description"] + + +def test_ready_requires_full_set(env): + client, conn = env + 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,18.4,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["ready"] is False + # price is empty (no rate) and title/short_description generated -> missing includes price, price_source, grade/thickness/moisture terms + assert "price" in body["missing"] + + +def test_price_suggestion_from_rate(env): + client, conn = env + conn.execute("INSERT INTO pricing_rules(species_id,price_per_bdft,created_at,updated_at) VALUES (1,10.0,'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,18.4,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"] == 184.0 # 18.4 bdft * $10 + assert body["price"] == 184.0 + + +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)") + 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",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),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={}) + assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)" diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 390a3ce..9d8ddad 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -12,6 +12,7 @@ import MeasurePanel from './capture/MeasurePanel.svelte'; import Settings from './create/settings/Settings.svelte'; import SlabList from './create/slabs/SlabList.svelte'; import PhotoAttach from './create/photos/PhotoAttach.svelte'; +import Review from './create/review/Review.svelte'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; const session: CaptureSession = createCaptureSession(); @@ -22,6 +23,7 @@ import PhotoAttach from './create/photos/PhotoAttach.svelte'; let showSettings = false; let showSlabs = false; let showPhotos = false; + let showReview = false; const unsubCap = session.capture.subscribe((c) => { cap = c; @@ -98,6 +100,7 @@ import PhotoAttach from './create/photos/PhotoAttach.svelte'; + {#if showSettings} @@ -108,6 +111,9 @@ import PhotoAttach from './create/photos/PhotoAttach.svelte'; {#if showPhotos} {/if} + {#if showReview} + + {/if} {/if} diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte new file mode 100644 index 0000000..1fc66ec --- /dev/null +++ b/frontend/src/create/review/Review.svelte @@ -0,0 +1,86 @@ + + +
+

Review to Ready

+
+ + +
+
+ + +
+
+ + +
+ + {#if error}

{error}

{/if} + + {#if result} +

+ {result.ready ? 'READY for publish' : `Not ready — missing: ${result.missing.join(', ')}`} +

+
Title: {result.title ?? '(none)'}
+
Short description: {result.short_description ?? '(none)'}
+
+ Price: {result.price != null ? `$${result.price.toFixed(2)}` : '(empty — enter required)'} + {#if result.price_suggestion != null && result.price_suggestion !== result.price} + (suggested ${result.price_suggestion.toFixed(2)}) + {/if} +
+ {/if} +
+ +