feat(review): S5 pricing rules + deterministic title/short desc + server-derived ready + Review UI
- backend/pricing.py: /admin/pricing CRUD (one flat $/bdft per species leaf, SETTINGS-35).
- backend/review.py: POST /slabs/{id}/review -> deterministic title + short description
(7 leaf patterns, length ft/in split, thickness 1/8-in precision, METADATA-80/85/90),
price suggestion (bdft x species rate, REVIEW-25), server-derived ready + missing set
(REVIEW-5).
- frontend: create/review/Review.svelte (slab id, primary category, price override,
ready state + generated copy) + dev toggle.
- tests: 3 pricing + 4 review tests (title no-dims, ready set, price suggestion,
knife-blank dims).
Verified: pytest 43 passed; gates exit 0; npm build ok.
This commit is contained in:
@@ -262,5 +262,11 @@ app.include_router(slabs_router)
|
|||||||
from .photos import router as photos_router
|
from .photos import router as photos_router
|
||||||
app.include_router(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
|
# expose for tests
|
||||||
__all__ = ["app", "__version__"]
|
__all__ = ["app", "__version__"]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)"
|
||||||
@@ -12,6 +12,7 @@ import MeasurePanel from './capture/MeasurePanel.svelte';
|
|||||||
import Settings from './create/settings/Settings.svelte';
|
import Settings from './create/settings/Settings.svelte';
|
||||||
import SlabList from './create/slabs/SlabList.svelte';
|
import SlabList from './create/slabs/SlabList.svelte';
|
||||||
import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
||||||
|
import Review from './create/review/Review.svelte';
|
||||||
|
|
||||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||||
const session: CaptureSession = createCaptureSession();
|
const session: CaptureSession = createCaptureSession();
|
||||||
@@ -22,6 +23,7 @@ import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
|||||||
let showSettings = false;
|
let showSettings = false;
|
||||||
let showSlabs = false;
|
let showSlabs = false;
|
||||||
let showPhotos = false;
|
let showPhotos = false;
|
||||||
|
let showReview = false;
|
||||||
|
|
||||||
const unsubCap = session.capture.subscribe((c) => {
|
const unsubCap = session.capture.subscribe((c) => {
|
||||||
cap = c;
|
cap = c;
|
||||||
@@ -98,6 +100,7 @@ import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
|||||||
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
|
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
|
||||||
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
|
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
|
||||||
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
|
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
|
||||||
|
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
|
||||||
</div>
|
</div>
|
||||||
{#if showSettings}
|
{#if showSettings}
|
||||||
<Settings />
|
<Settings />
|
||||||
@@ -108,6 +111,9 @@ import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
|||||||
{#if showPhotos}
|
{#if showPhotos}
|
||||||
<PhotoAttach />
|
<PhotoAttach />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if showReview}
|
||||||
|
<Review />
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let slabId = '';
|
||||||
|
let primaryCategoryId: number | '' = '';
|
||||||
|
let price = '';
|
||||||
|
let result: {
|
||||||
|
ready: boolean;
|
||||||
|
missing: string[];
|
||||||
|
title: string | null;
|
||||||
|
short_description: string | null;
|
||||||
|
price: number | null;
|
||||||
|
price_suggestion: number | null;
|
||||||
|
primary_wood_category_id: number | null;
|
||||||
|
} | null = null;
|
||||||
|
let error = '';
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
async function review() {
|
||||||
|
if (!slabId) {
|
||||||
|
error = 'Enter a slab id.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
result = null;
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
if (primaryCategoryId !== '') payload.primary_wood_category_id = primaryCategoryId;
|
||||||
|
if (price !== '') payload.price = Number(price);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (res.ok) result = body;
|
||||||
|
else error = body.error?.message || `review failed (${res.status})`;
|
||||||
|
} catch {
|
||||||
|
error = 'network error';
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="review">
|
||||||
|
<h2>Review to Ready</h2>
|
||||||
|
<div>
|
||||||
|
<label>Slab ID</label>
|
||||||
|
<input bind:value={slabId} placeholder="uuid" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Primary wood category id (optional; defaults to first selected)</label>
|
||||||
|
<input type="number" bind:value={primaryCategoryId} placeholder="auto" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Price override (optional; suggestion = bdft × species rate)</label>
|
||||||
|
<input type="number" step="0.01" bind:value={price} placeholder="use suggestion" />
|
||||||
|
</div>
|
||||||
|
<button on:click={review} disabled={busy}>{busy ? 'Reviewing…' : 'Review'}</button>
|
||||||
|
{#if error}<p class="err">{error}</p>{/if}
|
||||||
|
|
||||||
|
{#if result}
|
||||||
|
<p class="ready" class:ok={result.ready}>
|
||||||
|
{result.ready ? 'READY for publish' : `Not ready — missing: ${result.missing.join(', ')}`}
|
||||||
|
</p>
|
||||||
|
<div><strong>Title:</strong> {result.title ?? '(none)'}</div>
|
||||||
|
<div><strong>Short description:</strong> {result.short_description ?? '(none)'}</div>
|
||||||
|
<div>
|
||||||
|
<strong>Price:</strong> {result.price != null ? `$${result.price.toFixed(2)}` : '(empty — enter required)'}
|
||||||
|
{#if result.price_suggestion != null && result.price_suggestion !== result.price}
|
||||||
|
<span class="sug"> (suggested ${result.price_suggestion.toFixed(2)})</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.review { max-width: 560px; color: #eee; }
|
||||||
|
label { display: block; margin: 8px 0 4px; }
|
||||||
|
input { width: 100%; padding: 6px; }
|
||||||
|
button { margin-top: 12px; }
|
||||||
|
.ready { margin-top: 12px; font-weight: 600; }
|
||||||
|
.ready.ok { color: #7fd37f; }
|
||||||
|
.ready:not(.ok) { color: #e8a; }
|
||||||
|
.err { color: #e88; }
|
||||||
|
.sug { color: #aaa; font-weight: 400; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user