Explorar o código

Inventory part name suggestions in parts editors (#202)

* quick search while writing.

* helpers and tests
Bernt Christian Egeland hai 2 meses
pai
achega
86e4833538
Modificáronse 29 ficheiros con 1209 adicións e 56 borrados
  1. 6 0
      messages/de/inventory.json
  2. 6 0
      messages/en/inventory.json
  3. 6 0
      messages/es/inventory.json
  4. 6 0
      messages/fr/inventory.json
  5. 6 0
      messages/it/inventory.json
  6. 6 0
      messages/lt/inventory.json
  7. 6 0
      messages/nb/inventory.json
  8. 6 0
      messages/nl/inventory.json
  9. 6 0
      messages/pl/inventory.json
  10. 6 0
      messages/pt-BR/inventory.json
  11. 6 0
      messages/ru/inventory.json
  12. 6 0
      messages/tr/inventory.json
  13. 190 0
      src/__tests__/features/inventory/part-name-suggestions.test.tsx
  14. 402 0
      src/__tests__/features/inventory/part-pricing.test.ts
  15. 5 3
      src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx
  16. 2 1
      src/features/billing/Actions/recurringInvoiceActions.ts
  17. 2 1
      src/features/inventory/Components/InventoryPartForm.tsx
  18. 210 0
      src/features/inventory/Components/PartNameSuggestions.tsx
  19. 160 0
      src/features/inventory/Lib/partPricing.ts
  20. 1 1
      src/features/quotes/Components/QuoteLaborEditor.tsx
  21. 2 1
      src/features/quotes/Components/QuotePageClient.tsx
  22. 47 4
      src/features/quotes/Components/QuotePartsEditor.tsx
  23. 2 1
      src/features/quotes/Components/useQuoteFormState.ts
  24. 6 7
      src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx
  25. 84 12
      src/features/vehicles/Components/service-edit/PartsEditor.tsx
  26. 1 0
      src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx
  27. 12 11
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  28. 9 13
      src/features/vehicles/Components/service-page/useServiceFormState.ts
  29. 2 1
      src/lib/cron/recurring-invoices.ts

+ 6 - 0
messages/de/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Lager",
     "reason": "Grund",
     "allReasons": "Alle Gründe"
+  },
+  "suggestions": {
+    "title": "Aus Ihrem Lager",
+    "inStock": "{quantity} auf Lager",
+    "outOfStock": "Nicht auf Lager",
+    "onBackorder": "Im Rückstand ({count})"
   }
 }

+ 6 - 0
messages/en/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Inventory",
     "reason": "Reason",
     "allReasons": "All reasons"
+  },
+  "suggestions": {
+    "title": "From your inventory",
+    "inStock": "{quantity} in stock",
+    "outOfStock": "Out of stock",
+    "onBackorder": "On backorder ({count})"
   }
 }

+ 6 - 0
messages/es/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Inventario",
     "reason": "Motivo",
     "allReasons": "Todos los motivos"
+  },
+  "suggestions": {
+    "title": "De su inventario",
+    "inStock": "{quantity} en stock",
+    "outOfStock": "Agotado",
+    "onBackorder": "En pedido pendiente ({count})"
   }
 }

+ 6 - 0
messages/fr/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Inventaire",
     "reason": "Motif",
     "allReasons": "Tous les motifs"
+  },
+  "suggestions": {
+    "title": "Depuis votre stock",
+    "inStock": "{quantity} en stock",
+    "outOfStock": "En rupture de stock",
+    "onBackorder": "En réapprovisionnement ({count})"
   }
 }

+ 6 - 0
messages/it/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Magazzino",
     "reason": "Motivo",
     "allReasons": "Tutti i motivi"
+  },
+  "suggestions": {
+    "title": "Dal tuo magazzino",
+    "inStock": "{quantity} in magazzino",
+    "outOfStock": "Esaurito",
+    "onBackorder": "In arretrato ({count})"
   }
 }

+ 6 - 0
messages/lt/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Sandėlis",
     "reason": "Priežastis",
     "allReasons": "Visos priežastys"
+  },
+  "suggestions": {
+    "title": "Iš jūsų sandėlio",
+    "inStock": "{quantity} sandėlyje",
+    "outOfStock": "Nėra sandėlyje",
+    "onBackorder": "Užsakoma ({count})"
   }
 }

+ 6 - 0
messages/nb/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Lager",
     "reason": "Årsak",
     "allReasons": "Alle årsaker"
+  },
+  "suggestions": {
+    "title": "Fra lageret ditt",
+    "inStock": "{quantity} på lager",
+    "outOfStock": "Utsolgt",
+    "onBackorder": "På restordre ({count})"
   }
 }

+ 6 - 0
messages/nl/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Voorraad",
     "reason": "Reden",
     "allReasons": "Alle redenen"
+  },
+  "suggestions": {
+    "title": "Uit uw voorraad",
+    "inStock": "{quantity} in voorraad",
+    "outOfStock": "Niet op voorraad",
+    "onBackorder": "In nabestelling ({count})"
   }
 }

+ 6 - 0
messages/pl/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Magazyn",
     "reason": "Powód",
     "allReasons": "Wszystkie powody"
+  },
+  "suggestions": {
+    "title": "Z Twojego magazynu",
+    "inStock": "{quantity} w magazynie",
+    "outOfStock": "Brak w magazynie",
+    "onBackorder": "W zamówieniu ({count})"
   }
 }

+ 6 - 0
messages/pt-BR/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Estoque",
     "reason": "Motivo",
     "allReasons": "Todos os motivos"
+  },
+  "suggestions": {
+    "title": "Do seu estoque",
+    "inStock": "{quantity} em estoque",
+    "outOfStock": "Fora de estoque",
+    "onBackorder": "Sob encomenda ({count})"
   }
 }

+ 6 - 0
messages/ru/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Склад",
     "reason": "Причина",
     "allReasons": "Все причины"
+  },
+  "suggestions": {
+    "title": "Из вашего склада",
+    "inStock": "{quantity} в наличии",
+    "outOfStock": "Нет в наличии",
+    "onBackorder": "Под заказ ({count})"
   }
 }

+ 6 - 0
messages/tr/inventory.json

@@ -143,5 +143,11 @@
     "backToInventory": "Stok",
     "reason": "Neden",
     "allReasons": "Tüm nedenler"
+  },
+  "suggestions": {
+    "title": "Stoğunuzdan",
+    "inStock": "{quantity} stokta",
+    "outOfStock": "Stokta yok",
+    "onBackorder": "Ön siparişte ({count})"
   }
 }

+ 190 - 0
src/__tests__/features/inventory/part-name-suggestions.test.tsx

@@ -0,0 +1,190 @@
+/**
+ * Tests for the inline part-name typeahead shown in the work order and quote
+ * parts editors.
+ *
+ * The value of this feature is that it links a line to stock. A line typed by
+ * hand stays unlinked, and an unlinked line never deducts inventory when the
+ * job is saved, so the suggestion has to surface the right part early and
+ * apply the inventory id when picked.
+ */
+
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import {
+  PartNameSuggestions,
+  type PartSuggestion,
+} from "@/features/inventory/Components/PartNameSuggestions";
+
+vi.mock("next-intl", () => ({
+  useTranslations: () => (key: string, values?: Record<string, unknown>) =>
+    values ? `${key}:${JSON.stringify(values)}` : key,
+}));
+
+vi.mock("@/components/currency-settings-context", () => ({
+  useFormatCurrency: () => (n: number) => `$${n.toFixed(2)}`,
+}));
+
+const PARTS: PartSuggestion[] = [
+  { id: "p1", name: "Brake pad front", partNumber: "BP-1", unitCost: 10, sellPrice: 25, quantity: 4 },
+  { id: "p2", name: "Brake disc", partNumber: "BD-9", unitCost: 30, sellPrice: 70, quantity: 2 },
+  { id: "p3", name: "Oil filter", partNumber: "OF-2", unitCost: 4, sellPrice: 12, quantity: 9 },
+  { id: "p4", name: "Brake fluid", partNumber: "BF-3", unitCost: 5, sellPrice: 14, quantity: 6 },
+  { id: "p5", name: "Brake cable", partNumber: "BC-7", unitCost: 8, sellPrice: 20, quantity: 1 },
+];
+
+function setup(query: string, over: Partial<React.ComponentProps<typeof PartNameSuggestions>> = {}) {
+  const onSelect = vi.fn();
+  render(
+    <PartNameSuggestions query={query} parts={PARTS} onSelect={onSelect} {...over} />,
+  );
+  return { onSelect };
+}
+
+/** Names of the rendered suggestion buttons, in order. */
+function shown() {
+  return screen.queryAllByRole("button").map((b) => b.textContent ?? "");
+}
+
+describe("PartNameSuggestions", () => {
+  it("shows nothing until enough has been typed", () => {
+    setup("b");
+    expect(shown()).toHaveLength(0);
+  });
+
+  it("shows nothing for an empty field", () => {
+    setup("");
+    expect(shown()).toHaveLength(0);
+  });
+
+  it("matches on name and caps the list at three", () => {
+    setup("brake");
+    // Four parts match "brake"; only three may render.
+    expect(shown()).toHaveLength(3);
+  });
+
+  it("matches on part number too", () => {
+    setup("of-2");
+    expect(shown()).toHaveLength(1);
+    expect(shown()[0]).toContain("Oil filter");
+  });
+
+  it("is case insensitive", () => {
+    setup("BRAKE PAD");
+    expect(shown()[0]).toContain("Brake pad front");
+  });
+
+  it("ranks an exact part-number hit first", () => {
+    setup("bd-9");
+    expect(shown()[0]).toContain("Brake disc");
+  });
+
+  it("ranks a name prefix above a mid-string match", () => {
+    // "filter" appears only inside "Oil filter", so a prefix beats it.
+    setup("oil");
+    expect(shown()[0]).toContain("Oil filter");
+  });
+
+  it("shows nothing when the row is already linked to stock", () => {
+    setup("brake", { disabled: true });
+    expect(shown()).toHaveLength(0);
+  });
+
+  it("hides once the name exactly matches the only suggestion", () => {
+    // The row already holds this part; re-offering it is noise.
+    setup("oil filter");
+    expect(shown()).toHaveLength(0);
+  });
+
+  it("passes the whole part back when picked, including the stock id", async () => {
+    const user = userEvent.setup();
+    const { onSelect } = setup("oil");
+    await user.click(screen.getAllByRole("button")[0]);
+    expect(onSelect).toHaveBeenCalledWith(
+      expect.objectContaining({ id: "p3", name: "Oil filter", sellPrice: 12 }),
+    );
+  });
+
+  it("shows stock level and price so the right part can be told apart", () => {
+    setup("oil");
+    expect(shown()[0]).toContain("9");
+    expect(shown()[0]).toContain("$12.00");
+  });
+
+  it("shows the price that will actually be applied, not a raw zero", () => {
+    // A part with no sell price is billed at cost. Showing $0.00 here
+    // contradicted what landed on the line.
+    const noSell: PartSuggestion[] = [
+      { id: "z", name: "Zero priced", partNumber: null, unitCost: 18, sellPrice: 0, quantity: 3 },
+    ];
+    render(<PartNameSuggestions query="zero" parts={noSell} onSelect={vi.fn()} />);
+    expect(shown()[0]).toContain("$18.00");
+    expect(shown()[0]).not.toContain("$0.00");
+  });
+
+  it("shows the marked-up price when the workshop marks up inventory", () => {
+    const p: PartSuggestion[] = [
+      { id: "m", name: "Marked up", partNumber: null, unitCost: 10, sellPrice: 25, quantity: 2 },
+    ];
+    render(
+      <PartNameSuggestions
+        query="marked"
+        parts={p}
+        onSelect={vi.fn()}
+        markupAppliesToInventory
+        defaultMarkupPercent={40}
+      />,
+    );
+    expect(shown()[0]).toContain("$14.00");
+  });
+
+  it("flags out of stock and backordered parts like the picker does", () => {
+    const p: PartSuggestion[] = [
+      { id: "o", name: "Outta stock", partNumber: null, unitCost: 5, sellPrice: 9, quantity: 0 },
+      { id: "b", name: "Outstanding backorder", partNumber: null, unitCost: 5, sellPrice: 9, quantity: -3 },
+    ];
+    render(<PartNameSuggestions query="out" parts={p} onSelect={vi.fn()} />);
+    const text = shown().join(" ");
+    expect(text).toContain("suggestions.outOfStock");
+    expect(text).toContain("suggestions.onBackorder");
+  });
+
+  it("exposes the full name as a tooltip for names that still overflow", () => {
+    const long: PartSuggestion[] = [
+      {
+        id: "long",
+        name: "Brake pad set front axle ceramic low-dust OEM equivalent",
+        partNumber: "BP-LONG-1",
+        unitCost: 10,
+        sellPrice: 40,
+        quantity: 3,
+      },
+    ];
+    render(<PartNameSuggestions query="brake" parts={long} onSelect={vi.fn()} />);
+    expect(screen.getByTitle(long[0].name)).toBeTruthy();
+  });
+
+  it("can be reached by keyboard and picked with Enter", async () => {
+    // onMouseDown alone never fires for keyboard activation, so tabbing to a
+    // suggestion and pressing Enter used to do nothing at all.
+    const user = userEvent.setup();
+    const { onSelect } = setup("oil");
+    await user.tab();
+    expect(document.activeElement).toBe(screen.getAllByRole("button")[0]);
+    await user.keyboard("{Enter}");
+    expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "p3" }));
+  });
+
+  it("can be picked with Space", async () => {
+    const user = userEvent.setup();
+    const { onSelect } = setup("oil");
+    await user.tab();
+    await user.keyboard(" ");
+    expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "p3" }));
+  });
+
+  it("renders nothing when the inventory is empty", () => {
+    render(<PartNameSuggestions query="brake" parts={[]} onSelect={vi.fn()} />);
+    expect(shown()).toHaveLength(0);
+  });
+});

+ 402 - 0
src/__tests__/features/inventory/part-pricing.test.ts

@@ -0,0 +1,402 @@
+/**
+ * Tests for every money figure derived from a stocked part.
+ *
+ * This exists because the rules were duplicated. The inventory picker had the
+ * full fallback chain; the inline name suggestion used `sellPrice` directly,
+ * so any part whose sell price had never been filled in was added to the line
+ * at zero. Nothing errored, the customer was simply not charged. The
+ * cost-plus-markup formula was then hand-written in four more places, each
+ * free to drift.
+ *
+ * Expected values here are the arithmetic a workshop would do on paper, not a
+ * transcription of what the code returns. Where the two disagree the code is
+ * wrong.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+  lineTotal,
+  markupFromCostAndPrice,
+  parseQuantity,
+  priceFromCostAndMarkup,
+  priceFromCostAndMultiplier,
+  readPartsPricingSettings,
+  resolvePartPrice,
+  roundMoney,
+} from "@/features/inventory/Lib/partPricing";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+
+/** How the settings map actually arrives: keys to strings, everything optional. */
+const settingsMap = (defaultMarkupPercent?: string, markupAppliesToInventory?: string) => ({
+  [SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]: defaultMarkupPercent,
+  [SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY]: markupAppliesToInventory,
+});
+
+const readSettings = (settings: Record<string, string | undefined>) =>
+  readPartsPricingSettings(settings, {
+    defaultMarkupPercent: SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
+    markupAppliesToInventory: SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY,
+  });
+
+/** True when a figure is exact to the cent, with no sub-cent tail. */
+const isExactToTheCent = (value: number) =>
+  Math.abs(value * 100 - Math.round(value * 100)) < 1e-9;
+
+describe("roundMoney", () => {
+  it("rounds to the cent", () => {
+    expect(roundMoney(10.004)).toBe(10);
+    expect(roundMoney(10.005)).toBe(10.01);
+    expect(roundMoney(10.006)).toBe(10.01);
+  });
+
+  it("rounds a decimal half up even when binary float lands just under it", () => {
+    // 2.5 * 19.99 is held as 49.974999999999994, so rounding the binary value
+    // bills 49.97. The decimal answer is 49.975 and a customer invoice rounds
+    // it up. Getting this wrong is a silent one-cent underbill per line.
+    expect(roundMoney(2.5 * 19.99)).toBe(49.98);
+    // The textbook case: naive rounding returns 1 here.
+    expect(roundMoney(1.005)).toBe(1.01);
+    expect(roundMoney(1234567.005)).toBe(1234567.01);
+  });
+
+  it("clears the float tail from an otherwise exact product", () => {
+    expect(roundMoney(3 * 0.1)).toBe(0.3);
+    expect(roundMoney(7 * 1.15)).toBe(8.05);
+  });
+
+  it("rounds a credit by the same magnitude as the charge it reverses", () => {
+    expect(roundMoney(-(2.5 * 19.99))).toBe(-49.98);
+    expect(roundMoney(-1.005)).toBe(-1.01);
+    expect(roundMoney(-10.004)).toBe(-10);
+  });
+
+  it("never returns NaN, whatever it is handed", () => {
+    expect(roundMoney("")).toBe(0);
+    expect(roundMoney("abc")).toBe(0);
+    expect(roundMoney(undefined)).toBe(0);
+    expect(roundMoney(null)).toBe(0);
+    expect(roundMoney(Number.POSITIVE_INFINITY)).toBe(0);
+    expect(roundMoney(Number.NaN)).toBe(0);
+  });
+});
+
+describe("parseQuantity", () => {
+  it("reads a quantity the number input already stored as a string", () => {
+    expect(parseQuantity("2")).toBe(2);
+    expect(parseQuantity("0.5")).toBe(0.5);
+  });
+
+  it("keeps a deliberate zero rather than rounding it up to one", () => {
+    expect(parseQuantity(0)).toBe(0);
+    expect(parseQuantity("0")).toBe(0);
+  });
+
+  it("treats a cleared field as zero, matching how totals are recomputed", () => {
+    expect(parseQuantity("")).toBe(0);
+  });
+
+  it("never yields NaN, so a total can never become NaN", () => {
+    expect(parseQuantity("abc")).toBe(0);
+    expect(parseQuantity(undefined)).toBe(0);
+    expect(parseQuantity(null)).toBe(0);
+    expect(parseQuantity(Number.POSITIVE_INFINITY)).toBe(0);
+  });
+});
+
+describe("priceFromCostAndMarkup", () => {
+  it("adds the markup percentage to cost", () => {
+    expect(priceFromCostAndMarkup(100, 50)).toBe(150);
+    expect(priceFromCostAndMarkup(200, 25)).toBe(250);
+    expect(priceFromCostAndMarkup(44.99, 100)).toBe(89.98);
+  });
+
+  it("sells at cost when the markup is zero, with no hidden floor", () => {
+    expect(priceFromCostAndMarkup(80, 0)).toBe(80);
+    expect(priceFromCostAndMarkup(0, 50)).toBe(0);
+  });
+
+  it("handles fractional markups and costs to the cent", () => {
+    expect(priceFromCostAndMarkup(10, 12.5)).toBe(11.25);
+    expect(priceFromCostAndMarkup(9.99, 33)).toBe(13.29);
+    expect(priceFromCostAndMarkup(19.99, 60)).toBe(31.98);
+    expect(priceFromCostAndMarkup(29.99, 50)).toBe(44.99);
+    expect(priceFromCostAndMarkup(0.01, 50)).toBe(0.02);
+  });
+
+  it("never produces a sub-cent price", () => {
+    for (let cost = 0.01; cost < 200; cost += 3.37) {
+      for (const markup of [0, 7.5, 12.5, 33, 50, 66.6, 100, 150]) {
+        expect(isExactToTheCent(priceFromCostAndMarkup(cost, markup))).toBe(true);
+      }
+    }
+  });
+
+  it("reads cost and markup that arrived as strings from an input", () => {
+    expect(priceFromCostAndMarkup("100", "50")).toBe(150);
+    expect(priceFromCostAndMarkup("", "50")).toBe(0);
+    expect(priceFromCostAndMarkup("100", "")).toBe(100);
+  });
+});
+
+describe("priceFromCostAndMultiplier", () => {
+  it("multiplies cost by the inventory form's multiplier", () => {
+    expect(priceFromCostAndMultiplier(100, 1.5)).toBe(150);
+    expect(priceFromCostAndMultiplier(9.99, 2)).toBe(19.98);
+  });
+
+  it("agrees with the percentage form, since 1.5x is a 50 percent markup", () => {
+    // The two settings use different units. They must not disagree on price,
+    // or the same part is worth different money in the catalog and on the job.
+    for (const cost of [0.01, 9.99, 44.99, 100, 137.5]) {
+      expect(priceFromCostAndMultiplier(cost, 1.5)).toBe(priceFromCostAndMarkup(cost, 50));
+      expect(priceFromCostAndMultiplier(cost, 1)).toBe(priceFromCostAndMarkup(cost, 0));
+      expect(priceFromCostAndMultiplier(cost, 2.25)).toBe(priceFromCostAndMarkup(cost, 125));
+    }
+  });
+});
+
+describe("markupFromCostAndPrice", () => {
+  it("derives the markup a price implies over a cost", () => {
+    expect(markupFromCostAndPrice(100, 150)).toBe(50);
+    expect(markupFromCostAndPrice(100, 100)).toBe(0);
+    expect(markupFromCostAndPrice(10, 11.25)).toBe(12.5);
+  });
+
+  it("reports a negative markup when a part is sold below cost", () => {
+    expect(markupFromCostAndPrice(100, 50)).toBe(-50);
+  });
+
+  it("treats a price with no cost behind it as a free override, not infinite margin", () => {
+    expect(markupFromCostAndPrice(0, 50)).toBe(0);
+    expect(markupFromCostAndPrice("", 50)).toBe(0);
+    expect(markupFromCostAndPrice(-5, 50)).toBe(0);
+  });
+
+  it("recovers the markup exactly when the price lands on a whole cent", () => {
+    // Editing the price and editing the markup are two ways to set the same
+    // number. Where no rounding intervenes they must agree exactly, or the
+    // margin shown to the workshop is a lie.
+    for (const [cost, markup] of [
+      [100, 50],
+      [100, 12.5],
+      [10, 12.5],
+      [44.99, 100],
+      [37.5, 17.5],
+      [9.99, 33],
+      [250, 0],
+    ] as const) {
+      expect(markupFromCostAndPrice(cost, priceFromCostAndMarkup(cost, markup))).toBe(markup);
+    }
+  });
+
+  it("drifts only by what cent-rounding forces, never more", () => {
+    // A price must land on a whole cent, so the markup it implies cannot
+    // always be the one that produced it. 9.99 + 50% is 14.985, which bills as
+    // 14.99, and 14.99 over 9.99 really is 50.1%. The recoverable bound is
+    // half a cent spread over cost, plus half the markup field's own 0.1
+    // resolution. Anything beyond that is an arithmetic fault, not rounding.
+    for (const cost of [0.5, 9.99, 10, 37.5, 44.99, 100, 249.95]) {
+      for (const markup of [0, 12.5, 17.5, 33, 50, 100]) {
+        const price = priceFromCostAndMarkup(cost, markup);
+        const recovered = markupFromCostAndPrice(cost, price);
+        const bound = (0.005 / cost) * 100 + 0.05 + 1e-9;
+        expect(Math.abs(recovered - markup)).toBeLessThanOrEqual(bound);
+      }
+    }
+  });
+
+  it("pins the known cent-rounding case so a change of formula is visible", () => {
+    expect(priceFromCostAndMarkup(9.99, 50)).toBe(14.99);
+    expect(markupFromCostAndPrice(9.99, 14.99)).toBe(50.1);
+  });
+});
+
+describe("lineTotal", () => {
+  it("multiplies quantity by unit price", () => {
+    expect(lineTotal(2, 615)).toBe(1230);
+    expect(lineTotal(1, 44.99)).toBe(44.99);
+    expect(lineTotal(3, 19.99)).toBe(59.97);
+  });
+
+  it("reads a quantity still held as a string", () => {
+    expect(lineTotal("2", 615)).toBe(1230);
+  });
+
+  it("is zero when the quantity is zero or the field was cleared", () => {
+    expect(lineTotal(0, 615)).toBe(0);
+    expect(lineTotal("", 615)).toBe(0);
+  });
+
+  it("rounds a fractional quantity to the cent", () => {
+    expect(lineTotal(2.5, 19.99)).toBe(49.98);
+    expect(lineTotal(1.5, 10.05)).toBe(15.08);
+  });
+
+  it("never yields NaN or a sub-cent total", () => {
+    for (const quantity of ["2", "", "0", 0, 3, 2.5, undefined, "abc", null]) {
+      for (const price of [0, 0.01, 19.99, 615, 1234.56]) {
+        const total = lineTotal(quantity, price);
+        expect(Number.isNaN(total)).toBe(false);
+        expect(isExactToTheCent(total)).toBe(true);
+      }
+    }
+  });
+});
+
+describe("readPartsPricingSettings", () => {
+  it("reads the markup the user typed into settings", () => {
+    expect(readSettings(settingsMap("50", "true"))).toEqual({
+      defaultMarkupPercent: 50,
+      markupAppliesToInventory: true,
+    });
+  });
+
+  it("reads a fractional markup", () => {
+    expect(readSettings(settingsMap("12.5", "false")).defaultMarkupPercent).toBe(12.5);
+  });
+
+  it("treats anything other than the string 'true' as off", () => {
+    // The toggle is persisted as a string, so a stale or absent value must not
+    // silently switch inventory parts onto markup pricing.
+    expect(readSettings(settingsMap("50", "false")).markupAppliesToInventory).toBe(false);
+    expect(readSettings(settingsMap("50", undefined)).markupAppliesToInventory).toBe(false);
+    expect(readSettings(settingsMap("50", "TRUE")).markupAppliesToInventory).toBe(false);
+    expect(readSettings(settingsMap("50", "1")).markupAppliesToInventory).toBe(false);
+  });
+
+  it("falls back to no markup when the setting is missing or unusable", () => {
+    expect(readSettings(settingsMap(undefined, "true")).defaultMarkupPercent).toBe(0);
+    expect(readSettings(settingsMap("", "true")).defaultMarkupPercent).toBe(0);
+    expect(readSettings(settingsMap("abc", "true")).defaultMarkupPercent).toBe(0);
+    expect(readSettings({}).defaultMarkupPercent).toBe(0);
+  });
+});
+
+describe("resolvePartPrice", () => {
+  it("uses the part's own sell price when it has one", () => {
+    expect(resolvePartPrice({ unitCost: 10, sellPrice: 25 })).toEqual({
+      unitPrice: 25,
+      markupPercent: 0,
+    });
+  });
+
+  it("falls back to cost rather than billing at zero", () => {
+    // This is the bug the module was extracted to kill: a part whose sell
+    // price was never filled in was added to the line for free.
+    expect(resolvePartPrice({ unitCost: 10, sellPrice: 0 })).toEqual({
+      unitPrice: 10,
+      markupPercent: 0,
+    });
+  });
+
+  it("yields zero only when the part genuinely has no cost or price", () => {
+    expect(resolvePartPrice({ unitCost: 0, sellPrice: 0 }).unitPrice).toBe(0);
+  });
+
+  it("ignores the org markup unless it is switched on for inventory", () => {
+    expect(
+      resolvePartPrice(
+        { unitCost: 10, sellPrice: 25 },
+        { markupAppliesToInventory: false, defaultMarkupPercent: 40 },
+      ),
+    ).toEqual({ unitPrice: 25, markupPercent: 0 });
+  });
+
+  it("prices from cost plus markup when the setting is on", () => {
+    expect(
+      resolvePartPrice(
+        { unitCost: 10, sellPrice: 25 },
+        { markupAppliesToInventory: true, defaultMarkupPercent: 40 },
+      ),
+    ).toEqual({ unitPrice: 14, markupPercent: 40 });
+  });
+
+  it("rounds a marked-up price to whole cents", () => {
+    expect(
+      resolvePartPrice(
+        { unitCost: 9.99, sellPrice: 0 },
+        { markupAppliesToInventory: true, defaultMarkupPercent: 33 },
+      ).unitPrice,
+    ).toBe(13.29);
+  });
+
+  it("reports the markup it actually applied, so the displayed margin is real", () => {
+    const withMarkup = resolvePartPrice(
+      { unitCost: 20, sellPrice: 0 },
+      { markupAppliesToInventory: true, defaultMarkupPercent: 25 },
+    );
+    expect(withMarkup.unitPrice).toBe(25);
+    expect(markupFromCostAndPrice(20, withMarkup.unitPrice)).toBe(withMarkup.markupPercent);
+  });
+
+  it("falls back to sell price when the markup is zero or negative", () => {
+    // A markup of 0 through this branch would price every part at cost and
+    // quietly discard its sell price.
+    for (const defaultMarkupPercent of [0, -20]) {
+      expect(
+        resolvePartPrice(
+          { unitCost: 10, sellPrice: 25 },
+          { markupAppliesToInventory: true, defaultMarkupPercent },
+        ),
+      ).toEqual({ unitPrice: 25, markupPercent: 0 });
+    }
+  });
+});
+
+/**
+ * A part can reach a line four ways: the picker dialog, the inline name
+ * suggestion, a barcode scan, and apply-markup-to-all. They disagreed once
+ * already. These pin the whole chain from the saved setting to the stored
+ * total.
+ */
+describe("settings to line total, end to end", () => {
+  const part = { unitCost: 40, sellPrice: 90, quantity: 3 };
+
+  it("bills the part's sell price when markup is off", () => {
+    const pricing = readSettings(settingsMap("50", "false"));
+    const { unitPrice, markupPercent } = resolvePartPrice(part, pricing);
+    expect(unitPrice).toBe(90);
+    expect(markupPercent).toBe(0);
+    expect(lineTotal(part.quantity, unitPrice)).toBe(270);
+  });
+
+  it("bills cost plus the configured markup when it is on", () => {
+    const pricing = readSettings(settingsMap("50", "true"));
+    const { unitPrice, markupPercent } = resolvePartPrice(part, pricing);
+    expect(unitPrice).toBe(60);
+    expect(markupPercent).toBe(50);
+    expect(lineTotal(part.quantity, unitPrice)).toBe(180);
+  });
+
+  it("holds total === quantity * unitPrice for every quantity a row can hold", () => {
+    const pricing = readSettings(settingsMap("33", "true"));
+    const { unitPrice } = resolvePartPrice({ unitCost: 9.99, sellPrice: 0 }, pricing);
+    expect(unitPrice).toBe(13.29);
+    for (const [quantity, expected] of [
+      [0, 0],
+      ["", 0],
+      [1, 13.29],
+      ["2", 26.58],
+      [3, 39.87],
+      [2.5, 33.23], // 33.225 rounded up
+    ] as const) {
+      expect(lineTotal(quantity, unitPrice)).toBe(expected);
+    }
+  });
+
+  it("prices a part identically however it reaches the line", () => {
+    const pricing = readSettings(settingsMap("50", "true"));
+    const stocked = { unitCost: 29.99, sellPrice: 80 };
+
+    // Picker and suggestion and barcode scan all call resolvePartPrice.
+    const resolved = resolvePartPrice(stocked, pricing).unitPrice;
+    // Apply-markup-to-all recomputes from cost and the same setting.
+    const applied = priceFromCostAndMarkup(stocked.unitCost, pricing.defaultMarkupPercent);
+    // The catalog form derives a sell price from the equivalent multiplier.
+    const catalog = priceFromCostAndMultiplier(stocked.unitCost, 1.5);
+
+    expect(resolved).toBe(44.99);
+    expect(applied).toBe(resolved);
+    expect(catalog).toBe(resolved);
+  });
+});

+ 5 - 3
src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx

@@ -1,6 +1,7 @@
 import { getServiceRecord } from '@/features/vehicles/Actions/serviceActions'
 import { getSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { readPartsPricingSettings } from '@/features/inventory/Lib/partPricing'
 import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
 import { getLaborPresetsList } from '@/features/labor-presets/Actions/laborPresetActions'
 
@@ -80,9 +81,10 @@ export default async function ServiceDetailPage({
   const defaultTaxRate = taxEnabled ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0 : 0
   const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0
   const defaultDueDays = Number(settings[SETTING_KEYS.INVOICE_DUE_DAYS]) || 0
-  const defaultMarkupPercent = Number(settings[SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT]) || 0
-  const markupAppliesToInventory =
-    settings[SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY] === 'true'
+  const { defaultMarkupPercent, markupAppliesToInventory } = readPartsPricingSettings(settings, {
+    defaultMarkupPercent: SETTING_KEYS.PARTS_DEFAULT_MARKUP_PERCENT,
+    markupAppliesToInventory: SETTING_KEYS.PARTS_MARKUP_APPLIES_TO_INVENTORY,
+  })
   const inventoryParts = inventoryResult.success && inventoryResult.data ? inventoryResult.data : []
   const laborPresets = presetsResult.success && presetsResult.data ? presetsResult.data : []
   const initialVehicle = {

+ 2 - 1
src/features/billing/Actions/recurringInvoiceActions.ts

@@ -12,6 +12,7 @@ import {
 } from "../Schema/recurringInvoiceSchema";
 import { resolveInvoicePrefix } from "@/lib/invoice-utils";
 import { calculateTotals } from "@/lib/tax";
+import { lineTotal } from "@/features/inventory/Lib/partPricing";
 
 export async function getRecurringInvoices() {
   return withAuth(async ({ organizationId }) => {
@@ -337,7 +338,7 @@ export async function processRecurringInvoices() {
                 partNumber: p.partNumber,
                 quantity: p.quantity,
                 unitPrice: p.unitPrice,
-                total: p.quantity * p.unitPrice,
+                total: lineTotal(p.quantity, p.unitPrice),
               })),
             },
             laborItems: {

+ 2 - 1
src/features/inventory/Components/InventoryPartForm.tsx

@@ -24,6 +24,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
 import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
 import { cn } from "@/lib/utils";
 import { compressImage } from "@/lib/compress-image";
+import { priceFromCostAndMultiplier } from "@/features/inventory/Lib/partPricing";
 
 interface InventoryPartFormProps {
   open: boolean;
@@ -672,7 +673,7 @@ export function InventoryPartForm({ open, onOpenChange, part, markupMultiplier,
                       const cost = Number(e.target.value) || 0;
                       const sellPriceInput = document.getElementById("sellPrice") as HTMLInputElement | null;
                       if (sellPriceInput) {
-                        sellPriceInput.value = String(Math.round(cost * markupMultiplier * 100) / 100);
+                        sellPriceInput.value = String(priceFromCostAndMultiplier(cost, markupMultiplier));
                       }
                     }}
                   />

+ 210 - 0
src/features/inventory/Components/PartNameSuggestions.tsx

@@ -0,0 +1,210 @@
+'use client'
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { useFormatCurrency } from '@/components/currency-settings-context'
+import { Package } from 'lucide-react'
+import { resolvePartPrice } from '@/features/inventory/Lib/partPricing'
+
+/** The subset of an inventory part this picker needs. */
+export interface PartSuggestion {
+  id: string
+  name: string
+  partNumber: string | null
+  barcode?: string | null
+  category?: string | null
+  description?: string | null
+  unitCost: number
+  sellPrice: number
+  quantity: number
+}
+
+const MAX_SUGGESTIONS = 3
+const MIN_QUERY_LENGTH = 2
+
+/**
+ * Rank matches so the most useful three surface.
+ *
+ * A prefix match on the name is what someone typing "bra..." for "Brake pad"
+ * expects to see first; an exact part-number hit is even stronger, since that
+ * is a deliberate lookup rather than a guess.
+ */
+function rank(part: PartSuggestion, query: string): number {
+  const name = part.name.toLowerCase()
+  const number = (part.partNumber ?? '').toLowerCase()
+
+  if (number && number === query) return 0
+  if (name === query) return 1
+  if (number.startsWith(query)) return 2
+  if (name.startsWith(query)) return 3
+  if (name.includes(query)) return 4
+  if (number.includes(query)) return 5
+  return 6
+}
+
+function matches(part: PartSuggestion, query: string): boolean {
+  return rank(part, query) < 6
+}
+
+/**
+ * Inline typeahead under a part's Name field.
+ *
+ * Free-typing a part name that already exists in stock is the main way a line
+ * ends up unlinked, which means the job never deducts inventory. Surfacing the
+ * stocked match at the moment of typing makes linking the path of least
+ * resistance, rather than something you remember to do via the picker dialog.
+ *
+ * Capped at three: this sits inside a dense editable row, and a longer list
+ * would cover the fields below it.
+ */
+export function PartNameSuggestions({
+  query,
+  parts,
+  onSelect,
+  /** Already linked to stock, so there is nothing to suggest. */
+  disabled = false,
+  currencyCode,
+  defaultMarkupPercent = 0,
+  markupAppliesToInventory = false,
+}: {
+  query: string
+  parts: PartSuggestion[]
+  onSelect: (part: PartSuggestion) => void
+  disabled?: boolean
+  currencyCode?: string
+  /** Same pricing inputs the inventory picker takes, so both agree. */
+  defaultMarkupPercent?: number
+  markupAppliesToInventory?: boolean
+}) {
+  const t = useTranslations('inventory')
+  const formatCurrency = useFormatCurrency()
+  const [dismissed, setDismissed] = useState(false)
+  const containerRef = useRef<HTMLDivElement>(null)
+
+  const trimmed = query.trim().toLowerCase()
+
+  const suggestions = useMemo(() => {
+    if (disabled || trimmed.length < MIN_QUERY_LENGTH) return []
+    return parts
+      .filter((p) => matches(p, trimmed))
+      .sort((a, b) => rank(a, trimmed) - rank(b, trimmed))
+      .slice(0, MAX_SUGGESTIONS)
+  }, [parts, trimmed, disabled])
+
+  // Typing again after dismissing should bring the list back.
+  useEffect(() => {
+    setDismissed(false)
+  }, [query])
+
+  // Escape closes without stealing the click-away behaviour of the row.
+  useEffect(() => {
+    if (suggestions.length === 0 || dismissed) return
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') setDismissed(true)
+    }
+    const onClickAway = (e: MouseEvent) => {
+      if (!containerRef.current?.contains(e.target as Node)) setDismissed(true)
+    }
+    document.addEventListener('keydown', onKey)
+    document.addEventListener('mousedown', onClickAway)
+    return () => {
+      document.removeEventListener('keydown', onKey)
+      document.removeEventListener('mousedown', onClickAway)
+    }
+  }, [suggestions.length, dismissed])
+
+  // An exact name hit means the row already holds that part; nothing to offer.
+  const alreadyExact =
+    suggestions.length === 1 && suggestions[0].name.toLowerCase() === trimmed
+
+  if (dismissed || alreadyExact || suggestions.length === 0) return null
+
+  const priceOf = (part: PartSuggestion) =>
+    resolvePartPrice(part, { defaultMarkupPercent, markupAppliesToInventory })
+      .unitPrice
+
+  const choose = (part: PartSuggestion) => {
+    onSelect(part)
+    setDismissed(true)
+  }
+
+  return (
+    <div
+      ref={containerRef}
+      // Grows past the narrow Name field so long part names are readable
+      // without hovering. Kept to the field width on mobile, where the field
+      // sits in the right-hand column and a wider panel would run off-screen.
+      className="absolute left-0 top-full z-30 mt-1 w-full overflow-hidden rounded-md border bg-popover shadow-md sm:w-max sm:min-w-full sm:max-w-[min(30rem,60vw)]"
+    >
+      <p className="border-b px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
+        {t('suggestions.title')}
+      </p>
+      {suggestions.map((part) => (
+        <button
+          key={part.id}
+          type="button"
+          // Pointer and keyboard are handled separately and never both fire.
+          // mousedown (not click) beats the field's blur, which would otherwise
+          // unmount this button before the click landed. Keyboard activation
+          // does not raise mousedown at all, so Enter/Space needs its own
+          // handler or tabbing to a suggestion does nothing.
+          onMouseDown={(e) => {
+            e.preventDefault()
+            choose(part)
+          }}
+          onKeyDown={(e) => {
+            if (e.key !== 'Enter' && e.key !== ' ') return
+            e.preventDefault()
+            choose(part)
+          }}
+          className="flex w-full items-center justify-between gap-3 whitespace-nowrap px-2 py-1.5 text-left text-sm hover:bg-accent"
+        >
+          <span className="flex min-w-0 items-center gap-2">
+            <Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+            <span className="truncate" title={part.name}>
+              {part.name}
+            </span>
+            {part.partNumber && (
+              <span className="shrink-0 font-mono text-xs text-muted-foreground">
+                {part.partNumber}
+              </span>
+            )}
+            {part.category && (
+              <span className="shrink-0 text-xs text-muted-foreground">
+                {part.category}
+              </span>
+            )}
+          </span>
+          <span className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
+            {/* The price actually applied on pick, not the raw sell price:
+                a part with no sell price is billed at cost, and showing 0
+                here contradicted what landed on the line. */}
+            <span className="font-medium text-foreground">
+              {formatCurrency(priceOf(part), currencyCode)}
+            </span>
+            {/* Show cost whenever the billed price differs from it, which is
+                what makes the margin readable. Gating on sellPrice instead hid
+                the cost in the one case it matters most: markup pricing on a
+                part whose sell price was never set. */}
+            {part.unitCost > 0 && priceOf(part) !== part.unitCost && (
+              <span className="line-through">
+                {formatCurrency(part.unitCost, currencyCode)}
+              </span>
+            )}
+            {part.quantity > 0 ? (
+              <span>{t('suggestions.inStock', { quantity: part.quantity })}</span>
+            ) : part.quantity === 0 ? (
+              <span className="font-medium text-amber-600 dark:text-amber-500">
+                {t('suggestions.outOfStock')}
+              </span>
+            ) : (
+              <span className="font-medium text-red-600 dark:text-red-500">
+                {t('suggestions.onBackorder', { count: Math.abs(part.quantity) })}
+              </span>
+            )}
+          </span>
+        </button>
+      ))}
+    </div>
+  )
+}

+ 160 - 0
src/features/inventory/Lib/partPricing.ts

@@ -0,0 +1,160 @@
+/**
+ * The single source of truth for every money figure derived from a stocked
+ * part: what the customer is charged, the markup that price implies, and the
+ * line total that follows from it.
+ *
+ * It exists because these rules were duplicated. The inventory picker had the
+ * full fallback chain; the inline name suggestion used `sellPrice` directly,
+ * so any part whose sell price was never filled in landed on the line at zero.
+ * The same cost-plus-markup formula was then written out by hand in four more
+ * places (barcode scan, the row reducer, apply-markup-to-all, and the part
+ * form), each free to drift from the others. Nothing here should be inlined at
+ * a call site again: a pricing rule that disagrees with itself bills the wrong
+ * amount silently, and no test at the call site catches it.
+ *
+ * Two different units are in play, and they are not interchangeable:
+ *  - `defaultMarkupPercent` is a percentage on a job or quote line. 50 means
+ *    cost + 50%.
+ *  - `markupMultiplier` multiplies cost in the inventory catalog form. 1.5
+ *    means the same thing as a 50 percent markup.
+ */
+
+export interface PricedPart {
+  unitCost: number
+  sellPrice: number
+}
+
+export interface PricingSettings {
+  /** Org default markup %, applied to cost when the setting below is on. */
+  defaultMarkupPercent?: number
+  /** When true, inventory parts are priced from cost + default markup. */
+  markupAppliesToInventory?: boolean
+}
+
+export interface ResolvedPrice {
+  unitPrice: number
+  /** Kept in step with unitPrice so the displayed margin matches reality. */
+  markupPercent: number
+}
+
+/**
+ * Money is held to the cent, so every derived amount rounds the same way.
+ *
+ * Binary floats cannot represent most decimal amounts exactly, so a bare
+ * `cost * 1.5` yields values like 44.980000000000004. Left unrounded those
+ * reach the database and are summed into subtotals, where the error compounds
+ * into a visible penny discrepancy on the document.
+ */
+export function roundMoney(value: unknown): number {
+  const parsed = Number(value)
+  if (!Number.isFinite(parsed)) return 0
+  // Round on the decimal value, not the binary approximation of it. A plain
+  // Math.round(v * 100) / 100 bills 2.5 x 19.99 as 49.97, because that product
+  // is held as 49.974999999999994 and so falls just short of the halfway point
+  // it should sit exactly on. The same flaw rounds 1.005 down to 1.00.
+  //
+  // Twelve significant digits is well past where the noise lives and well
+  // short of the ~15 a double carries, so this restores the decimal figure
+  // without inventing precision. Rounding is symmetric about zero, so a credit
+  // line rounds by the same magnitude as the charge it reverses.
+  const normalized = Number(parsed.toPrecision(12))
+  const scaled = Number((normalized * 100).toPrecision(12))
+  return (scaled < 0 ? -Math.round(-scaled) : Math.round(scaled)) / 100
+}
+
+/**
+ * Read a value that may still be raw input from a number field.
+ *
+ * These fields store whatever the input produced, so a cleared box holds `""`
+ * rather than a number, despite what the row types claim.
+ */
+function parseNumber(value: unknown): number {
+  const parsed = Number(value)
+  return Number.isFinite(parsed) ? parsed : 0
+}
+
+/**
+ * Read a line quantity that may still be raw input.
+ *
+ * Every total is derived as quantity * unitPrice, so this follows that same
+ * rule and keeps the two in step: an unusable value reads as 0, and a
+ * deliberate 0 stays 0 instead of being rounded up to 1.
+ */
+export function parseQuantity(value: unknown): number {
+  return parseNumber(value)
+}
+
+/**
+ * Customer price for a part bought at `cost` and sold at `markupPercent` over
+ * it. A markup of 0 sells at cost; there is no hidden floor.
+ */
+export function priceFromCostAndMarkup(cost: unknown, markupPercent: unknown): number {
+  return roundMoney(parseNumber(cost) * (1 + parseNumber(markupPercent) / 100))
+}
+
+/**
+ * Catalog sell price for a part bought at `cost`, using the inventory form's
+ * multiplier rather than a percentage. A multiplier of 1.5 equals a 50 percent
+ * markup; see the note at the top of this file.
+ */
+export function priceFromCostAndMultiplier(cost: unknown, multiplier: unknown): number {
+  return roundMoney(parseNumber(cost) * parseNumber(multiplier))
+}
+
+/**
+ * The inverse of {@link priceFromCostAndMarkup}: what markup does this price
+ * imply over this cost?
+ *
+ * Held to one decimal, which is what the markup field accepts. With no cost
+ * recorded there is no meaningful percentage, so the price is treated as a
+ * free override at 0 rather than reported as an infinite margin.
+ */
+export function markupFromCostAndPrice(cost: unknown, price: unknown): number {
+  const parsedCost = parseNumber(cost)
+  if (parsedCost <= 0) return 0
+  return Math.round((parseNumber(price) / parsedCost - 1) * 1000) / 10
+}
+
+/**
+ * What a line is worth. Rounded to the cent for the same reason every other
+ * amount here is: this value is stored and then summed into a subtotal.
+ */
+export function lineTotal(quantity: unknown, unitPrice: unknown): number {
+  return roundMoney(parseQuantity(quantity) * parseNumber(unitPrice))
+}
+
+/**
+ * Turn the raw settings map into the pricing inputs the rest of this module
+ * takes. Settings are stored as strings, so a missing or unparseable value has
+ * to read as "no markup" rather than NaN.
+ */
+export function readPartsPricingSettings(
+  settings: Record<string, string | undefined>,
+  keys: { defaultMarkupPercent: string; markupAppliesToInventory: string },
+): Required<PricingSettings> {
+  return {
+    defaultMarkupPercent: parseNumber(settings[keys.defaultMarkupPercent]),
+    markupAppliesToInventory: settings[keys.markupAppliesToInventory] === 'true',
+  }
+}
+
+export function resolvePartPrice(
+  part: PricedPart,
+  { defaultMarkupPercent = 0, markupAppliesToInventory = false }: PricingSettings = {},
+): ResolvedPrice {
+  const cost = parseNumber(part.unitCost)
+  const sell = parseNumber(part.sellPrice)
+
+  // The workshop prices parts from cost plus a house markup.
+  if (markupAppliesToInventory && defaultMarkupPercent > 0) {
+    return {
+      unitPrice: priceFromCostAndMarkup(cost, defaultMarkupPercent),
+      markupPercent: defaultMarkupPercent,
+    }
+  }
+
+  // Otherwise the part's own sell price wins. Falling back to cost matters:
+  // a part with no sell price set would otherwise be billed at zero, which is
+  // worse than billing at cost and far easier to miss.
+  return { unitPrice: sell > 0 ? sell : cost, markupPercent: 0 }
+}

+ 1 - 1
src/features/quotes/Components/QuoteLaborEditor.tsx

@@ -42,7 +42,7 @@ const QuoteLaborRow = memo(function QuoteLaborRow({
   const isService = labor.pricingType === "service";
   const formatCurrency = useFormatCurrency();
   return (
-    <div className={`grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]${labor.excluded ? " line-through opacity-50" : ""}`}>
+    <div className={`grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto] ${labor.excluded ? "line-through opacity-50" : ""}`}>
       <div className="col-span-2 flex gap-2 sm:col-span-1">
         <Input placeholder={tDescriptionPlaceholder} value={labor.description} onChange={(e) => onUpdate(index, "description", e.target.value)} className="flex-1" />
         <button

+ 2 - 1
src/features/quotes/Components/QuotePageClient.tsx

@@ -39,6 +39,7 @@ import { QuoteLaborEditor } from './QuoteLaborEditor'
 import { QuoteNotesEditor } from './QuoteNotesEditor'
 import { QuoteRightColumn } from './QuoteRightColumn'
 import { VehicleCombobox } from './VehicleCombobox'
+import { lineTotal } from '@/features/inventory/Lib/partPricing'
 
 const LG_BREAKPOINT = 1024
 
@@ -123,7 +124,7 @@ export function QuotePageClient({
           partNumber: part.partNumber || '',
           quantity: part.quantity,
           unitPrice: part.unitPrice,
-          total: part.quantity * part.unitPrice,
+          total: lineTotal(part.quantity, part.unitPrice),
           excluded: false,
           inventoryPartId: part.inventoryPartId ?? null,
         }))

+ 47 - 4
src/features/quotes/Components/QuotePartsEditor.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { memo, useState } from "react";
+import { memo, useCallback, useState } from "react";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Package, Plus, Trash2 } from "lucide-react";
@@ -8,6 +8,11 @@ import { useFormatCurrency } from '@/components/currency-settings-context'
 import { InventoryPickerDialog } from "@/features/vehicles/Components/service-edit/InventoryPickerDialog";
 import type { InventoryPartOption } from "@/features/vehicles/Components/service-edit/form-types";
 import type { QuotePartInput } from "./quote-page-types";
+import {
+  PartNameSuggestions,
+  type PartSuggestion,
+} from "@/features/inventory/Components/PartNameSuggestions";
+import { lineTotal, parseQuantity, resolvePartPrice } from "@/features/inventory/Lib/partPricing";
 
 const QuotePartRow = memo(function QuotePartRow({
   part,
@@ -19,12 +24,16 @@ const QuotePartRow = memo(function QuotePartRow({
   tNamePlaceholder,
   tDeleteRow,
   tExcludeFromTotal,
+  inventoryParts,
+  onSelectSuggestion,
 }: {
   part: QuotePartInput;
   index: number;
   currencyCode: string;
   onUpdate: (index: number, field: keyof QuotePartInput, value: string | number | boolean) => void;
   onDelete: (index: number) => void;
+  inventoryParts: PartSuggestion[];
+  onSelectSuggestion: (index: number, part: PartSuggestion) => void;
   tPartNumber: string;
   tNamePlaceholder: string;
   tDeleteRow: string;
@@ -32,9 +41,18 @@ const QuotePartRow = memo(function QuotePartRow({
 }) {
   const formatCurrency = useFormatCurrency();
   return (
-    <div className={`grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]${part.excluded ? " line-through opacity-50" : ""}`}>
+    <div className={`grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto] ${part.excluded ? "line-through opacity-50" : ""}`}>
       <Input placeholder={tPartNumber} value={part.partNumber ?? ""} onChange={(e) => onUpdate(index, "partNumber", e.target.value)} />
-      <Input placeholder={tNamePlaceholder} value={part.name} onChange={(e) => onUpdate(index, "name", e.target.value)} />
+      <div className="relative">
+        <Input placeholder={tNamePlaceholder} value={part.name} onChange={(e) => onUpdate(index, "name", e.target.value)} />
+        <PartNameSuggestions
+          query={part.name}
+          parts={inventoryParts}
+          disabled={!!part.inventoryPartId}
+          currencyCode={currencyCode}
+          onSelect={(picked) => onSelectSuggestion(index, picked)}
+        />
+      </div>
       <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => onUpdate(index, "quantity", e.target.value)} />
       <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => onUpdate(index, "unitPrice", e.target.value)} />
       <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(part.total, currencyCode)}</div>
@@ -73,6 +91,29 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
   const formatCurrency = useFormatCurrency()
   const [pickerOpen, setPickerOpen] = useState(false)
   const canPickFromStock = inventoryParts.length > 0 && !!onAddBulk
+
+  // Applying a suggestion touches five fields. onUpdate handles one at a time,
+  // so call it per field rather than leaving the row half-populated; the
+  // inventory link is what makes the job deduct stock on conversion.
+  const handleSelectSuggestion = useCallback(
+    (index: number, picked: PartSuggestion) => {
+      const current = partItems[index]
+      // Derive from the row's own quantity so the line still satisfies
+      // total === quantity * unitPrice. Defaulting an empty or zero quantity
+      // to 1 would bill a total the row's own fields do not add up to.
+      const quantity = parseQuantity(current?.quantity)
+      // Quotes carry no cost/markup model, so this resolves to the part's sell
+      // price, falling back to cost rather than billing at zero.
+      const { unitPrice } = resolvePartPrice(picked)
+      onUpdate(index, "name", picked.name)
+      onUpdate(index, "partNumber", picked.partNumber ?? "")
+      onUpdate(index, "unitPrice", unitPrice)
+      onUpdate(index, "total", lineTotal(quantity, unitPrice))
+      onUpdate(index, "inventoryPartId", picked.id)
+    },
+    [onUpdate, partItems],
+  )
+
   return (
     <div className="rounded-lg border p-3 space-y-2">
       <div className="flex items-center justify-between">
@@ -101,6 +142,8 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
               currencyCode={currencyCode}
               onUpdate={onUpdate}
               onDelete={onDelete}
+              inventoryParts={inventoryParts}
+              onSelectSuggestion={handleSelectSuggestion}
               tPartNumber={t("parts.partNumber")}
               tNamePlaceholder={t("parts.namePlaceholder")}
               tDeleteRow={t("parts.deleteRow")}
@@ -124,7 +167,7 @@ export const QuotePartsEditor = memo(function QuotePartsEditor({
                 name: picked.name,
                 quantity: picked.quantity,
                 unitPrice: picked.unitPrice,
-                total: picked.quantity * picked.unitPrice,
+                total: lineTotal(picked.quantity, picked.unitPrice),
                 excluded: false,
                 inventoryPartId: picked.inventoryPartId ?? null,
               },

+ 2 - 1
src/features/quotes/Components/useQuoteFormState.ts

@@ -8,6 +8,7 @@ import { useConfirm } from "@/components/confirm-dialog";
 import { updateQuote, deleteQuote, convertQuoteToServiceRecord } from "@/features/quotes/Actions/quoteActions";
 import { acknowledgeQuoteResponse } from "@/features/quotes/Actions/quoteResponseActions";
 import { calculateTotals } from "@/lib/tax";
+import { lineTotal } from "@/features/inventory/Lib/partPricing";
 import type { QuoteRecord, QuotePartInput, QuoteLaborInput } from "./quote-page-types";
 import { emptyPart, makeEmptyLabor, makeEmptyService } from "./quote-page-types";
 
@@ -155,7 +156,7 @@ export function useQuoteFormState({
     setPartItems((prev) => {
       const updated = [...prev];
       const part = { ...updated[index], [field]: value };
-      if (field === "quantity" || field === "unitPrice") part.total = Number(part.quantity) * Number(part.unitPrice);
+      if (field === "quantity" || field === "unitPrice") part.total = lineTotal(part.quantity, part.unitPrice);
       updated[index] = part;
       return updated;
     });

+ 6 - 7
src/features/vehicles/Components/service-edit/InventoryPickerDialog.tsx

@@ -8,6 +8,7 @@ import { Search } from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import type { ServicePartInput } from '@/features/vehicles/Schema/serviceSchema'
 import type { InventoryPartOption } from './form-types'
+import { resolvePartPrice } from '@/features/inventory/Lib/partPricing'
 
 const PAGE_SIZE = 100
 
@@ -98,12 +99,10 @@ export function InventoryPickerDialog({
                 // When markup-applies-to-inventory is enabled, recompute the sell price
                 // from cost + default markup. Otherwise fall back to the inventory's
                 // own sellPrice (which has its own separate markup system).
-                const useGlobalMarkup = markupAppliesToInventory && defaultMarkupPercent > 0
-                const price = useGlobalMarkup
-                  ? Math.round(ip.unitCost * (1 + defaultMarkupPercent / 100) * 100) / 100
-                  : ip.sellPrice > 0
-                    ? ip.sellPrice
-                    : ip.unitCost
+                const { unitPrice: price, markupPercent } = resolvePartPrice(ip, {
+                  defaultMarkupPercent,
+                  markupAppliesToInventory,
+                })
                 onSelectPart({
                   partNumber: ip.partNumber || '',
                   name: ip.name,
@@ -111,7 +110,7 @@ export function InventoryPickerDialog({
                   unitPrice: price,
                   total: price,
                   unitCost: ip.unitCost,
-                  markupPercent: useGlobalMarkup ? defaultMarkupPercent : 0,
+                  markupPercent,
                   inventoryPartId: ip.id,
                 })
                 onOpenChange(false)

+ 84 - 12
src/features/vehicles/Components/service-edit/PartsEditor.tsx

@@ -27,6 +27,16 @@ import {
 } from '@dnd-kit/sortable'
 import { CSS } from '@dnd-kit/utilities'
 
+import {
+  PartNameSuggestions,
+  type PartSuggestion,
+} from '@/features/inventory/Components/PartNameSuggestions'
+import {
+  lineTotal,
+  priceFromCostAndMarkup,
+  resolvePartPrice,
+} from '@/features/inventory/Lib/partPricing'
+
 interface PartsEditorProps {
   partItems: ServicePartInput[]
   setPartItems: React.Dispatch<React.SetStateAction<ServicePartInput[]>>
@@ -34,6 +44,8 @@ interface PartsEditorProps {
   partsSubtotal: number
   currencyCode: string
   hasInventory: boolean
+  /** Stocked parts, used for the inline name suggestions. */
+  inventoryParts?: PartSuggestion[]
   onOpenInventory: () => void
   onScanBarcode?: () => void
   /** Default markup % applied to new manually-added rows. Hidden from customers. */
@@ -51,6 +63,10 @@ function SortablePartRow({
   currencyCode,
   t,
   dragEnabled,
+  inventoryParts,
+  onSelectSuggestion,
+  defaultMarkupPercent,
+  markupAppliesToInventory,
 }: {
   id: string
   part: ServicePartInput
@@ -58,6 +74,10 @@ function SortablePartRow({
   updatePart: (index: number, field: keyof ServicePartInput, value: string | number) => void
   onDelete: () => void
   currencyCode: string
+  inventoryParts: PartSuggestion[]
+  onSelectSuggestion: (index: number, part: PartSuggestion) => void
+  defaultMarkupPercent: number
+  markupAppliesToInventory: boolean
   t: (key: string) => string
   dragEnabled: boolean
 }) {
@@ -95,13 +115,24 @@ function SortablePartRow({
           value={part.partNumber ?? ''}
           onChange={(e) => updatePart(index, 'partNumber', e.target.value)}
         />
-        <Textarea
-          placeholder={t('namePlaceholder')}
-          value={part.name}
-          onChange={(e) => updatePart(index, 'name', e.target.value)}
-          rows={1}
-          className="min-h-9 resize-none"
-        />
+        <div className="relative">
+          <Textarea
+            placeholder={t('namePlaceholder')}
+            value={part.name}
+            onChange={(e) => updatePart(index, 'name', e.target.value)}
+            rows={1}
+            className="min-h-9 w-full resize-none"
+          />
+          <PartNameSuggestions
+            query={part.name}
+            parts={inventoryParts}
+            disabled={!!part.inventoryPartId}
+            currencyCode={currencyCode}
+            defaultMarkupPercent={defaultMarkupPercent}
+            markupAppliesToInventory={markupAppliesToInventory}
+            onSelect={(picked) => onSelectSuggestion(index, picked)}
+          />
+        </div>
         <Input
           type="number"
           min="0"
@@ -163,6 +194,7 @@ export function PartsEditor({
   partsSubtotal,
   currencyCode,
   hasInventory,
+  inventoryParts = [],
   onOpenInventory,
   onScanBarcode,
   defaultMarkupPercent = 0,
@@ -173,6 +205,40 @@ export function PartsEditor({
   const [mounted, setMounted] = useState(false)
   useEffect(() => setMounted(true), [])
   const keyCounterRef = useRef(0)
+
+  // Applying a suggestion sets name, number, cost, price and the stock link
+  // together. Doing it in one setPartItems pass keeps the row consistent and
+  // avoids five renders; the inventoryPartId is what makes the job deduct
+  // stock when it is saved.
+  const handleSelectSuggestion = useCallback(
+    (index: number, picked: PartSuggestion) => {
+      setPartItems((prev) =>
+        prev.map((row, i) => {
+          if (i !== index) return row
+          // Same pricing rule as the inventory picker, so a part costs the
+          // same however it was added to the line.
+          const { unitPrice, markupPercent } = resolvePartPrice(picked, {
+            defaultMarkupPercent,
+            markupAppliesToInventory,
+          })
+          return {
+            ...row,
+            name: picked.name,
+            partNumber: picked.partNumber ?? '',
+            unitCost: picked.unitCost,
+            unitPrice,
+            markupPercent,
+            // Derived from the row's own quantity, so the line still satisfies
+            // total === quantity * unitPrice. Defaulting an empty or zero
+            // quantity to 1 would bill a total its fields do not add up to.
+            total: lineTotal(row.quantity, unitPrice),
+            inventoryPartId: picked.id,
+          }
+        }),
+      )
+    },
+    [setPartItems, defaultMarkupPercent, markupAppliesToInventory],
+  )
   const keysRef = useRef<string[]>([])
 
   // Keep keys array in sync with items length
@@ -227,14 +293,12 @@ export function PartsEditor({
       prev.map((p) => {
         const eligible = markupAppliesToInventory || !p.inventoryPartId
         if (!eligible) return p
-        const cost = Number(p.unitCost) || 0
-        const markup = defaultMarkupPercent
-        const unitPrice = Math.round(cost * (1 + markup / 100) * 100) / 100
+        const unitPrice = priceFromCostAndMarkup(p.unitCost, defaultMarkupPercent)
         return {
           ...p,
-          markupPercent: markup,
+          markupPercent: defaultMarkupPercent,
           unitPrice,
-          total: Number(p.quantity) * unitPrice,
+          total: lineTotal(p.quantity, unitPrice),
         }
       })
     )
@@ -310,6 +374,10 @@ export function PartsEditor({
                     currencyCode={currencyCode}
                     t={t}
                     dragEnabled
+                    inventoryParts={inventoryParts}
+                    onSelectSuggestion={handleSelectSuggestion}
+                    defaultMarkupPercent={defaultMarkupPercent}
+                    markupAppliesToInventory={markupAppliesToInventory}
                   />
                 ))}
               </SortableContext>
@@ -326,6 +394,10 @@ export function PartsEditor({
                 currencyCode={currencyCode}
                 t={t}
                 dragEnabled={false}
+                inventoryParts={inventoryParts}
+                onSelectSuggestion={handleSelectSuggestion}
+                defaultMarkupPercent={defaultMarkupPercent}
+                markupAppliesToInventory={markupAppliesToInventory}
               />
             ))
           )}

+ 1 - 0
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -59,6 +59,7 @@ export function DetailsLeftColumn({
         partsSubtotal={formState.partsSubtotal}
         currencyCode={currencyCode}
         hasInventory={inventoryParts.length > 0}
+        inventoryParts={inventoryParts}
         onOpenInventory={() => formState.setShowInventoryPicker(true)}
         onScanBarcode={onScanBarcode}
         defaultMarkupPercent={defaultMarkupPercent}

+ 12 - 11
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -47,6 +47,7 @@ import { DetailsLeftColumn } from './DetailsLeftColumn'
 import { DetailsRightColumn } from './DetailsRightColumn'
 import { ObservationsManager, type ObservationsControls } from './ObservationsManager'
 import type { ServicePageClientProps } from './service-page-types'
+import { lineTotal, resolvePartPrice } from '@/features/inventory/Lib/partPricing'
 
 export type { ServicePageClientProps, BoardTechnicianOption } from './service-page-types'
 
@@ -162,22 +163,22 @@ export function ServicePageClient({
       const result = await lookupPartByBarcode(barcode)
       if (result.success && result.data) {
         const part = result.data
-        // Same rule as the inventory picker — opt-in markup applies to inventory parts too.
-        const useGlobalMarkup = markupAppliesToInventory && defaultMarkupPercent > 0
-        const price = useGlobalMarkup
-          ? Math.round(part.unitCost * (1 + defaultMarkupPercent / 100) * 100) / 100
-          : part.sellPrice > 0
-            ? part.sellPrice
-            : part.unitCost
+        // Same rule as the inventory picker — opt-in markup applies to
+        // inventory parts too. Scanning a part must price it identically to
+        // picking it, so both go through resolvePartPrice.
+        const { unitPrice, markupPercent } = resolvePartPrice(part, {
+          defaultMarkupPercent,
+          markupAppliesToInventory,
+        })
         formState.dirtySetPartItems((prev) => [
           {
             partNumber: part.partNumber || '',
             name: part.name,
             quantity: 1,
-            unitPrice: price,
-            total: price,
+            unitPrice,
+            total: lineTotal(1, unitPrice),
             unitCost: part.unitCost,
-            markupPercent: useGlobalMarkup ? defaultMarkupPercent : 0,
+            markupPercent,
             inventoryPartId: part.id,
           },
           ...prev,
@@ -268,7 +269,7 @@ export function ServicePageClient({
         partNumber: part.partNumber || '',
         quantity: part.quantity,
         unitPrice: part.unitPrice,
-        total: part.quantity * part.unitPrice,
+        total: lineTotal(part.quantity, part.unitPrice),
         unitCost: 0,
         markupPercent: 0,
         inventoryPartId: part.inventoryPartId || '',

+ 9 - 13
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -1,5 +1,10 @@
 import { useState, useCallback, useRef, useEffect } from 'react'
 import { calculateTotals } from '@/lib/tax'
+import {
+  lineTotal,
+  markupFromCostAndPrice,
+  priceFromCostAndMarkup,
+} from '@/features/inventory/Lib/partPricing'
 import type { ServicePartInput, ServiceLaborInput, InitialData } from './service-page-types'
 import type { ServiceDetail } from '../service-detail/types'
 
@@ -145,23 +150,14 @@ export function useServiceFormState({
       setPartItems((prev) => {
         const updated = [...prev]
         const part = { ...updated[index], [field]: value }
-        const cost = Number(part.unitCost) || 0
 
         if (field === 'unitCost' || field === 'markupPercent') {
           // Cost or markup changed → recompute the customer-facing price.
-          const markup = Number(part.markupPercent) || 0
-          part.unitPrice = Math.round(cost * (1 + markup / 100) * 100) / 100
+          part.unitPrice = priceFromCostAndMarkup(part.unitCost, part.markupPercent)
         } else if (field === 'unitPrice') {
           // Price was edited directly → derive markup back from cost so the
-          // displayed margin matches reality. If cost is 0 there's no
-          // meaningful percentage; leave markup at 0 and treat price as a
-          // free override.
-          const price = Number(part.unitPrice) || 0
-          if (cost > 0) {
-            part.markupPercent = Math.round(((price / cost) - 1) * 1000) / 10
-          } else {
-            part.markupPercent = 0
-          }
+          // displayed margin matches reality.
+          part.markupPercent = markupFromCostAndPrice(part.unitCost, part.unitPrice)
         }
 
         if (
@@ -170,7 +166,7 @@ export function useServiceFormState({
           field === 'unitCost' ||
           field === 'markupPercent'
         ) {
-          part.total = Number(part.quantity) * Number(part.unitPrice)
+          part.total = lineTotal(part.quantity, part.unitPrice)
         }
         updated[index] = part
         return updated

+ 2 - 1
src/lib/cron/recurring-invoices.ts

@@ -2,6 +2,7 @@ import { CronJob } from 'cron'
 import { db } from '@/lib/db'
 import { resolveInvoicePrefix } from '@/lib/invoice-utils'
 import { calculateTotals } from '@/lib/tax'
+import { lineTotal } from '@/features/inventory/Lib/partPricing'
 
 function calculateNextRunDate(current: Date, frequency: string): Date {
   const next = new Date(current)
@@ -109,7 +110,7 @@ export function processRecurringInvoices() {
                 partItems: {
                   create: ri.templateParts.map((p) => ({
                     name: p.name, partNumber: p.partNumber,
-                    quantity: p.quantity, unitPrice: p.unitPrice, total: p.quantity * p.unitPrice,
+                    quantity: p.quantity, unitPrice: p.unitPrice, total: lineTotal(p.quantity, p.unitPrice),
                   })),
                 },
                 laborItems: {