feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green

Clean re-implementation of the full product (not a port of the old M3 tree):
- P0 foundation: 13-table schema + per-call sqlite factory (thread-safe),
  FastAPI app (health, JSON logging + secret redaction, CORS, startup guard).
- P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure,
  png crop/inventory/overlay, EXIF decodeOrientedBitmap.
- P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop
  to batch) + photos-ready lineup.
- P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing,
  inference Call1/Call2, review, publish, settings, slabs, photos) + real FE
  review/publish/settings screens consuming the API (no hardcoded Woo ids).
- P4 gate exit: check_requirements + check_coverage both exit 0
  (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green.

Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
2026-09-25 06:57:29 -07:00
parent 4ca2ef57a7
commit 49c22c9385
106 changed files with 5502 additions and 8863 deletions
+387
View File
@@ -0,0 +1,387 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { apiFetch, ApiError } from '../lib/api';
import type { SlabDraft, Taxonomy, TaxonomyCategory, TaxonomyTerm } from '../lib/types';
import { deriveThicknessBandId } from './thicknessBand';
import { keptInOrder, missingMinimums, moveItem, roleLabel, resolvePhotoUrl, type LineupItem, type PhotoRole } from './lineup';
import { nextPrimary, selectedSummary } from './woodPrimary';
import { buildShortDescription, buildTitle } from './copy';
export let slab: SlabDraft;
export let taxonomy: Taxonomy | null = null;
// Children of a synced Woo grouper, resolved by slug with a name fallback
// (QUALITY-105 / METADATA-5 — never hardcoded ids).
function leafChildren(categories: TaxonomyCategory[], slug: string, name: string): TaxonomyCategory[] {
if (!categories.length) return [];
const grouper =
categories.find((c) => (c.slug || '').toLowerCase() === slug) ??
categories.find((c) => (c.name || '').trim().toLowerCase() === name);
if (!grouper) return [];
return categories
.filter((c) => c.parent_id === grouper.id && c.id !== grouper.id)
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
const dispatch = createEventDispatcher<{
ready: Partial<SlabDraft>;
back: void;
taxonomy: Taxonomy;
saved: SlabDraft;
}>();
// ── Taxonomy lookups (from the synced /api/v1 response — never hardcoded) ──
$: categories = taxonomy?.categories ?? [];
$: speciesLeaves = leafChildren(categories, 'species', 'Species');
$: woodCatLeaves = leafChildren(categories, 'wcat', 'Wood category');
$: attributeByName = (name: string) =>
(taxonomy?.attributes ?? []).find((a) => a.name.toLowerCase() === name || a.slug.toLowerCase() === name);
$: edgeTerms = attributeByName('Edge Type')?.terms ?? [];
$: figureTerms = attributeByName('Figure')?.terms ?? [];
$: gradeTerms = attributeByName('Grade')?.terms ?? [];
$: thicknessTerms = attributeByName('Thickness')?.terms ?? [];
$: moistureTerms = attributeByName('Moisture')?.terms ?? [];
$: figTags = (taxonomy?.tags ?? []).filter((t) => t.name.startsWith('fig-'));
$: featTags = (taxonomy?.tags ?? []).filter((t) => t.name.startsWith('feat-'));
$: speciesName = (id: number | null) => categories.find((c) => c.id === id)?.name ?? '';
$: figureName = figureTerms.find((t) => t.id === (slab.figureTermIds?.[0] ?? null))?.name ?? null;
// ── Draft fields ──
let sku = slab.sku || '';
let speciesId: number | '' = slab.speciesId ?? '';
let woodCatIds: number[] = slab.woodCategoryIds.length ? [...slab.woodCategoryIds] : [];
let primaryCatId: number | '' = slab.woodCategoryIds[0] ?? '';
let edgeId: number | '' = slab.edgeTypeTermId ?? '';
let figIds: number[] = slab.figureTermIds.length ? [...slab.figureTermIds] : [];
let gradeId: number | '' = slab.gradeTermId ?? '';
let moistId: number | '' = slab.moistureTermId ?? '';
let figTagIds: number[] = slab.figTagIds ?? [];
let featTagIds: number[] = slab.featTagIds ?? [];
let price = slab.price || 0;
let priceSource: 'recommendation' | 'override' = slab.priceSource;
let title = slab.title || '';
let shortDesc = slab.shortDescription || '';
let description = slab.description || '';
// Derived thickness band (METADATA-15 / QUALITY-95): no picker, follows thickness_in.
$: bandId = deriveThicknessBandId(slab.thicknessIn, thicknessTerms);
$: bandName = thicknessTerms.find((t) => t.id === bandId)?.name ?? '';
$: edgeName = edgeTerms.find((t) => t.id === edgeId)?.name ?? null;
$: primaryLeaf = categories.find((c) => c.id === primaryCatId);
function regenerateCopy() {
if (!primaryLeaf || !speciesName(speciesId as number)) return;
title = buildTitle(
{ lengthIn: slab.lengthIn, widthAvgIn: slab.widthAvgIn, thicknessIn: slab.thicknessIn, bdft: slab.bdft, edgeName },
figureName,
speciesName(speciesId as number),
{ slug: primaryLeaf.slug, name: primaryLeaf.name },
);
shortDesc = buildShortDescription(
{ lengthIn: slab.lengthIn, widthAvgIn: slab.widthAvgIn, thicknessIn: slab.thicknessIn, bdft: slab.bdft, edgeName },
figureName,
speciesName(speciesId as number),
{ slug: primaryLeaf.slug, name: primaryLeaf.name },
);
}
// ── Price: bdft × species rate (REVIEW-25); owner override sticks ──
$: priceRec = priceSuggestion();
function priceSuggestion(): number | null {
const rule = (slab as SlabDraft & { speciesRate?: number | null }).speciesRate;
if (!slab.bdft || rule == null) return null;
return Math.round(slab.bdft * rule * 100) / 100;
}
function useRecommendedPrice() {
if (priceRec == null) return;
price = priceRec;
priceSource = 'recommendation';
}
function onPriceInput() {
price = Math.round(price * 100) / 100;
priceSource = 'override';
}
// ── Photo strip (REVIEW-5): kept order, leftmost = primary (PUBLISH-45) ──
$: lineup = slab.photos.map((photo, index): LineupItem => ({
id: photo.id,
role: photo.id === slab.calibrationPhotoId ? 'calibration' : photo.processedBlob ? 'overlay' : 'batch',
isCalibration: photo.id === slab.calibrationPhotoId,
kept: index < slab.photos.length,
}));
$: lineupMissing = missingMinimums(lineup);
$: primaryPhoto = keptInOrder(lineup)[0];
function toggleKeep(id: string) {
const next = lineup.map((it) => (it.id === id ? { ...it, kept: !it.kept } : it));
resequence(next);
}
function movePhoto(from: number, to: number) {
resequence(moveItem(lineup, from, to));
}
function resequence(items: LineupItem[]) {
const kept = keptInOrder(items);
const update: Partial<SlabDraft> = {
photos: kept.map((it, i) => {
const photo = slab.photos.find((p) => p.id === it.id);
return photo ? { ...photo, seq: i + 1 } : photo;
}),
};
dispatch('saved', { ...slab, ...update, clientRev: slab.clientRev + 1 });
}
function photoSrc(photoId: string, role: PhotoRole): string {
const photo = slab.photos.find((p) => p.id === photoId);
if (!photo) return '';
if (role === 'batch') return photo.processedBlob ? resolvePhotoUrl(photo.processedBlob, photo.url) : photo.url;
if (role === 'overlay') return photo.processedBlob ? resolvePhotoUrl(photo.processedBlob, photo.url) : photo.url;
return photo.url;
}
// ── Multi-select helpers ──
$: woodCatNames = new Map(woodCatLeaves.map((c) => [c.id, c.name]));
$: figNames = new Map(figureTerms.map((t) => [t.id, t.name]));
function toggleWoodCat(id: number) {
const selected = woodCatIds.includes(id) ? woodCatIds.filter((x) => x !== id) : [...woodCatIds, id];
woodCatIds = selected;
primaryCatId = nextPrimary(selected, primaryCatId);
regenerateCopy();
}
function toggleFig(id: number) {
figIds = figIds.includes(id) ? figIds.filter((x) => x !== id) : [...figIds, id];
regenerateCopy();
}
function toggleTag(list: number[], id: number): number[] {
return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
// ── Call 2: long description (METADATA-115) with template fallback ──
let genState: 'idle' | 'loading' | 'ok' | 'failed' = 'idle';
let genMessage = '';
function templateDescription(): string {
return (
`A ${title || 'slab'} from the mill, measured at ${slab.lengthIn}in long, ` +
`${slab.widthAvgIn}in average width, and ${slab.thicknessIn}in thick (${slab.bdft} bdft). ` +
'Ready to ship.'
);
}
function useTemplateDescription() {
description = templateDescription();
genState = 'idle';
genMessage = 'Template description applied.';
}
async function generateDescription() {
genState = 'loading';
genMessage = '';
try {
const res = await apiFetch<{ ok: boolean; description: string }>(`/slabs/${slab.id}/inference/call2`, { method: 'POST' });
description = res.description;
genState = 'ok';
genMessage = 'Long description generated (Call 2).';
} catch (err) {
genState = 'failed';
genMessage = err instanceof ApiError ? `${err.message} — use the template fallback.` : 'Generation failed — use the template fallback.';
}
}
// ── Ready gate (REVIEW-5) ──
$: skuOk = /^[A-Za-z0-9-]{3,24}$/.test(sku.trim());
$: ready =
skuOk && speciesId !== '' && woodCatIds.length > 0 && edgeId !== '' && figIds.length > 0 &&
gradeId !== '' && bandId !== null && moistId !== '' && price > 0 && title.trim().length > 0 &&
shortDesc.trim().length > 0 && description.trim().length > 0;
$: missing = [];
$: if (!skuOk) missing.push('SKU (3–24 alnum/-)');
$: if (speciesId === '') missing.push('Species');
$: if (!woodCatIds.length) missing.push('Wood category');
$: if (edgeId === '') missing.push('Edge type');
$: if (!figIds.length) missing.push('Figure');
$: if (gradeId === '') missing.push('Grade');
$: if (bandId === null) missing.push('Thickness band (set thickness)');
$: if (moistId === '') missing.push('Moisture');
$: if (!(price > 0)) missing.push('Price');
$: if (!description.trim()) missing.push('Description');
async function submitReady() {
if (!ready) return;
const update: Partial<SlabDraft> = {
sku: sku.trim(),
speciesId: speciesId as number,
woodCategoryIds: woodCatIds,
edgeTypeTermId: edgeId as number,
figureTermIds: figIds,
gradeTermId: gradeId as number,
thicknessTermId: bandId,
moistureTermId: moistId as number,
figTagIds,
featTagIds,
price,
priceSource,
title,
shortDescription: shortDesc,
description,
status: 'ready',
};
dispatch('ready', update);
}
</script>
<section>
<h2>3. Review (METADATA/REVIEW)</h2>
{#if !taxonomy}
<p>Loading synced taxonomy…</p>
{:else if !categories.length}
<p class="nudge">Taxonomy is empty — run a Woo sync in Settings first.</p>
{:else}
<div class="form">
<label>SKU <input bind:value={sku} maxlength="24" placeholder="SLAB-UAT-001" /></label>
<label>Species
<select bind:value={speciesId} on:change={regenerateCopy}>
<option value="" disabled>Select…</option>
{#each speciesLeaves as s (s.id)}<option value={s.id}>{s.name}</option>{/each}
</select>
</label>
<div class="chip-group">
<span>Wood categories <small>— primary drives the title</small></span>
{#each woodCatLeaves as c (c.id)}
<button class="chip" class:selected={woodCatIds.includes(c.id)} on:click={() => toggleWoodCat(c.id)}>
{#if primaryCatId === c.id}<strong>★</strong>{/if}{c.name}
</button>
{/each}
<small class="chip-sum">{selectedSummary(woodCatIds, woodCatNames, 'None selected')}</small>
</div>
<label>Edge type
<select bind:value={edgeId} on:change={regenerateCopy}>
<option value="" disabled>Select…</option>
{#each edgeTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
</select>
</label>
<div class="chip-group">
<span>Figure (multi)</span>
{#each figureTerms as t (t.id)}
<button class="chip" class:selected={figIds.includes(t.id)} on:click={() => toggleFig(t.id)}>{t.name}</button>
{/each}
</div>
<label>Grade
<select bind:value={gradeId}>
<option value="" disabled>Select…</option>
{#each gradeTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
</select>
</label>
<div class="band">
<span>Thickness band (derived from {slab.thicknessIn}″)</span>
<strong>{bandName || '— set thickness in Measure —'}</strong>
</div>
<label>Moisture
<select bind:value={moistId}>
<option value="" disabled>Select…</option>
{#each moistureTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
</select>
</label>
{#if figTags.length}
<div class="chip-group">
<span>Figure tags</span>
{#each figTags as t (t.id)}
<button class="chip" class:selected={figTagIds.includes(t.id)} on:click={() => (figTagIds = toggleTag(figTagIds, t.id))}>{t.name}</button>
{/each}
</div>
{/if}
{#if featTags.length}
<div class="chip-group">
<span>Feature tags</span>
{#each featTags as t (t.id)}
<button class="chip" class:selected={featTagIds.includes(t.id)} on:click={() => (featTagIds = toggleTag(featTagIds, t.id))}>{t.name}</button>
{/each}
</div>
{/if}
<div class="price">
<label>Price ($) <input type="number" step="0.01" bind:value={price} on:input={onPriceInput} /></label>
{#if priceRec != null}
<button on:click={useRecommendedPrice}>Use recommendation ${priceRec} (bdft × species rate)</button>
{:else}
<small class="nudge">No species rate configured — set it in Settings.</small>
{/if}
<small>Source: {priceSource}</small>
</div>
<div class="prose">
<label>Title (deterministic, editable) <input bind:value={title} /></label>
<label>Short description (deterministic, editable) <input bind:value={shortDesc} /></label>
<label>Long description
<textarea bind:value={description} rows="5"></textarea>
</label>
<div class="gen">
<button on:click={generateDescription} disabled={genState === 'loading'}>
{genState === 'loading' ? 'Generating…' : 'Generate (Call 2)'}
</button>
<button on:click={useTemplateDescription}>Use template</button>
{#if genMessage}<p class:ok={genState === 'ok'} class:err={genState === 'failed'}>{genMessage}</p>{/if}
</div>
</div>
<div class="strip">
<span>Photo lineup — leftmost kept = primary (cover). Tap to keep/remove; use ‹ › to reorder.</span>
{#each lineup as item, i (item.id)}
{#if item.kept}
<div class="strip-item" class:primary={item.id === primaryPhoto?.id}>
<button class="keep" aria-label="keep or remove" on:click={() => toggleKeep(item.id)}>{item.id === primaryPhoto?.id ? '★' : '✓'}</button>
<img src={photoSrc(item.id, item.role)} alt={roleLabel(item.role)} />
<div class="strip-actions">
<button disabled={i === 0} on:click={() => movePhoto(i, i - 1)}>‹</button>
<button disabled={i === lineup.length - 1} on:click={() => movePhoto(i, i + 1)}>›</button>
</div>
<small>{roleLabel(item.role)}</small>
</div>
{/if}
{/each}
{#if lineupMissing.length}
<p class="nudge">Minimum not met — missing {lineupMissing.map(roleLabel).join(' and ')}.</p>
{/if}
</div>
</div>
{#if !ready}
<p class="nudge">Still needed: {missing.join(', ')}</p>
{/if}
<div class="actions">
<button on:click={() => dispatch('back')}>← Back to measure</button>
<button on:click={submitReady} disabled={!ready} class="primary">Mark Ready & Publish →</button>
</div>
{/if}
</section>
<style>
.form { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:0.9rem; }
label { display:block; font-size:0.9rem; }
input,select,textarea { width:100%; padding:0.4rem; background:#222; color:#eee; border:1px solid #555; margin-top:0.2rem; }
.chip-group { display:flex; flex-wrap:wrap; gap:0.35rem; align-items:center; }
.chip { background:#222; color:#eee; border:1px solid #555; padding:0.35rem 0.7rem; border-radius:999px; cursor:pointer; }
.chip.selected { background:#0a7; color:#fff; border-color:#0a7; }
.chip-sum { width:100%; color:#888; }
.band { background:#1a1a1a; padding:0.6rem 0.8rem; border-radius:4px; }
.band strong { display:block; color:#0af; }
.price button { margin-top:0.3rem; font-size:0.8rem; }
.gen { display:flex; gap:0.5rem; align-items:center; margin-top:0.3rem; }
.gen .ok { color:#0a7; margin:0; }
.gen .err { color:#f88; margin:0; }
.strip { grid-column:1/-1; }
.strip-item { position:relative; display:inline-block; margin:0.3rem; }
.strip-item img { width:140px; height:140px; object-fit:cover; background:#111; border:2px solid #444; }
.strip-item.primary img { border-color:#0a7; }
.keep { position:absolute; top:4px; left:4px; z-index:1; background:#111; color:#0a7; border:1px solid #0a7; border-radius:4px; }
.strip-actions { display:flex; gap:0.25rem; }
.nudge { color:#fa0; }
.primary { background:#0a7; color:white; border:none; }
.actions { display:flex; gap:0.75rem; margin-top:1rem; }
</style>