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.
589 lines
24 KiB
HTML
589 lines
24 KiB
HTML
<!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 & 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 > 0)</div>
|
||
<div class="legend-item"><div class="legend-swatch" style="background: var(--warn);"></div>stock-out paths (qty < 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=…&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>
|