WFDASH-WEBAPP/stock-flow-overview.html
Dave aed3f5c861 feat: implement discount functionality in POS system
Added support for discounts in the POS checkout process, allowing operators to apply either a fixed amount or a percentage off the gross total. Updated the state management to include discount details and modified the receipt and cart rendering to display pre- and post-discount values. Enhanced the checkout API to handle discount inputs and ensure proper calculations. Updated relevant tests to cover new discount logic.
2026-09-03 12:19:21 +02:00

589 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>WFDASH Stock Flow — How It Works Today</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=DM+Serif+Display&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
:root {
--bg: #fafafa;
--card: #ffffff;
--line: #e7e5e0;
--line-soft: #f0eeea;
--ink: #14171e;
--ink-2: #3a4256;
--ink-3: #7c7a72;
--accent: #2f6f5e;
--accent-soft: rgba(47, 111, 94, 0.08);
--accent-line: rgba(47, 111, 94, 0.22);
--warn: #c97b3f;
--warn-soft: rgba(201, 123, 63, 0.10);
--danger: #b06367;
--danger-soft: rgba(176, 99, 103, 0.10);
--pos: #5a4ec9;
--pos-soft: rgba(90, 78, 201, 0.10);
--pos-line: rgba(90, 78, 201, 0.25);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--ink);
font-family: "Outfit", system-ui, -apple-system, sans-serif;
font-size: 15px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 64px 28px 96px; }
/* Header */
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); font-weight: 600;
padding: 6px 12px; background: var(--accent-soft);
border-radius: 999px; margin-bottom: 20px;
}
h1 {
font-family: "DM Serif Display", serif;
font-weight: 400;
font-size: 44px;
line-height: 1.1;
margin: 0 0 16px;
letter-spacing: -0.01em;
}
.lede { color: var(--ink-2); font-size: 18px; max-width: 720px; margin: 0 0 48px; }
.lede strong { color: var(--ink); font-weight: 600; }
/* Section */
section { margin: 64px 0; }
h2 {
font-size: 22px; font-weight: 600; margin: 0 0 8px;
display: flex; align-items: center; gap: 10px;
}
h2 svg { color: var(--accent); flex-shrink: 0; }
h2 .num {
display: inline-grid; place-items: center;
width: 26px; height: 26px; border-radius: 8px;
background: var(--accent); color: white;
font-size: 13px; font-weight: 600; font-family: "Outfit";
}
.sub { color: var(--ink-3); font-size: 14px; margin: 0 0 24px; }
/* Source of truth card */
.truth {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 24px 28px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.truth-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 24px;
}
@media (max-width: 760px) { .truth-grid { grid-template-columns: 1fr; } }
.truth h3 {
font-size: 16px; font-weight: 600; margin: 0 0 6px;
font-family: "JetBrains Mono", monospace; color: var(--accent);
}
.truth p { margin: 0 0 12px; color: var(--ink-2); font-size: 14px; }
.kv { display: grid; grid-template-columns: 130px 1fr; row-gap: 8px; column-gap: 16px; margin: 0; }
.kv dt { color: var(--ink-3); font-size: 13px; font-family: "JetBrains Mono", monospace; }
.kv dd { margin: 0; font-size: 13px; color: var(--ink); font-family: "JetBrains Mono", monospace; }
/* Path cards: stock IN and stock OUT */
.paths { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 760px) { .paths { grid-template-columns: 1fr; } }
.path {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 22px 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.path.in { border-top: 3px solid var(--accent); }
.path.out { border-top: 3px solid var(--warn); }
.path h3 {
font-size: 14px; font-weight: 600; margin: 0 0 4px;
text-transform: uppercase; letter-spacing: 0.06em;
}
.path.in h3 { color: var(--accent); }
.path.out h3 { color: var(--warn); }
.path h4 { font-size: 16px; font-weight: 600; margin: 8px 0 4px; color: var(--ink); }
.path p { font-size: 13.5px; color: var(--ink-2); margin: 0 0 12px; }
.path .where {
font-family: "JetBrains Mono", monospace; font-size: 12px;
color: var(--ink-3); padding: 6px 10px;
background: var(--line-soft); border-radius: 6px;
display: inline-block; margin-top: 4px;
}
.flow {
display: flex; align-items: center; gap: 6px;
font-family: "JetBrains Mono", monospace; font-size: 11.5px;
color: var(--ink-2); flex-wrap: wrap; margin: 8px 0 4px;
}
.flow .pill {
background: var(--line-soft); padding: 3px 8px; border-radius: 5px;
}
.flow .arrow { color: var(--ink-3); }
/* POS section */
.pos-card {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 28px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
position: relative; overflow: hidden;
}
.pos-card::before {
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: var(--pos);
}
.pos-grid {
display: grid; grid-template-columns: 280px 1fr; gap: 32px; align-items: start;
}
@media (max-width: 760px) { .pos-grid { grid-template-columns: 1fr; } }
.pos-icon {
background: var(--pos-soft); border-radius: 14px;
aspect-ratio: 1; display: grid; place-items: center;
border: 1px solid var(--pos-line);
}
.pos-icon svg { width: 64px; height: 64px; color: var(--pos); }
.pos-meta { font-size: 14px; color: var(--ink-2); }
.pos-meta p { margin: 0 0 12px; }
.pos-meta .endpoint {
display: block; font-family: "JetBrains Mono", monospace; font-size: 12.5px;
color: var(--ink); padding: 6px 10px; background: var(--line-soft);
border-radius: 6px; margin: 4px 0;
}
.pos-meta .endpoint .verb {
color: var(--pos); font-weight: 600; margin-right: 8px;
}
/* Pricing triggers */
.triggers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@media (max-width: 820px) { .triggers { grid-template-columns: 1fr; } }
.trigger {
background: var(--card); border: 1px solid var(--line);
border-radius: 14px; padding: 18px 20px;
}
.trigger .when {
font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--warn); margin-bottom: 8px;
}
.trigger h4 { font-size: 15px; font-weight: 600; margin: 0 0 6px; }
.trigger p { font-size: 13.5px; color: var(--ink-2); margin: 0 0 10px; }
.trigger .set-by {
font-family: "JetBrains Mono", monospace; font-size: 11.5px;
color: var(--ink-3);
}
.trigger .set-by b { color: var(--ink); font-weight: 500; }
/* Mermaid */
.mermaid-wrap {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
overflow-x: auto;
}
.mermaid { font-family: "Outfit", sans-serif !important; }
.mermaid .nodeLabel { font-family: "Outfit", sans-serif !important; font-size: 13px !important; }
/* Legend */
.legend {
display: flex; flex-wrap: wrap; gap: 18px;
margin-top: 20px; font-size: 13px; color: var(--ink-2);
}
.legend-item { display: flex; align-items: center; gap: 8px; }
.legend-swatch { width: 14px; height: 14px; border-radius: 4px; }
/* Code refs */
.ref {
font-family: "JetBrains Mono", monospace; font-size: 12px;
color: var(--ink-3);
}
.ref b { color: var(--ink); font-weight: 500; }
/* Footer */
footer {
margin-top: 80px; padding-top: 24px; border-top: 1px solid var(--line);
font-size: 13px; color: var(--ink-3);
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px;
}
</style>
</head>
<body>
<div class="wrap">
<div class="eyebrow">WFDASH · Stock &amp; POS Architecture</div>
<h1>How stock works today — and how it talks to the POS.</h1>
<p class="lede">
There is one source of truth (<strong>stock_ledger</strong>) and one denormalised
cache (<strong>stock_items.current_qty</strong>). Every stock movement
— buying from a supplier, booking a surplus, fulfilling a customer order, ringing
up a sale at the till — writes a ledger row first, then mirrors the qty into the
cache. The POS app never touches the cache directly; it goes through three
authenticated API endpoints.
</p>
<!-- 1. Source of truth -->
<section>
<h2><span class="num">1</span> The single source of truth</h2>
<p class="sub">Two PocketBase collections carry the entire stock system. The
ledger is append-only and signed; the cache is denormalised for fast reads.</p>
<div class="truth">
<div class="truth-grid">
<div>
<h3>stock_items</h3>
<p>The catalogue. One row per SKU. Carries all the
pricing, supplier linkage, and the running quantity the UI reads.</p>
<dl class="kv">
<dt>sku</dt><dd>supplier's code</dd>
<dt>name / unit</dt><dd>display + uom</dd>
<dt>current_qty</dt><dd>denormalised cache</dd>
<dt>min_qty</dt><dd>low-stock trigger</dd>
<dt>target_qty</dt><dd>auto-reorder goal</dd>
<dt>cost_price</dt><dd>latest purchase cost</dd>
<dt>selling_price</dt><dd>POS shelf price</dd>
<dt>markup</dt><dd>POS markup %</dd>
<dt>cost_history</dt><dd>append-only list</dd>
<dt>supplier</dt><dd>FK → suppliers</dd>
<dt>is_active</dt><dd>soft-hide flag</dd>
</dl>
</div>
<div>
<h3>stock_ledger</h3>
<p>The audit trail. Every stock movement — in or out — is a row here
with a signed quantity and a reason. The cache is rebuildable
from this table alone.</p>
<dl class="kv">
<dt>stock_item</dt><dd>FK → stock_items</dd>
<dt>change_qty</dt><dd>signed (+ / −)</dd>
<dt>unit_cost</dt><dd>when known</dd>
<dt>reason</dt><dd>enum (see below)</dd>
<dt>reference_type</dt><dd>e.g. "order_item"</dd>
<dt>reference_id</dt><dd>FK to the source</dd>
<dt>notes</dt><dd>free text + audit tags</dd>
<dt>created_by</dt><dd>user FK</dd>
</dl>
</div>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background: var(--accent);"></div>stock-in paths (qty &gt; 0)</div>
<div class="legend-item"><div class="legend-swatch" style="background: var(--warn);"></div>stock-out paths (qty &lt; 0)</div>
<div class="legend-item"><div class="legend-swatch" style="background: var(--pos);"></div>POS app surfaces</div>
</div>
</div>
</section>
<!-- 2. Stock IN paths -->
<section>
<h2><span class="num">2</span> How items get INTO stock</h2>
<p class="sub">Three entry points. All three end in
<span class="ref"><b>stock_ledger.reason ∈ {stock_order, purchase_surplus, adjustment}</b></span>.</p>
<div class="paths">
<div class="path in">
<h3>Path A</h3>
<h4>Receive a stock purchase (the accountant's flow)</h4>
<p>Warehouse marks the PO received
(<span class="ref"><b>stock_purchases.status = "received"</b></span>).
Accountant then hits "Book into stock" on the invoicing page.</p>
<div class="flow">
<span class="pill">stock_purchase.received</span>
<span class="arrow">→</span>
<span class="pill">bookStockFromInvoice()</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger +qty</span>
<span class="arrow">→</span>
<span class="pill">stock_items.current_qty + qty</span>
<span class="arrow">→</span>
<span class="pill">booked_at stamped</span>
</div>
<div class="where">src/lib/server/stock-booking-service.ts · /api/v1/invoicing/book</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
Partial lines supported — each line carries its own
<span class="ref"><b>quantity_received</b></span> accumulator.
</p>
</div>
<div class="path in">
<h3>Path B</h3>
<h4>Book a surplus from a customer collection</h4>
<p>When the supplier delivers more than the customer ordered
(e.g. MOQ), the driver records <span class="ref"><b>qty_collected</b></span>
on the order item. Operator splits the difference into stock.</p>
<div class="flow">
<span class="pill">order_item.collected</span>
<span class="arrow">→</span>
<span class="pill">findMatchingStockItem()</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger +surplus</span>
<span class="arrow">→</span>
<span class="pill">stock_items.current_qty + surplus</span>
</div>
<div class="where">src/lib/server/stock-surplus.ts · /api/v1/stock/surplus-book</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
Match by SKU → by supplier + normalised description. Operator can override.
</p>
</div>
<div class="path in">
<h3>Path C</h3>
<h4>Manual stocktake adjustment</h4>
<p>Operator counts the shelf, finds a discrepancy vs. the cache,
and posts a signed correction.</p>
<div class="flow">
<span class="pill">/stock/[id]</span>
<span class="arrow">→</span>
<span class="pill">stock_items.adjust</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger ±qty (reason: adjustment)</span>
<span class="arrow">→</span>
<span class="pill">current_qty patched</span>
</div>
<div class="where">/api/v1/stock/items/[id]/adjust</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
Compensation tagging kicks in if the cache write fails after the ledger row lands.
</p>
</div>
</div>
</section>
<!-- 3. Stock OUT paths -->
<section>
<h2><span class="num">3</span> How items get OUT of stock</h2>
<p class="sub">Three exit points. All three write
<span class="ref"><b>stock_ledger.reason = "customer_fulfillment"</b></span> with a
negative quantity (or <b>"adjustment"</b> for write-offs).</p>
<div class="paths">
<div class="path out">
<h3>Path D</h3>
<h4>Fulfil a customer order from stock</h4>
<p>Operator on <span class="ref"><b>/orders/new</b></span> ticks "fulfil from stock"
for a line. Same route used on <span class="ref"><b>/orders/[id]/process</b></span>
to redirect an existing line.</p>
<div class="flow">
<span class="pill">order_item create</span>
<span class="arrow">→</span>
<span class="pill">item_status = "stock_booked"</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger −qty</span>
<span class="arrow">→</span>
<span class="pill">current_qty − qty</span>
</div>
<div class="where">src/app/api/v1/stock/fulfill/route.ts</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
`stock_booked_from` back-link written on the order_item for audit + drill-down.
</p>
</div>
<div class="path out">
<h3>Path E</h3>
<h4>POS sale (the till)</h4>
<p>External WFSALES POS app (Flutter, separate repo) calls a
<span class="ref"><b>Bearer POS_API_KEY</b></span>-protected endpoint,
commits a sale, and decrements stock in one transaction.</p>
<div class="flow">
<span class="pill">till checkout</span>
<span class="arrow">→</span>
<span class="pill">pos_sales + pos_sale_items</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger −qty (ref: pos_sale)</span>
<span class="arrow">→</span>
<span class="pill">current_qty − qty</span>
</div>
<div class="where">src/lib/server/pos-service.ts · /api/v1/pos/checkout</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
Idempotent on <span class="ref"><b>client_ref</b></span> — offline till retries
don't double-decrement.
</p>
</div>
<div class="path out">
<h3>Path F</h3>
<h4>Write-off / manual shrink</h4>
<p>Damaged in the warehouse, expired, lost, given to a sample kit.
Same <span class="ref"><b>/stock/[id]/adjust</b></span> route as Path C,
just a negative qty.</p>
<div class="flow">
<span class="pill">/stock/[id]</span>
<span class="arrow">→</span>
<span class="pill">stock_items.adjust (−n)</span>
<span class="arrow">→</span>
<span class="pill">stock_ledger reason: adjustment</span>
</div>
<div class="where">/api/v1/stock/items/[id]/adjust</div>
<p style="font-size: 12.5px; color: var(--ink-3); margin-top: 10px;">
One route handles both directions — the sign of the qty is the difference.
</p>
</div>
</div>
</section>
<!-- 4. POS integration -->
<section>
<h2><span class="num">4</span> How the POS app links in</h2>
<p class="sub">The WFSALES POS is a <b>separate</b> app (Flutter) that runs on the
office till. It only ever talks to WFDASH through three authenticated endpoints
under <span class="ref"><b>/api/v1/pos/*</b></span>. The auth is a
device-scoped bearer token (<span class="ref"><b>POS_API_KEY</b></span>) — distinct
from the agent's key, because the till only needs catalog + checkout + history.</p>
<div class="pos-card">
<div class="pos-grid">
<div class="pos-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<path d="M7 8h10M7 12h6M7 16h4"/>
<circle cx="17" cy="16" r="2.5"/>
</svg>
</div>
<div class="pos-meta">
<p><b>Catalog pull</b> — at till boot, the POS calls the catalog endpoint and caches
the active stock list locally. This is what makes the offline queue possible.</p>
<span class="endpoint"><span class="verb">GET</span>/api/v1/pos/catalog</span>
<p style="margin-top: 14px;"><b>Checkout</b> — when the cashier hits "ring up", a single
POST creates the sale + line items + the negative ledger rows + decrements the cache.
Idempotent on <span class="ref"><b>client_ref</b></span> so a retry of a lost-response sale
doesn't double-decrement stock.</p>
<span class="endpoint"><span class="verb">POST</span>/api/v1/pos/checkout</span>
<p style="margin-top: 14px;"><b>History + cashup</b> — the till pulls today's sales for
cashup reconciliation and the cashier's shift close.</p>
<span class="endpoint"><span class="verb">GET</span>/api/v1/pos/sales?from=…&amp;to=…</span>
<p style="margin-top: 14px; margin-bottom: 0; font-size: 13px; color: var(--ink-3);">
<b>Categories</b> in the catalog come from server-side matching
(<span class="ref"><b>match_brands → match_supplier_tags → match_keywords</b></span> on the
<b>categories</b> collection), not from a relation on the stock row. Tills can read the
category icon straight off the response.
</p>
</div>
</div>
</div>
</section>
<!-- 5. When prices get set -->
<section>
<h2><span class="num">5</span> When do prices get set?</h2>
<p class="sub">Three pricing fields on a stock row —
<span class="ref"><b>cost_price</b></span>, <span class="ref"><b>selling_price</b></span>,
<span class="ref"><b>markup</b></span>. Each is set by a different trigger.</p>
<div class="triggers">
<div class="trigger">
<div class="when">Trigger 1 · Receive a stock purchase</div>
<h4>cost_price (auto)</h4>
<p>When the accountant books a stock purchase, the line's
<span class="ref"><b>cost_price</b></span> overwrites the stock row's
<b>cost_price</b> and appends an immutable entry to
<span class="ref"><b>cost_history</b></span>.</p>
<div class="set-by">Set by: <b>buildStockReceiptCostPatch()</b> in
stock-cost-service.ts</div>
</div>
<div class="trigger">
<div class="when">Trigger 2 · Manual edit on /stock</div>
<h4>selling_price + markup (manual)</h4>
<p>Operator edits the shelf price or markup % directly on the stock detail page.
<b>If cost changes,</b> a zero-qty ledger row is written as an audit record
(reason: <span class="ref"><b>adjustment</b></span>, ref: <b>manual</b>).</p>
<div class="set-by">Set by: <b>updateStockItemsPricing()</b> in
stock-pricing-service.ts</div>
</div>
<div class="trigger">
<div class="when">Trigger 3 · AI / Hermes agent</div>
<h4>selling_price (autonomous)</h4>
<p>External agent (Hermes) keeps the catalogue in sync with supplier price lists —
e.g. when a price list PDF re-baselines, it PATCHes the
<span class="ref"><b>selling_price</b></span> field through the agent API.</p>
<div class="set-by">Set by: <b>updateStockItem()</b> in
stock-items-service.ts (PATCH /api/v1/stock/items)</div>
</div>
</div>
</section>
<!-- 6. The graph -->
<section>
<h2><span class="num">6</span> The whole flow, in one picture</h2>
<p class="sub">Green = stock-in. Orange = stock-out. Purple = POS surfaces.
Everything terminates in the same ledger.</p>
<div class="mermaid-wrap">
<pre class="mermaid">
flowchart TB
classDef in fill:#e8f1ed,stroke:#2f6f5e,color:#14171e
classDef out fill:#faeede,stroke:#c97b3f,color:#14171e
classDef pos fill:#ede9f8,stroke:#5a4ec9,color:#14171e
classDef cache fill:#ffffff,stroke:#7c7a72,color:#14171e,stroke-dasharray: 4 3
classDef truth fill:#f4f1ea,stroke:#14171e,color:#14171e
subgraph SOURCES["Stock-in sources"]
direction TB
SP["stock_purchase<br/>(accountant books)"]:::in
SU["customer order<br/>(supplier over-delivers)"]:::in
ADJ_IN["manual adjustment<br/>(stocktake correction)"]:::in
end
subgraph OUTS["Stock-out destinations"]
direction TB
CO["customer order<br/>(fulfil from stock)"]:::out
PSALE["POS sale<br/>(WFSALES till)"]:::pos
ADJ_OUT["manual write-off<br/>(damage / loss)"]:::out
end
LEDGER[("stock_ledger<br/>(append-only, signed qty)")]:::truth
CACHE[("stock_items.current_qty<br/>(denormalised cache)")]:::cache
POS_APP["WFSALES POS app<br/>(Flutter, separate repo)"]:::pos
POS_APP -- GET /api/v1/pos/catalog --> CACHE
POS_APP -- POST /api/v1/pos/checkout --> PSALE
SP -- "bookStockFromInvoice<br/>reason: stock_order" --> LEDGER
SU -- "bookSurplus<br/>reason: purchase_surplus" --> LEDGER
ADJ_IN -- "stock_items.adjust<br/>reason: adjustment" --> LEDGER
CO -- "stock/fulfill<br/>reason: customer_fulfillment" --> LEDGER
PSALE -- "checkoutSale<br/>reason: customer_fulfillment<br/>ref: pos_sale" --> LEDGER
ADJ_OUT -- "stock_items.adjust<br/>reason: adjustment" --> LEDGER
LEDGER -. "update current_qty" .-> CACHE
WFDASH_UI["WFDASH /stock, /stock-orders,<br/>/orders/[id] (operator UI)"]
WFDASH_UI -- "reads" --> CACHE
WFDASH_UI -- "drills into" --> LEDGER
</pre>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background: var(--accent);"></div>Stock-in to ledger</div>
<div class="legend-item"><div class="legend-swatch" style="background: var(--warn);"></div>Stock-out to ledger</div>
<div class="legend-item"><div class="legend-swatch" style="background: var(--pos);"></div>POS-app surfaces</div>
<div class="legend-item"><div class="legend-swatch" style="background: white; border: 1px dashed var(--ink-3);"></div>Derived cache (rebuildable from ledger)</div>
</div>
</section>
<footer>
<span>WFDASH codebase · paths referenced as <code>src/...</code> from the repo root</span>
<span>Generated for the WFDASH operator walkthrough</span>
</footer>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'base',
themeVariables: {
fontFamily: '"Outfit", system-ui, sans-serif',
fontSize: '13px',
primaryColor: '#ffffff',
primaryTextColor: '#14171e',
primaryBorderColor: '#e7e5e0',
lineColor: '#7c7a72',
secondaryColor: '#fafafa',
tertiaryColor: '#fafafa',
},
flowchart: { curve: 'basis', padding: 12 }
});
</script>
</body>
</html>