Merge pull request 'SETTINGS-35: species $/bdft in Settings + taxonomy parent fix (#76)' (#85) from issue-76-settings-species-pricing into main
This commit was merged in pull request #85.
This commit is contained in:
+44
-4
@@ -20,6 +20,23 @@ def _scoped_tags(tags: list[dict]) -> list[dict]:
|
|||||||
return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))]
|
return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_category(c: dict) -> tuple:
|
||||||
|
"""Map a Woo REST category row into woo_categories insert values.
|
||||||
|
|
||||||
|
WooCommerce REST v3 returns `parent` (0 = top-level). Older fixtures may
|
||||||
|
already use `parent_id`. Never invent ids — only store what the payload carries.
|
||||||
|
"""
|
||||||
|
raw_parent = c.get("parent_id", c.get("parent"))
|
||||||
|
if raw_parent in (0, "0", None, ""):
|
||||||
|
parent_id = None
|
||||||
|
else:
|
||||||
|
parent_id = int(raw_parent)
|
||||||
|
woo_id = c.get("woo_id", c.get("id"))
|
||||||
|
if woo_id is not None:
|
||||||
|
woo_id = int(woo_id)
|
||||||
|
return (c["id"], c["name"], c["slug"], parent_id, woo_id)
|
||||||
|
|
||||||
|
|
||||||
def _subset_hash(categories: list[dict], attributes: list[dict],
|
def _subset_hash(categories: list[dict], attributes: list[dict],
|
||||||
terms_by_attr: dict, scoped_tags: list[dict]) -> str:
|
terms_by_attr: dict, scoped_tags: list[dict]) -> str:
|
||||||
subset = {
|
subset = {
|
||||||
@@ -40,14 +57,37 @@ def sync(client: WooClient, conn) -> dict:
|
|||||||
terms_by_attr = {a["id"]: client.get_attribute_terms(a["id"]) for a in attributes}
|
terms_by_attr = {a["id"]: client.get_attribute_terms(a["id"]) for a in attributes}
|
||||||
scoped_tags = _scoped_tags(tags)
|
scoped_tags = _scoped_tags(tags)
|
||||||
|
|
||||||
|
# Attributes/tags/terms are not referenced by slabs FKs — safe to rebuild.
|
||||||
conn.execute("DELETE FROM woo_attribute_terms")
|
conn.execute("DELETE FROM woo_attribute_terms")
|
||||||
conn.execute("DELETE FROM woo_attributes")
|
conn.execute("DELETE FROM woo_attributes")
|
||||||
conn.execute("DELETE FROM woo_categories")
|
|
||||||
conn.execute("DELETE FROM woo_tags")
|
conn.execute("DELETE FROM woo_tags")
|
||||||
conn.executemany(
|
|
||||||
"INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?)",
|
# Categories: upsert then drop stale rows. A full DELETE fails when slabs or
|
||||||
[(c["id"], c["name"], c["slug"], c.get("parent_id"), c.get("woo_id")) for c in categories],
|
# pricing_rules still reference species_id (live UAT IntegrityError on test-woo).
|
||||||
|
cat_rows = [_normalize_category(c) for c in categories]
|
||||||
|
cat_ids = [r[0] for r in cat_rows]
|
||||||
|
for row in cat_rows:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (?,?,?,?,?) "
|
||||||
|
"ON CONFLICT(id) DO UPDATE SET "
|
||||||
|
"name=excluded.name, slug=excluded.slug, parent_id=excluded.parent_id, woo_id=excluded.woo_id",
|
||||||
|
row,
|
||||||
)
|
)
|
||||||
|
if cat_ids:
|
||||||
|
placeholders = ",".join("?" * len(cat_ids))
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM woo_categories WHERE id NOT IN ({placeholders}) "
|
||||||
|
"AND id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) "
|
||||||
|
"AND id NOT IN (SELECT species_id FROM pricing_rules)",
|
||||||
|
cat_ids,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM woo_categories "
|
||||||
|
"WHERE id NOT IN (SELECT species_id FROM slabs WHERE species_id IS NOT NULL) "
|
||||||
|
"AND id NOT IN (SELECT species_id FROM pricing_rules)"
|
||||||
|
)
|
||||||
|
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||||
[(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes],
|
[(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes],
|
||||||
|
|||||||
@@ -87,6 +87,47 @@ def test_price_suggestion_from_rate(env):
|
|||||||
assert body["price"] == 184.0
|
assert body["price"] == 184.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_suggestion_tv8_quality_90(env):
|
||||||
|
"""QUALITY-90 / TV-8: 0.96 bdft × $12.50 → 12.00."""
|
||||||
|
client, conn = env
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO pricing_rules(species_id,price_per_bdft,created_at,updated_at) VALUES (1,12.50,'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,
|
||||||
|
0.96,
|
||||||
|
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"] == 12.00
|
||||||
|
assert body["price"] == 12.00
|
||||||
|
|
||||||
|
|
||||||
def test_knife_blank_carries_dims(env):
|
def test_knife_blank_carries_dims(env):
|
||||||
client, conn = 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 woo_categories(id,name,slug,parent_id,woo_id) VALUES (3,'Knife blanks','knife-blanks',NULL,102)")
|
||||||
|
|||||||
@@ -141,6 +141,75 @@ def test_anchor_stable_across_identical_syncs(db_dir):
|
|||||||
assert r1["taxonomy_anchor"] == r2["taxonomy_anchor"]
|
assert r1["taxonomy_anchor"] == r2["taxonomy_anchor"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_maps_woo_parent_field_to_parent_id(db_dir):
|
||||||
|
"""Woo REST categories use `parent` (0 = top-level), not `parent_id`.
|
||||||
|
|
||||||
|
Live UAT lost hierarchy when sync only read parent_id (SETTINGS-35 / METADATA-10).
|
||||||
|
Fixture ids are local to this test — not production store ids.
|
||||||
|
"""
|
||||||
|
cats = (
|
||||||
|
200,
|
||||||
|
{"X-WP-TotalPages": "1"},
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{"id": 10, "name": "Species", "slug": "species", "parent": 0},
|
||||||
|
{"id": 11, "name": "Cedar", "slug": "cedar", "parent": 10},
|
||||||
|
{"id": 20, "name": "Wood category", "slug": "wcat", "parent": 0},
|
||||||
|
{"id": 21, "name": "Live edge wood", "slug": "liveedge", "parent": 20},
|
||||||
|
]
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
tags = (200, {"X-WP-TotalPages": "1"}, b"[]")
|
||||||
|
attrs = (200, {"X-WP-TotalPages": "1"}, b"[]")
|
||||||
|
c, _ = _client([cats, tags, attrs])
|
||||||
|
taxonomy_mod.sync(c, db_mod.get_connection())
|
||||||
|
conn = db_mod.get_connection()
|
||||||
|
rows = {
|
||||||
|
r[0]: {"name": r[1], "slug": r[2], "parent_id": r[3], "woo_id": r[4]}
|
||||||
|
for r in conn.execute("SELECT id, name, slug, parent_id, woo_id FROM woo_categories")
|
||||||
|
}
|
||||||
|
assert rows[10]["parent_id"] is None
|
||||||
|
assert rows[11]["parent_id"] == 10
|
||||||
|
assert rows[20]["parent_id"] is None
|
||||||
|
assert rows[21]["parent_id"] == 20
|
||||||
|
# woo_id falls back to Woo category id when the payload has no woo_id field
|
||||||
|
assert rows[11]["woo_id"] == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_with_slab_species_fk_does_not_fail(db_dir):
|
||||||
|
"""Live UAT: DELETE woo_categories fails when a slab still points at species_id."""
|
||||||
|
conn = db_mod.get_connection()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (11,'Old Cedar','old-cedar',NULL,11)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO slabs(id, status, species_id, server_rev, created_at, updated_at) "
|
||||||
|
"VALUES ('slab-fk-1', 'draft', 11, 1, 't', 't')"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
cats = (
|
||||||
|
200,
|
||||||
|
{"X-WP-TotalPages": "1"},
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{"id": 10, "name": "Species", "slug": "species", "parent": 0},
|
||||||
|
{"id": 11, "name": "Cedar", "slug": "cedar", "parent": 10},
|
||||||
|
]
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
tags = (200, {"X-WP-TotalPages": "1"}, b"[]")
|
||||||
|
attrs = (200, {"X-WP-TotalPages": "1"}, b"[]")
|
||||||
|
c, _ = _client([cats, tags, attrs])
|
||||||
|
taxonomy_mod.sync(c, conn)
|
||||||
|
rows = {
|
||||||
|
r[0]: r[3]
|
||||||
|
for r in conn.execute("SELECT id, name, slug, parent_id FROM woo_categories")
|
||||||
|
}
|
||||||
|
assert rows[11] == 10 # parent mapped; row kept for FK
|
||||||
|
assert conn.execute("SELECT species_id FROM slabs WHERE id='slab-fk-1'").fetchone()[0] == 11
|
||||||
|
|
||||||
|
|
||||||
def test_403_raises_forbidden_error(db_dir):
|
def test_403_raises_forbidden_error(db_dir):
|
||||||
def fake_request(url, method, data, headers):
|
def fake_request(url, method, data, headers):
|
||||||
raise WooForbiddenError("request forbidden (403)")
|
raise WooForbiddenError("request forbidden (403)")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
|
||||||
|
|
||||||
interface SecretState { configured: boolean }
|
interface SecretState { configured: boolean }
|
||||||
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
|
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
|
||||||
@@ -18,6 +19,20 @@
|
|||||||
woo_create_status: string | null;
|
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 settings: Settings | null = null;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let saving = false;
|
let saving = false;
|
||||||
@@ -26,11 +41,155 @@
|
|||||||
// local edit buffers (write-only for secrets)
|
// local edit buffers (write-only for secrets)
|
||||||
let form: any = {};
|
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}.
|
// Unmasked UAT mode returns the secret as a plain string; masked mode returns {configured}.
|
||||||
const isUnmasked = (s: Secret) => typeof s === 'string';
|
const isUnmasked = (s: Secret) => typeof s === 'string';
|
||||||
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as 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);
|
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() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -56,6 +215,7 @@
|
|||||||
message = 'Failed to load settings';
|
message = 'Failed to load settings';
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
|
void loadRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -108,6 +268,7 @@
|
|||||||
const res = await fetch('/api/v1/settings/test-woo', { method: 'POST' });
|
const res = await fetch('/api/v1/settings/test-woo', { method: 'POST' });
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') };
|
wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') };
|
||||||
|
if (wooTest.ok) await loadRates();
|
||||||
} catch {
|
} catch {
|
||||||
wooTest = { ok: false, detail: 'network error' };
|
wooTest = { ok: false, detail: 'network error' };
|
||||||
}
|
}
|
||||||
@@ -201,6 +362,46 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<div class="section">
|
||||||
<h3>Inference</h3>
|
<h3>Inference</h3>
|
||||||
<button on:click={testInference} disabled={saving || inferenceTest?.busy}>Test Inference connection</button>
|
<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 { margin-top: 20px; padding-top: 12px; border-top: 1px solid #333; }
|
||||||
.section h3 { margin: 0 0 8px; font-size: 1.05rem; color: #eee; }
|
.section h3 { margin: 0 0 8px; font-size: 1.05rem; color: #eee; }
|
||||||
.section button { margin-top: 4px; }
|
.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>
|
</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' }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { test, expect, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SETTINGS-35 UI contract against vite preview (no /api proxy).
|
||||||
|
* Taxonomy/pricing are Playwright mocks with fixture ids only — never live UAT
|
||||||
|
* store ids (QUALITY-105 / METADATA-5).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SETTINGS = {
|
||||||
|
woo_base_url: 'https://example.invalid',
|
||||||
|
woo_wp_username: 'wpuser',
|
||||||
|
woo_app_password: { configured: true },
|
||||||
|
inference_base_url: { configured: false },
|
||||||
|
inference_api_key: { configured: false },
|
||||||
|
inference_model: null,
|
||||||
|
inference_enabled: false,
|
||||||
|
content_llm_enabled: false,
|
||||||
|
brand_voice: null,
|
||||||
|
geo_context: null,
|
||||||
|
woo_create_status: 'draft',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Synthetic parented tree. Fixture ids 10/11/12/20/21/99 — not production. */
|
||||||
|
const PARENTED_TAXONOMY = {
|
||||||
|
categories: [
|
||||||
|
{ 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 },
|
||||||
|
],
|
||||||
|
attributes: [],
|
||||||
|
tags: [],
|
||||||
|
taxonomy_anchor: 'fixture',
|
||||||
|
taxonomy_last_sync_at: '2026-01-01T00:00:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function json(route: Route, body: unknown, status = 200) {
|
||||||
|
await route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiOpts = {
|
||||||
|
taxonomy?: unknown;
|
||||||
|
pricingGet?: () => unknown;
|
||||||
|
onPricingPost?: (route: Route, payload: Record<string, unknown>) => Promise<void>;
|
||||||
|
onPricingPut?: (route: Route, id: string, payload: Record<string, unknown>) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mockApi(page: Page, opts: ApiOpts) {
|
||||||
|
await page.route('**/api/v1/**', async (route) => {
|
||||||
|
const req = route.request();
|
||||||
|
const url = new URL(req.url());
|
||||||
|
const path = url.pathname;
|
||||||
|
const method = req.method();
|
||||||
|
const payload = (() => {
|
||||||
|
try {
|
||||||
|
return req.postDataJSON() as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (path === '/api/v1/settings' && method === 'GET') {
|
||||||
|
await json(route, SETTINGS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === '/api/v1/admin/taxonomy' && method === 'GET') {
|
||||||
|
await json(route, opts.taxonomy ?? { categories: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const putMatch = path.match(/^\/api\/v1\/admin\/pricing\/(\d+)$/);
|
||||||
|
if (putMatch && method === 'PUT') {
|
||||||
|
if (opts.onPricingPut) {
|
||||||
|
await opts.onPricingPut(route, putMatch[1], payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await json(route, { error: { code: 'not_found', message: 'rule not found' } }, 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === '/api/v1/admin/pricing' && method === 'GET') {
|
||||||
|
const list = opts.pricingGet ? opts.pricingGet() : [];
|
||||||
|
await json(route, list);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === '/api/v1/admin/pricing' && method === 'POST') {
|
||||||
|
if (opts.onPricingPost) {
|
||||||
|
await opts.onPricingPost(route, payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await json(
|
||||||
|
route,
|
||||||
|
{
|
||||||
|
id: 601,
|
||||||
|
species_id: payload.species_id,
|
||||||
|
price_per_bdft: payload.price_per_bdft,
|
||||||
|
},
|
||||||
|
201
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await json(route, { error: { code: 'not_found', message: path } }, 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettings(page: Page) {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: 'Settings (dev)' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('species-pricing')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Settings species pricing (SETTINGS-35)', () => {
|
||||||
|
test('empty taxonomy: prompt only, no seeded species rows', async ({ page }) => {
|
||||||
|
await mockApi(page, { taxonomy: { categories: [] } });
|
||||||
|
await openSettings(page);
|
||||||
|
const empty = page.getByTestId('species-pricing-empty');
|
||||||
|
await expect(empty).toBeVisible();
|
||||||
|
await expect(empty).toContainText(/Test WooCommerce/i);
|
||||||
|
await expect(page.locator('.rate-row')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('save-rates')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('Cedar', { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByText('Maple', { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists only Species-grouper children (not wood cats / parents / furniture)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockApi(page, {
|
||||||
|
taxonomy: PARENTED_TAXONOMY,
|
||||||
|
pricingGet: () => [{ id: 501, species_id: 11, price_per_bdft: 8.25 }],
|
||||||
|
});
|
||||||
|
await openSettings(page);
|
||||||
|
await expect(page.locator('.rate-row')).toHaveCount(2);
|
||||||
|
await expect(page.locator('[data-species-slug="cedar"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-species-slug="maple"]')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Price per bdft for Cedar')).toHaveValue('8.25');
|
||||||
|
await expect(page.getByLabel('Price per bdft for Maple')).toHaveValue('');
|
||||||
|
await expect(page.locator('[data-species-slug="liveedge"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-species-slug="chairs"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-species-slug="species"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-species-slug="wcat"]')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('Live edge wood')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('Chairs', { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Save rates POSTs a new rule', async ({ page }) => {
|
||||||
|
const posts: Record<string, unknown>[] = [];
|
||||||
|
await mockApi(page, {
|
||||||
|
taxonomy: PARENTED_TAXONOMY,
|
||||||
|
onPricingPost: async (route, payload) => {
|
||||||
|
posts.push(payload);
|
||||||
|
await json(
|
||||||
|
route,
|
||||||
|
{ id: 601, species_id: payload.species_id, price_per_bdft: payload.price_per_bdft },
|
||||||
|
201
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await openSettings(page);
|
||||||
|
await page.getByLabel('Price per bdft for Cedar').fill('12.5');
|
||||||
|
await page.getByTestId('save-rates').click();
|
||||||
|
await expect(page.getByText('Saved 1 rate')).toBeVisible();
|
||||||
|
expect(posts).toEqual([{ species_id: 11, price_per_bdft: 12.5 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Save rates PUT after POST 409', async ({ page }) => {
|
||||||
|
const posts: Record<string, unknown>[] = [];
|
||||||
|
const puts: { id: string; payload: Record<string, unknown> }[] = [];
|
||||||
|
let sawConflict = false;
|
||||||
|
await mockApi(page, {
|
||||||
|
taxonomy: PARENTED_TAXONOMY,
|
||||||
|
pricingGet: () =>
|
||||||
|
sawConflict ? [{ id: 501, species_id: 12, price_per_bdft: 8 }] : [],
|
||||||
|
onPricingPost: async (route, payload) => {
|
||||||
|
posts.push(payload);
|
||||||
|
sawConflict = true;
|
||||||
|
await json(
|
||||||
|
route,
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: 'duplicate',
|
||||||
|
message: 'rule exists for species',
|
||||||
|
detail: { species_id: payload.species_id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
409
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPricingPut: async (route, id, payload) => {
|
||||||
|
puts.push({ id, payload });
|
||||||
|
await json(route, {
|
||||||
|
id: Number(id),
|
||||||
|
species_id: 12,
|
||||||
|
price_per_bdft: payload.price_per_bdft,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await openSettings(page);
|
||||||
|
await page.getByLabel('Price per bdft for Maple').fill('9.75');
|
||||||
|
await page.getByTestId('save-rates').click();
|
||||||
|
await expect(page.getByText('Saved 1 rate')).toBeVisible();
|
||||||
|
expect(posts).toEqual([{ species_id: 12, price_per_bdft: 9.75 }]);
|
||||||
|
expect(puts).toEqual([{ id: '501', payload: { price_per_bdft: 9.75 } }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user