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:
2026-09-19 16:52:33 -07:00
parent 861c5e0dd1
commit 732310fdbe
7 changed files with 540 additions and 0 deletions
+6
View File
@@ -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';
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
</div>
{#if showSettings}
<Settings />
@@ -108,6 +111,9 @@ import PhotoAttach from './create/photos/PhotoAttach.svelte';
{#if showPhotos}
<PhotoAttach />
{/if}
{#if showReview}
<Review />
{/if}
</main>
{/if}
+86
View File
@@ -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>