feat(publish): S6 Woo create sequence + UAT draft + dup/stale/partial failure + Publish UI

- backend/publish.py: POST /slabs/{id}/publish. Sequence (PUBLISH-5): validate ready
  set -> SKU lookup (409 on dup, PUBLISH-15) -> refresh taxonomy if >1h (SETTINGS-60)
  -> resolve cats/tags/5 attributes -> upload processed PNGs -> POST /products ->
  finalize (set woo_product_id, purge photos, PUBLISH-25). SLAB-UAT-* always draft
  (PUBLISH-10). Failure: mark failed, retain photos, sync_log, plain recoverable/
  unrecoverable message (PUBLISH-30/36/37/38). Partial (media ok, POST failed):
  orphan media logged + manual cleanup note (PUBLISH-31). Payload: only non-null,
  slug = name+sku, sold_individually true (PUBLISH-55/60).
- frontend: create/publish/Publish.svelte (slab id, publish, result + recoverable/
  orphan tags) + dev toggle.
- tests: 5 publish tests (success+purge, UAT draft, not-ready 422, dup 409,
  partial-media 502). Woo transport mocked.

Verified: pytest 48 passed; gates exit 0; npm build ok.
This commit is contained in:
2026-09-19 17:00:26 -07:00
parent 732310fdbe
commit 5e03f45660
5 changed files with 493 additions and 0 deletions
+6
View File
@@ -13,6 +13,7 @@ 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';
import Publish from './create/publish/Publish.svelte';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
const session: CaptureSession = createCaptureSession();
@@ -24,6 +25,7 @@ import Review from './create/review/Review.svelte';
let showSlabs = false;
let showPhotos = false;
let showReview = false;
let showPublish = false;
const unsubCap = session.capture.subscribe((c) => {
cap = c;
@@ -101,6 +103,7 @@ import Review from './create/review/Review.svelte';
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
<button on:click={() => (showPublish = !showPublish)}>Publish (dev)</button>
</div>
{#if showSettings}
<Settings />
@@ -114,6 +117,9 @@ import Review from './create/review/Review.svelte';
{#if showReview}
<Review />
{/if}
{#if showPublish}
<Publish />
{/if}
</main>
{/if}
@@ -0,0 +1,69 @@
<script lang="ts">
let slabId = '';
let result: {
ok?: boolean;
status?: string;
woo_product_id?: number;
draft?: boolean;
error?: { code: string; message: string; detail?: Record<string, unknown> };
} | null = null;
let busy = false;
async function publish() {
if (!slabId) {
result = { error: { code: 'input', message: 'Enter a slab id.' } };
return;
}
busy = true;
result = null;
try {
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, { method: 'POST' });
const body = await res.json();
if (res.ok) result = body;
else result = { ok: false, error: body.error };
} catch {
result = { ok: false, error: { code: 'network', message: 'network error' } };
}
busy = false;
}
</script>
<div class="publish">
<h2>Publish to WooCommerce</h2>
<div>
<label>Slab ID</label>
<input bind:value={slabId} placeholder="uuid" />
</div>
<button on:click={publish} disabled={busy}>{busy ? 'Publishing…' : 'Publish'}</button>
{#if result}
{#if result.ok}
<p class="ok">
Published{result.draft ? ' (UAT draft)' : ''} → Woo product #{result.woo_product_id}
</p>
{:else}
<p class="err">
{result.error?.message}
{#if result.error?.detail?.recoverable === false}
<span class="tag">unrecoverable</span>
{:else if result.error?.detail?.recoverable === true}
<span class="tag">recoverable — retry</span>
{/if}
{#if result.error?.detail?.partial_media}
<span class="tag warn">orphan media — clean up manually</span>
{/if}
</p>
{/if}
{/if}
</div>
<style>
.publish { max-width: 520px; color: #eee; }
label { display: block; margin: 8px 0 4px; }
input { width: 100%; padding: 6px; }
button { margin-top: 12px; }
.ok { margin-top: 12px; color: #7fd37f; font-weight: 600; }
.err { margin-top: 12px; color: #e88; }
.tag { margin-left: 8px; font-size: 12px; padding: 1px 6px; border-radius: 4px; background: #444; }
.tag.warn { background: #6a4; color: #000; }
</style>