SETTINGS-35: species $/bdft in Settings + taxonomy parent fix (#76)
Add Settings species pricing UI (children of Species grouper by slug, never hardcoded ids). Map Woo REST parent→parent_id on taxonomy sync; upsert categories so slab FKs survive re-sync. Tests: parent map, FK-safe sync, TV-8 0.96×12.50→12.00, Playwright mock e2e. UAT verified on 5060.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
|
||||
|
||||
interface SecretState { configured: boolean }
|
||||
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
|
||||
@@ -18,6 +19,20 @@
|
||||
woo_create_status: string | null;
|
||||
}
|
||||
|
||||
interface PriceRule {
|
||||
id: number;
|
||||
species_id: number;
|
||||
price_per_bdft: number;
|
||||
}
|
||||
|
||||
interface RateRow {
|
||||
species_id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
ruleId: number | null;
|
||||
rateText: string;
|
||||
}
|
||||
|
||||
let settings: Settings | null = null;
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
@@ -26,11 +41,155 @@
|
||||
// local edit buffers (write-only for secrets)
|
||||
let form: any = {};
|
||||
|
||||
let rateRows: RateRow[] = [];
|
||||
let ratesLoading = false;
|
||||
let ratesSaving = false;
|
||||
let ratesMessage = '';
|
||||
let taxonomyPresent = false;
|
||||
|
||||
// Unmasked UAT mode returns the secret as a plain string; masked mode returns {configured}.
|
||||
const isUnmasked = (s: Secret) => typeof s === 'string';
|
||||
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
|
||||
const secretConfigured = (s: Secret): boolean => (isUnmasked(s) ? (s as string).length > 0 : s.configured);
|
||||
|
||||
function decodeName(name: string): string {
|
||||
// Woo sometimes returns HTML entities in category names.
|
||||
return name
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function loadRates() {
|
||||
ratesLoading = true;
|
||||
ratesMessage = '';
|
||||
try {
|
||||
const [taxRes, priceRes] = await Promise.all([
|
||||
fetch('/api/v1/admin/taxonomy'),
|
||||
fetch('/api/v1/admin/pricing'),
|
||||
]);
|
||||
if (!taxRes.ok) {
|
||||
taxonomyPresent = false;
|
||||
rateRows = [];
|
||||
ratesMessage = 'Could not load taxonomy. Run Test WooCommerce connection first.';
|
||||
ratesLoading = false;
|
||||
return;
|
||||
}
|
||||
const tax = await taxRes.json();
|
||||
const categories = (tax.categories || []) as TaxonomyCategory[];
|
||||
taxonomyPresent = categories.length > 0;
|
||||
const rules: PriceRule[] = priceRes.ok ? await priceRes.json() : [];
|
||||
const bySpecies = new Map<number, PriceRule>();
|
||||
for (const r of rules) bySpecies.set(r.species_id, r);
|
||||
|
||||
const leaves = speciesLeavesFromTaxonomy(categories);
|
||||
rateRows = leaves.map((leaf) => {
|
||||
const rule = bySpecies.get(leaf.id);
|
||||
return {
|
||||
species_id: leaf.id,
|
||||
name: decodeName(leaf.name),
|
||||
slug: leaf.slug,
|
||||
ruleId: rule?.id ?? null,
|
||||
rateText: rule != null ? String(rule.price_per_bdft) : '',
|
||||
};
|
||||
});
|
||||
if (!taxonomyPresent) {
|
||||
ratesMessage = 'No taxonomy yet. Run Test WooCommerce connection to sync species leaves.';
|
||||
} else if (leaves.length === 0) {
|
||||
ratesMessage =
|
||||
'Taxonomy is present but no species leaves were found under the Species grouper. Re-run Test Woo after updating the app, then sync again.';
|
||||
}
|
||||
} catch {
|
||||
ratesMessage = 'Failed to load species rates';
|
||||
rateRows = [];
|
||||
taxonomyPresent = false;
|
||||
}
|
||||
ratesLoading = false;
|
||||
}
|
||||
|
||||
async function saveRates() {
|
||||
ratesSaving = true;
|
||||
ratesMessage = '';
|
||||
let saved = 0;
|
||||
let failed = 0;
|
||||
for (const row of rateRows) {
|
||||
const raw = String(row.rateText ?? '').trim();
|
||||
if (raw === '') continue; // blank = leave unchanged (no delete)
|
||||
const rate = Number(raw);
|
||||
if (!Number.isFinite(rate) || rate < 0) {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (row.ruleId != null) {
|
||||
const res = await fetch(`/api/v1/admin/pricing/${row.ruleId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ price_per_bdft: rate }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
const body = await res.json();
|
||||
row.ruleId = body.id;
|
||||
row.rateText = String(body.price_per_bdft);
|
||||
saved += 1;
|
||||
} else {
|
||||
const res = await fetch('/api/v1/admin/pricing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ species_id: row.species_id, price_per_bdft: rate }),
|
||||
});
|
||||
if (res.status === 409) {
|
||||
// Rule exists — refresh list id then PUT
|
||||
const listRes = await fetch('/api/v1/admin/pricing');
|
||||
if (!listRes.ok) {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
const list: PriceRule[] = await listRes.json();
|
||||
const existing = list.find((r) => r.species_id === row.species_id);
|
||||
if (!existing) {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
const put = await fetch(`/api/v1/admin/pricing/${existing.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ price_per_bdft: rate }),
|
||||
});
|
||||
if (!put.ok) {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
const body = await put.json();
|
||||
row.ruleId = body.id;
|
||||
row.rateText = String(body.price_per_bdft);
|
||||
saved += 1;
|
||||
} else if (res.ok || res.status === 201) {
|
||||
const body = await res.json();
|
||||
row.ruleId = body.id;
|
||||
row.rateText = String(body.price_per_bdft);
|
||||
saved += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
rateRows = [...rateRows];
|
||||
if (failed && !saved) ratesMessage = 'Save rates failed';
|
||||
else if (failed) ratesMessage = `Saved ${saved}; ${failed} failed`;
|
||||
else if (saved) ratesMessage = `Saved ${saved} rate${saved === 1 ? '' : 's'}`;
|
||||
else ratesMessage = 'No rate changes to save';
|
||||
ratesSaving = false;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
@@ -56,6 +215,7 @@
|
||||
message = 'Failed to load settings';
|
||||
}
|
||||
loading = false;
|
||||
void loadRates();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -108,6 +268,7 @@
|
||||
const res = await fetch('/api/v1/settings/test-woo', { method: 'POST' });
|
||||
const body = await res.json();
|
||||
wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') };
|
||||
if (wooTest.ok) await loadRates();
|
||||
} catch {
|
||||
wooTest = { ok: false, detail: 'network error' };
|
||||
}
|
||||
@@ -201,6 +362,46 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="section" data-testid="species-pricing">
|
||||
<h3>Species pricing</h3>
|
||||
<p class="hint">One flat $ / bdft per synced species leaf (SETTINGS-35). Leaves come from taxonomy under Species — never a hardcoded list.</p>
|
||||
{#if ratesLoading}
|
||||
<p>Loading rates…</p>
|
||||
{:else if rateRows.length === 0}
|
||||
<p class="msg err" data-testid="species-pricing-empty">
|
||||
{ratesMessage || 'Run Test WooCommerce connection first to sync species leaves.'}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="rate-table" role="table" aria-label="Species rates">
|
||||
<div class="rate-head" role="row">
|
||||
<span role="columnheader">Species</span>
|
||||
<span role="columnheader">$ / bdft</span>
|
||||
</div>
|
||||
{#each rateRows as row (row.species_id)}
|
||||
<div class="rate-row" role="row" data-species-id={row.species_id} data-species-slug={row.slug}>
|
||||
<label class="species-name" for={`rate-${row.species_id}`}>{row.name}</label>
|
||||
<input
|
||||
id={`rate-${row.species_id}`}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputmode="decimal"
|
||||
bind:value={row.rateText}
|
||||
placeholder="—"
|
||||
aria-label={`Price per bdft for ${row.name}`}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button on:click={saveRates} disabled={ratesSaving || saving} data-testid="save-rates">
|
||||
{ratesSaving ? 'Saving rates…' : 'Save rates'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if ratesMessage && rateRows.length > 0}
|
||||
<p class="msg" class:ok={!ratesMessage.includes('fail')} class:err={ratesMessage.includes('fail')}>{ratesMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Inference</h3>
|
||||
<button on:click={testInference} disabled={saving || inferenceTest?.busy}>Test Inference connection</button>
|
||||
@@ -220,4 +421,18 @@
|
||||
.section { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
|
||||
.section h3 { margin: 0 0 8px; font-size: 1.05rem; color: #eee; }
|
||||
.section button { margin-top: 4px; }
|
||||
.hint { margin: 0 0 10px; color: #bbb; font-size: 0.9rem; }
|
||||
.msg { color: #ccc; }
|
||||
.msg.ok { color: #7fd37f; }
|
||||
.msg.err { color: #e88; }
|
||||
.rate-table { display: grid; gap: 6px; }
|
||||
.rate-head, .rate-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 7rem;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.rate-head { color: #aaa; font-size: 0.85rem; }
|
||||
.species-name { margin: 0; color: #eee; }
|
||||
.rate-row input { margin: 0; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
|
||||
|
||||
/** Fixture ids are local to this test — never production store ids. */
|
||||
const tree: TaxonomyCategory[] = [
|
||||
{ id: 10, name: 'Species', slug: 'species', parent_id: null },
|
||||
{ id: 11, name: 'Cedar', slug: 'cedar', parent_id: 10 },
|
||||
{ id: 12, name: 'Maple', slug: 'maple', parent_id: 10 },
|
||||
{ id: 20, name: 'Wood category', slug: 'wcat', parent_id: null },
|
||||
{ id: 21, name: 'Live edge wood', slug: 'liveedge', parent_id: 20 },
|
||||
{ id: 99, name: 'Chairs', slug: 'chairs', parent_id: null },
|
||||
];
|
||||
|
||||
describe('speciesLeavesFromTaxonomy', () => {
|
||||
it('returns only children of the Species grouper, sorted by name', () => {
|
||||
const leaves = speciesLeavesFromTaxonomy(tree);
|
||||
assert.deepEqual(
|
||||
leaves.map((l) => l.slug),
|
||||
['cedar', 'maple']
|
||||
);
|
||||
assert.equal(leaves[0].name, 'Cedar');
|
||||
assert.equal(leaves[1].name, 'Maple');
|
||||
});
|
||||
|
||||
it('returns empty when Species grouper is missing (no dump of all categories)', () => {
|
||||
const flat = tree.filter((c) => c.slug !== 'species');
|
||||
assert.deepEqual(speciesLeavesFromTaxonomy(flat), []);
|
||||
});
|
||||
|
||||
it('returns empty when Species has no children', () => {
|
||||
assert.deepEqual(
|
||||
speciesLeavesFromTaxonomy([{ id: 10, name: 'Species', slug: 'species', parent_id: null }]),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves Species grouper by name when slug differs', () => {
|
||||
const alt: TaxonomyCategory[] = [
|
||||
{ id: 10, name: 'Species', slug: 'product-species', parent_id: null },
|
||||
{ id: 11, name: 'Alder', slug: 'alder', parent_id: 10 },
|
||||
];
|
||||
assert.deepEqual(
|
||||
speciesLeavesFromTaxonomy(alt).map((l) => l.slug),
|
||||
['alder']
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Species leaf selection for SETTINGS-35 rate rows.
|
||||
|
||||
Resolves the Species grouper by slug `species` (name fallback "Species"), then
|
||||
returns its children. Never hardcodes store category ids (QUALITY-105 / METADATA-5).
|
||||
*/
|
||||
|
||||
export interface TaxonomyCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
parent_id: number | null;
|
||||
}
|
||||
|
||||
export function speciesLeavesFromTaxonomy(categories: TaxonomyCategory[]): TaxonomyCategory[] {
|
||||
if (!categories.length) return [];
|
||||
|
||||
const grouper =
|
||||
categories.find((c) => (c.slug || '').toLowerCase() === 'species') ||
|
||||
categories.find((c) => (c.name || '').trim().toLowerCase() === 'species');
|
||||
|
||||
if (!grouper) return [];
|
||||
|
||||
return categories
|
||||
.filter((c) => c.parent_id === grouper.id && c.id !== grouper.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
Reference in New Issue
Block a user