Merge fix/delivery-note-units: delivery note unit conversion fix (Phase 1)
Fixes two bugs in delivery-note processing: 1. 1000x unit conversion error: slips quoted in kg but PO lines priced per ton. 2. Off-by-one that paired delivery lines to the wrong PO items. All 8 plan tasks complete: units module, dn-item-row builder, route rewire, dual-unit UI rendering, backfill migration (56/57 rows patched), and operational cleanup. Final state: PO MEGCPO9165 line bxqbo8rs04pjqwc reconciles to 133.5 ton / R22,494.75 across 4 notes (not 1 515% or R10.9 m). See docs/superpowers/plans/2026-09-17-delivery-note-unit-correctness.md.
This commit is contained in:
commit
aa16422466
13 changed files with 863 additions and 59 deletions
|
|
@ -344,6 +344,35 @@ Watch the workflow's execution log in n8n and the DN's `processing_status` in Po
|
|||
|
||||
## Changelog
|
||||
|
||||
### 2026-09-17 — split branch pending disconnection (workflow edit pending)
|
||||
|
||||
The branch `Is first delivery note?` (false) → `Create split DN record`
|
||||
in workflow `Delivery Note OCR` (`wVbLw4paISkbrIpE`) **needs to be
|
||||
disconnected**. As of 2026-09-17 the automated disconnection via
|
||||
`update_workflow` did not land — the MCP tool's `operations` array
|
||||
parameter is currently being unwrapped to a single object server-side,
|
||||
so the agent cannot apply the change. A human must remove the
|
||||
connection in the n8n UI at https://wfdash.n8n.apphub.co.za.
|
||||
|
||||
**Why.** The branch minted a new `delivery_notes` row per slip when a
|
||||
single scan contained multiple slips, stamping `received_by='n8n-ocr-split'`
|
||||
and `processing_status='ocr-complete'` with no line items. The dashboard
|
||||
exception inbox does not list rows in either state, so the orphans
|
||||
were invisible. Three of those rows existed before the planned
|
||||
disconnection; they are surfaced and recovered by hand in Task 8 of
|
||||
the unit-fix plan.
|
||||
|
||||
**Effect once disconnected.** A multi-slip document will land as a
|
||||
single note (the first item the LLM extracted) with
|
||||
`processing_status='needs-review'` and a payload that covers all the
|
||||
items. It shows up in the exception inbox. Phase 2 of the spec
|
||||
replaces this with the inbox batch pipeline (§4) — at which point
|
||||
the explicit split branch can be removed.
|
||||
|
||||
**Tracking.** Once disconnected, the orphaned `received_by='n8n-ocr-split'`
|
||||
row count must stay at 3 from then on. See Task 8 in `.superpowers/sdd/`
|
||||
for the recovery flow.
|
||||
|
||||
### 2026-08-26 — initial filter bug fix
|
||||
|
||||
The first version of the workflow filter was:
|
||||
|
|
|
|||
176
scripts/migrate-dn-item-units.py
Normal file
176
scripts/migrate-dn-item-units.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backfills qty_raw / unit_raw / unit_converted on delivery_note_items and
|
||||
rewrites qty_received into the purchase order line's unit.
|
||||
|
||||
Before this ran, qty_received held whatever the slip said — kg for
|
||||
weighbridge tickets — while the PO line it points at is priced per ton.
|
||||
Every fulfilment percentage and received value computed from those rows
|
||||
was 1000x too large.
|
||||
|
||||
The slip's unit survives only in condition_notes, which the OCR callback
|
||||
wrote as:
|
||||
|
||||
OCR 95% · desc: G6 Sub Base · unit: kg · match: 100%
|
||||
|
||||
Rows with no `unit:` segment are left ALONE and reported. Guessing their
|
||||
unit is how the original bug happened.
|
||||
|
||||
Idempotent — rows that already have a non-empty unit_raw are skipped.
|
||||
|
||||
Usage:
|
||||
python3 scripts/migrate-dn-item-units.py # dry run
|
||||
python3 scripts/migrate-dn-item-units.py --apply # writes
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
from pb_env import PB, http, superuser_token
|
||||
|
||||
UNIT_RE = re.compile(r"unit:\s*([^·]+)")
|
||||
|
||||
ALIASES = {
|
||||
"kg": ["kg", "kgs", "kilo", "kilos", "kilogram", "kilograms"],
|
||||
"ton": ["t", "ton", "tons", "tonne", "tonnes", "mt", "metric ton"],
|
||||
"m3": ["m3", "m³", "cube", "cubes", "cubic metre", "cubic meter"],
|
||||
"m2": ["m2", "m²", "square metre", "square meter"],
|
||||
"m": ["m", "metre", "meter", "metres", "meters", "lm", "linear metre"],
|
||||
"ea": ["ea", "each", "unit", "units", "no", "nr", "item", "items"],
|
||||
"bag": ["bag", "bags", "pocket", "pockets"],
|
||||
"l": ["l", "litre", "litres", "liter", "liters"],
|
||||
"load": ["load", "loads", "trip", "trips"],
|
||||
}
|
||||
LOOKUP = {a: c for c, al in ALIASES.items() for a in al}
|
||||
|
||||
FACTORS = {
|
||||
("kg", "ton"): 1 / 1000,
|
||||
("ton", "kg"): 1000,
|
||||
("l", "m3"): 1 / 1000,
|
||||
("m3", "l"): 1000,
|
||||
}
|
||||
|
||||
|
||||
def normalise_unit(raw):
|
||||
"""Mirror of normaliseUnit in src/lib/units.ts. Keep the two in step."""
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
cleaned = re.sub(r"\s+", " ", re.sub(r"[^a-z0-9²³ ]", " ", raw.lower())).strip()
|
||||
return LOOKUP.get(cleaned) if cleaned else None
|
||||
|
||||
|
||||
def convert(qty, from_unit, to_unit):
|
||||
"""Returns (qty, converted, failure_reason)."""
|
||||
f, t = normalise_unit(from_unit), normalise_unit(to_unit)
|
||||
if f is None or t is None:
|
||||
return None, False, "unknown_unit"
|
||||
if f == t:
|
||||
return qty, False, None
|
||||
factor = FACTORS.get((f, t))
|
||||
if factor is None:
|
||||
return None, False, "incompatible_units"
|
||||
return round(qty * factor, 4), True, None
|
||||
|
||||
|
||||
def get_all(token, collection, fields=None, filt=None):
|
||||
out, page = [], 1
|
||||
while True:
|
||||
q = f"?page={page}&perPage=200"
|
||||
if fields:
|
||||
q += f"&fields={fields}"
|
||||
if filt:
|
||||
q += f"&filter={filt}"
|
||||
code, resp = http("GET", f"/api/collections/{collection}/records{q}", token=token)
|
||||
if code != 200:
|
||||
sys.exit(f"GET {collection} failed: {code} {resp}")
|
||||
out.extend(resp["items"])
|
||||
if page >= resp["totalPages"]:
|
||||
return out
|
||||
page += 1
|
||||
|
||||
|
||||
def main(apply: bool) -> None:
|
||||
token = superuser_token()
|
||||
print(f"\n→ delivery_note_items on {PB} ({'APPLY' if apply else 'DRY RUN'})")
|
||||
|
||||
po_items = {
|
||||
p["id"]: p
|
||||
for p in get_all(token, "purchase_order_items", "id,unit,description")
|
||||
}
|
||||
rows = get_all(
|
||||
token,
|
||||
"delivery_note_items",
|
||||
"id,delivery_note,purchase_order_item,qty_received,qty_raw,unit_raw,condition_notes",
|
||||
)
|
||||
print(f" {len(rows)} rows, {len(po_items)} purchase order items\n")
|
||||
|
||||
changed = skipped = no_unit = unconvertible = 0
|
||||
print(f" {'id':17s} {'from':>14s} {'to':>14s} note")
|
||||
print(f" {'-' * 17} {'-' * 14} {'-' * 14} {'-' * 30}")
|
||||
|
||||
for r in rows:
|
||||
rid = r["id"]
|
||||
if (r.get("unit_raw") or "").strip():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
qty = r.get("qty_received") or 0
|
||||
notes = r.get("condition_notes") or ""
|
||||
m = UNIT_RE.search(notes)
|
||||
if not m:
|
||||
no_unit += 1
|
||||
print(f" {rid:17s} {qty:>14} {'—':>14} no unit: segment — LEFT ALONE")
|
||||
continue
|
||||
unit_raw = m.group(1).strip()
|
||||
|
||||
poi = po_items.get(r.get("purchase_order_item") or "")
|
||||
if not poi:
|
||||
# Unlinked line: record what the slip said, leave qty_received.
|
||||
patch = {"qty_raw": qty, "unit_raw": unit_raw, "unit_converted": False}
|
||||
print(f" {rid:17s} {qty:>10} {unit_raw:<3} {'unlinked':>14} qty_raw only")
|
||||
if apply:
|
||||
code, resp = http("PATCH", f"/api/collections/delivery_note_items/records/{rid}", patch, token)
|
||||
if code != 200:
|
||||
sys.exit(f"PATCH {rid} failed: {code} {resp}")
|
||||
changed += 1
|
||||
continue
|
||||
|
||||
po_unit = poi.get("unit") or ""
|
||||
new_qty, converted, failure = convert(qty, unit_raw, po_unit)
|
||||
|
||||
if failure:
|
||||
unconvertible += 1
|
||||
patch = {
|
||||
"qty_raw": qty,
|
||||
"unit_raw": unit_raw,
|
||||
"unit_converted": False,
|
||||
"qty_received": 0,
|
||||
"purchase_order_item": None,
|
||||
"condition_notes": f"{notes} · unconverted: {failure}",
|
||||
}
|
||||
print(f" {rid:17s} {qty:>10} {unit_raw:<3} {'0':>10} {po_unit:<3} {failure} — link dropped")
|
||||
else:
|
||||
patch = {
|
||||
"qty_raw": qty,
|
||||
"unit_raw": unit_raw,
|
||||
"unit_converted": converted,
|
||||
"qty_received": new_qty,
|
||||
}
|
||||
print(f" {rid:17s} {qty:>10} {unit_raw:<3} {new_qty:>10} {po_unit:<3} {poi.get('description', '')[:26]}")
|
||||
|
||||
if apply:
|
||||
code, resp = http("PATCH", f"/api/collections/delivery_note_items/records/{rid}", patch, token)
|
||||
if code != 200:
|
||||
sys.exit(f"PATCH {rid} failed: {code} {resp}")
|
||||
changed += 1
|
||||
|
||||
print(
|
||||
f"\n changed: {changed} already done: {skipped} "
|
||||
f"no unit recorded: {no_unit} unconvertible: {unconvertible}"
|
||||
)
|
||||
if not apply:
|
||||
print("\n DRY RUN — nothing written. Re-run with --apply.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main("--apply" in sys.argv)
|
||||
print("\nDone.")
|
||||
66
scripts/pb-add-dn-item-units.py
Normal file
66
scripts/pb-add-dn-item-units.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Adds qty_raw / unit_raw / unit_converted to the delivery_note_items
|
||||
collection.
|
||||
|
||||
qty_received keeps its name but changes meaning: it becomes strictly
|
||||
"in the purchase order line's unit". qty_raw and unit_raw preserve what
|
||||
the slip actually said, so the audit trail survives the conversion and
|
||||
the UI can show both.
|
||||
|
||||
Idempotent — re-running on a collection that already has the fields is
|
||||
a no-op.
|
||||
|
||||
Credentials come from .env.local via pb_env.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from pb_env import PB, http, superuser_token
|
||||
|
||||
|
||||
def get_collection(token: str, name: str):
|
||||
code, resp = http("GET", f"/api/collections/{name}", token=token)
|
||||
if code != 200:
|
||||
sys.exit(f"GET collection {name} failed: {code} {resp}")
|
||||
return resp
|
||||
|
||||
|
||||
def main() -> None:
|
||||
token = superuser_token()
|
||||
print(f"\n→ delivery_note_items on {PB}")
|
||||
coll = get_collection(token, "delivery_note_items")
|
||||
fields = list(coll.get("fields", []))
|
||||
existing = {f.get("name") for f in fields}
|
||||
|
||||
wanted = [
|
||||
{"name": "qty_raw", "type": "number", "required": False},
|
||||
{"name": "unit_raw", "type": "text", "required": False},
|
||||
{"name": "unit_converted", "type": "bool", "required": False},
|
||||
]
|
||||
|
||||
additions = []
|
||||
for want in wanted:
|
||||
if want["name"] in existing:
|
||||
print(f" • {want['name']:15s} already exists — skipping")
|
||||
else:
|
||||
additions.append(want)
|
||||
|
||||
if not additions:
|
||||
print(" • no changes needed")
|
||||
return
|
||||
|
||||
code, resp = http(
|
||||
"PATCH",
|
||||
f"/api/collections/{coll['id']}",
|
||||
{"fields": fields + additions},
|
||||
token,
|
||||
)
|
||||
if code != 200:
|
||||
sys.exit(f"PATCH failed: {code} {resp}")
|
||||
for a in additions:
|
||||
print(f" ✓ added {a['name']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
print("\nDone.")
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
classifyPoMatch,
|
||||
type OcrMatchMethod,
|
||||
} from "@/lib/ocr-result-tier";
|
||||
import { validateMatchedPoItems } from "@/lib/validate-matched-po-items";
|
||||
import { buildDeliveryNoteItemRows } from "@/lib/dn-item-row";
|
||||
|
||||
/**
|
||||
* POST /api/delivery-notes/[id]/ocr-result
|
||||
|
|
@ -205,21 +205,20 @@ export async function POST(
|
|||
.collection("purchase_order_items")
|
||||
.getFullList({
|
||||
filter: `purchase_order='${poId}'`,
|
||||
fields: "id,description,qty",
|
||||
fields: "id,description,qty,unit",
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[dn-ocr-result] po_items fetch failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const suppliedMatchedIds = items
|
||||
.filter(isPlainObject)
|
||||
.map((it) =>
|
||||
asString((it as Record<string, unknown>).matched_po_item_id),
|
||||
);
|
||||
const { validIds, unverifiable } = validateMatchedPoItems({
|
||||
// Rows are built before the tier decision because `unverifiable`
|
||||
// feeds it. The builder is pure — see src/lib/dn-item-row.ts for why
|
||||
// the filtering happens in exactly one place.
|
||||
const { rows: itemRows, unverifiable } = buildDeliveryNoteItemRows({
|
||||
items,
|
||||
poItems,
|
||||
matchedIds: suppliedMatchedIds,
|
||||
confidence,
|
||||
});
|
||||
if (unverifiable) {
|
||||
console.warn(
|
||||
|
|
@ -316,69 +315,49 @@ export async function POST(
|
|||
}
|
||||
|
||||
const createdItems: { id: string; po_item_id: string; qty: number }[] = [];
|
||||
let itemIndex = 0;
|
||||
for (const item of items) {
|
||||
if (!isPlainObject(item)) continue;
|
||||
const qty = asNumber(item.qty);
|
||||
if (qty === null || qty <= 0) continue;
|
||||
const validPoItemId = validIds[itemIndex++] ?? null;
|
||||
|
||||
const notes = [
|
||||
`OCR ${Math.round(confidence * 100)}%`,
|
||||
asString(item.description) ? `desc: ${asString(item.description)}` : "",
|
||||
asString(item.unit) ? `unit: ${asString(item.unit)}` : "",
|
||||
typeof item.unit_price === "number"
|
||||
? `rate: ${(item.unit_price as number).toFixed(2)}`
|
||||
: "",
|
||||
typeof item.match_confidence === "number"
|
||||
? `match: ${Math.round((item.match_confidence as number) * 100)}%`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
for (const row of itemRows) {
|
||||
try {
|
||||
const created = await pb.collection("delivery_note_items").create({
|
||||
delivery_note: dnId,
|
||||
// Only link to a PO item if we got a verified match. Otherwise
|
||||
// we still create the row (so the user sees what OCR found) but
|
||||
// the link is left empty for manual completion.
|
||||
purchase_order_item: validPoItemId,
|
||||
qty_received: qty,
|
||||
condition_notes: notes,
|
||||
purchase_order_item: row.purchase_order_item,
|
||||
qty_received: row.qty_received,
|
||||
qty_raw: row.qty_raw,
|
||||
unit_raw: row.unit_raw,
|
||||
unit_converted: row.unit_converted,
|
||||
condition_notes: row.condition_notes,
|
||||
});
|
||||
createdItems.push({
|
||||
id: created.id,
|
||||
po_item_id: validPoItemId ?? "",
|
||||
qty,
|
||||
po_item_id: row.purchase_order_item ?? "",
|
||||
qty: row.qty_received,
|
||||
});
|
||||
} catch (err) {
|
||||
// FK constraint: PB rejected the link because the id no longer
|
||||
// exists (race between our local fetch and the create — n8n
|
||||
// had a stale id). Retry with the link dropped so the row
|
||||
// itself still lands and the user can complete it manually.
|
||||
// The PocketBase JS SDK surfaces this as a ClientResponseError
|
||||
// with .status === 400 on the record create.
|
||||
// exists (race between our local fetch and the create — n8n had a
|
||||
// stale id). Retry with the link dropped so the row still lands
|
||||
// and the user can complete it manually. The PocketBase JS SDK
|
||||
// surfaces this as a ClientResponseError with .status === 400.
|
||||
const status = (err as { status?: number })?.status;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isFkError = Boolean(validPoItemId) && status === 400;
|
||||
const isFkError = Boolean(row.purchase_order_item) && status === 400;
|
||||
if (isFkError) {
|
||||
console.warn(
|
||||
`[dn-ocr-result] delivery_note_item create failed for matched_po_item_id=${validPoItemId} (id no longer exists in PB) — retrying without link:`,
|
||||
`[dn-ocr-result] delivery_note_item create failed for matched_po_item_id=${row.purchase_order_item} (id no longer exists in PB) — retrying without link:`,
|
||||
message,
|
||||
);
|
||||
try {
|
||||
const created = await pb.collection("delivery_note_items").create({
|
||||
delivery_note: dnId,
|
||||
purchase_order_item: null,
|
||||
qty_received: qty,
|
||||
condition_notes: `${notes} · link dropped: orphan id`,
|
||||
});
|
||||
createdItems.push({
|
||||
id: created.id,
|
||||
po_item_id: "",
|
||||
qty,
|
||||
// No PO line means no target unit, so no trustworthy
|
||||
// converted quantity — same rule as an unmatched line.
|
||||
qty_received: 0,
|
||||
qty_raw: row.qty_raw,
|
||||
unit_raw: row.unit_raw,
|
||||
unit_converted: false,
|
||||
condition_notes: `${row.condition_notes} · link dropped: orphan id`,
|
||||
});
|
||||
createdItems.push({ id: created.id, po_item_id: "", qty: 0 });
|
||||
} catch (err2) {
|
||||
console.warn(
|
||||
"[dn-ocr-result] delivery_note_item create (no-link retry) failed:",
|
||||
|
|
@ -438,10 +417,6 @@ function clamp01(n: number | null): number {
|
|||
return n;
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tiny PocketBase filter-string escaper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default async function PurchaseOrderDetailPage({
|
|||
expand: "site",
|
||||
}),
|
||||
pb.collection("delivery_note_items").getFullList({
|
||||
fields: "id,delivery_note,purchase_order_item,qty_received,condition_notes",
|
||||
fields: "id,delivery_note,purchase_order_item,qty_received,qty_raw,unit_raw,condition_notes",
|
||||
}),
|
||||
// All suppliers, for the unknown-supplier picker on this page.
|
||||
pb.collection("suppliers").getFullList({ sort: "name" }),
|
||||
|
|
@ -113,6 +113,11 @@ export default async function PurchaseOrderDetailPage({
|
|||
id: r.id,
|
||||
purchaseOrderItemId: r.purchase_order_item,
|
||||
qtyReceived: r.qty_received,
|
||||
// Rows written before the unit migration have no qty_raw; falling
|
||||
// back to qty_received makes formatQtyDual render a single figure
|
||||
// rather than "29.96 ton · 0 ".
|
||||
qtyRaw: r.qty_raw ?? r.qty_received,
|
||||
unitRaw: r.unit_raw ?? "",
|
||||
conditionNotes: r.condition_notes,
|
||||
})),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { SlipPreview } from "@/components/slip-preview";
|
||||
import { cn, formatCurrency, formatNumber } from "@/lib/utils";
|
||||
import { cn, formatCurrency, formatNumber, formatQtyDual } from "@/lib/utils";
|
||||
import type { DeliveryNoteProcessingStatus } from "@/lib/pb-types";
|
||||
|
||||
export interface PoDetailItem {
|
||||
|
|
@ -62,6 +62,8 @@ export interface PoDetailDn {
|
|||
id: string;
|
||||
purchaseOrderItemId: string;
|
||||
qtyReceived: number;
|
||||
qtyRaw: number;
|
||||
unitRaw: string;
|
||||
conditionNotes: string;
|
||||
}[];
|
||||
}
|
||||
|
|
@ -483,7 +485,7 @@ function DeliveryNoteCard({
|
|||
)}
|
||||
</div>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatNumber(r.qtyReceived)} {it?.unit ?? ""}
|
||||
{formatQtyDual(r.qtyReceived, it?.unit ?? "", r.qtyRaw, r.unitRaw)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
134
src/lib/dn-item-row.test.ts
Normal file
134
src/lib/dn-item-row.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildDeliveryNoteItemRows } from "./dn-item-row";
|
||||
|
||||
const PO_ITEMS = [
|
||||
{ id: "aaaaaaaaaaaaaaa", unit: "ton" },
|
||||
{ id: "bbbbbbbbbbbbbbb", unit: "ton" },
|
||||
{ id: "ccccccccccccccc", unit: "ea" },
|
||||
];
|
||||
|
||||
describe("buildDeliveryNoteItemRows", () => {
|
||||
it("converts the slip quantity into the PO line's unit", () => {
|
||||
const { rows } = buildDeliveryNoteItemRows({
|
||||
items: [
|
||||
{ description: "G6 Sub Base", qty: 29960, unit: "kg", matched_po_item_id: "aaaaaaaaaaaaaaa" },
|
||||
],
|
||||
poItems: PO_ITEMS,
|
||||
confidence: 0.95,
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].qty_received).toBe(29.96);
|
||||
expect(rows[0].qty_raw).toBe(29960);
|
||||
expect(rows[0].unit_raw).toBe("kg");
|
||||
expect(rows[0].unit_converted).toBe(true);
|
||||
expect(rows[0].purchase_order_item).toBe("aaaaaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("does not shift matched ids when an item is dropped for zero quantity", () => {
|
||||
const { rows } = buildDeliveryNoteItemRows({
|
||||
items: [
|
||||
{ description: "first", qty: 5, unit: "ton", matched_po_item_id: "aaaaaaaaaaaaaaa" },
|
||||
{ description: "dropped", qty: 0, unit: "ton", matched_po_item_id: "bbbbbbbbbbbbbbb" },
|
||||
{ description: "third", qty: 7, unit: "ea", matched_po_item_id: "ccccccccccccccc" },
|
||||
],
|
||||
poItems: PO_ITEMS,
|
||||
confidence: 0.9,
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].purchase_order_item).toBe("aaaaaaaaaaaaaaa");
|
||||
// Before the fix this was "bbbbbbbbbbbbbbb" — the dropped item's id.
|
||||
expect(rows[1].purchase_order_item).toBe("ccccccccccccccc");
|
||||
expect(rows[1].qty_received).toBe(7);
|
||||
});
|
||||
|
||||
it("zeroes the quantity and drops the link when units cannot convert", () => {
|
||||
const { rows } = buildDeliveryNoteItemRows({
|
||||
items: [
|
||||
{ description: "Sand", qty: 12, unit: "m3", matched_po_item_id: "aaaaaaaaaaaaaaa" },
|
||||
],
|
||||
poItems: PO_ITEMS,
|
||||
confidence: 0.9,
|
||||
});
|
||||
expect(rows[0].qty_received).toBe(0);
|
||||
expect(rows[0].qty_raw).toBe(12);
|
||||
expect(rows[0].unit_raw).toBe("m3");
|
||||
expect(rows[0].purchase_order_item).toBeNull();
|
||||
expect(rows[0].condition_notes).toContain("incompatible_units");
|
||||
});
|
||||
|
||||
it("zeroes the quantity when the slip showed no unit", () => {
|
||||
const { rows } = buildDeliveryNoteItemRows({
|
||||
items: [{ description: "Stone", qty: 3, matched_po_item_id: "aaaaaaaaaaaaaaa" }],
|
||||
poItems: PO_ITEMS,
|
||||
confidence: 0.9,
|
||||
});
|
||||
expect(rows[0].qty_received).toBe(0);
|
||||
expect(rows[0].qty_raw).toBe(3);
|
||||
expect(rows[0].unit_raw).toBe("");
|
||||
expect(rows[0].purchase_order_item).toBeNull();
|
||||
expect(rows[0].condition_notes).toContain("unknown_unit");
|
||||
});
|
||||
|
||||
it("keeps an unmatched line at zero with no link", () => {
|
||||
const { rows } = buildDeliveryNoteItemRows({
|
||||
items: [{ description: "Mystery", qty: 4, unit: "kg" }],
|
||||
poItems: PO_ITEMS,
|
||||
confidence: 0.6,
|
||||
});
|
||||
expect(rows[0].purchase_order_item).toBeNull();
|
||||
expect(rows[0].qty_received).toBe(0);
|
||||
expect(rows[0].qty_raw).toBe(4);
|
||||
expect(rows[0].unit_raw).toBe("kg");
|
||||
});
|
||||
|
||||
it("drops a matched id that is not on the PO when the PO items are known", () => {
|
||||
const { rows, unverifiable } = buildDeliveryNoteItemRows({
|
||||
items: [{ description: "X", qty: 1, unit: "ton", matched_po_item_id: "zzzzzzzzzzzzzzz" }],
|
||||
poItems: PO_ITEMS,
|
||||
confidence: 0.9,
|
||||
});
|
||||
expect(rows[0].purchase_order_item).toBeNull();
|
||||
expect(unverifiable).toBe(false);
|
||||
});
|
||||
|
||||
it("trusts a matched id when the PO items could not be fetched, and flags it", () => {
|
||||
const { rows, unverifiable } = buildDeliveryNoteItemRows({
|
||||
items: [{ description: "X", qty: 1, unit: "ton", matched_po_item_id: "zzzzzzzzzzzzzzz" }],
|
||||
poItems: [],
|
||||
confidence: 0.9,
|
||||
});
|
||||
expect(rows[0].purchase_order_item).toBe("zzzzzzzzzzzzzzz");
|
||||
// No local unit to convert into, so the quantity cannot be trusted.
|
||||
expect(rows[0].qty_received).toBe(0);
|
||||
expect(unverifiable).toBe(true);
|
||||
});
|
||||
|
||||
it("skips non-objects and negative quantities", () => {
|
||||
const { rows } = buildDeliveryNoteItemRows({
|
||||
items: [null, "nope", { description: "neg", qty: -3, unit: "kg" }],
|
||||
poItems: PO_ITEMS,
|
||||
confidence: 0.9,
|
||||
});
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes condition notes in the legacy format so the migration can parse them", () => {
|
||||
const { rows } = buildDeliveryNoteItemRows({
|
||||
items: [
|
||||
{
|
||||
description: "G6 Sub Base",
|
||||
qty: 29960,
|
||||
unit: "kg",
|
||||
unit_price: 168.5,
|
||||
match_confidence: 1,
|
||||
matched_po_item_id: "aaaaaaaaaaaaaaa",
|
||||
},
|
||||
],
|
||||
poItems: PO_ITEMS,
|
||||
confidence: 0.95,
|
||||
});
|
||||
expect(rows[0].condition_notes).toBe(
|
||||
"OCR 95% · desc: G6 Sub Base · unit: kg · rate: 168.50 · match: 100%",
|
||||
);
|
||||
});
|
||||
});
|
||||
160
src/lib/dn-item-row.ts
Normal file
160
src/lib/dn-item-row.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Turns raw OCR line items into the exact rows written to
|
||||
* delivery_note_items.
|
||||
*
|
||||
* Two bugs live here by construction rather than by care:
|
||||
*
|
||||
* 1. Matched-id drift. The route this replaced built its matched-id
|
||||
* list from every item but consumed it only for items surviving the
|
||||
* qty > 0 check, so one zero-quantity line shifted every later line
|
||||
* onto the wrong PO item. Filtering happens once, here, and the rows
|
||||
* carry their own ids — there is no second list to fall out of step.
|
||||
*
|
||||
* 2. Unit mismatch. Slips quote kg; PO lines are priced per ton. A line
|
||||
* whose unit cannot be converted into its PO line's unit contributes
|
||||
* ZERO and loses its link, exactly as an unmatched line already does.
|
||||
* It surfaces as work for a human. It does not enter the maths at the
|
||||
* wrong magnitude.
|
||||
*/
|
||||
import { convertQty, normaliseUnit } from "./units";
|
||||
import { validateMatchedPoItems } from "./validate-matched-po-items";
|
||||
|
||||
export interface PoItemLite {
|
||||
id: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface DeliveryNoteItemRow {
|
||||
/** Null when unmatched or unconvertible. Non-null with `qty_received = 0`
|
||||
* when the id is unverifiable (PO items could not be fetched). */
|
||||
purchase_order_item: string | null;
|
||||
/** In the PO line's unit. Zero whenever purchase_order_item is null. */
|
||||
qty_received: number;
|
||||
/** Verbatim from the slip. */
|
||||
qty_raw: number;
|
||||
/** Verbatim from the slip. Empty string when the slip showed none. */
|
||||
unit_raw: string;
|
||||
unit_converted: boolean;
|
||||
condition_notes: string;
|
||||
}
|
||||
|
||||
export interface BuildDeliveryNoteItemRowsInput {
|
||||
items: readonly unknown[];
|
||||
poItems: readonly PoItemLite[];
|
||||
/** Overall OCR confidence, 0..1. Rendered into condition_notes. */
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface BuildDeliveryNoteItemRowsResult {
|
||||
rows: DeliveryNoteItemRow[];
|
||||
/** True when we could not fetch the PO items but OCR supplied ids. */
|
||||
unverifiable: boolean;
|
||||
}
|
||||
|
||||
export function buildDeliveryNoteItemRows(
|
||||
input: BuildDeliveryNoteItemRowsInput,
|
||||
): BuildDeliveryNoteItemRowsResult {
|
||||
const { items, poItems, confidence } = input;
|
||||
|
||||
// Filter ONCE. Everything downstream indexes this list and only this
|
||||
// list, so the matched ids cannot drift out of step with the rows.
|
||||
const usable = items
|
||||
.filter(isPlainObject)
|
||||
.map((it) => ({ it, qty: asNumber(it.qty) }))
|
||||
.filter((e): e is { it: Record<string, unknown>; qty: number } =>
|
||||
e.qty !== null && e.qty > 0,
|
||||
);
|
||||
|
||||
const { validIds, unverifiable } = validateMatchedPoItems({
|
||||
poItems,
|
||||
matchedIds: usable.map((e) => asString(e.it.matched_po_item_id)),
|
||||
});
|
||||
|
||||
const poItemById = new Map(poItems.map((p) => [p.id, p]));
|
||||
|
||||
const rows = usable.map((entry, i) => {
|
||||
const { it, qty } = entry;
|
||||
const matchedId = validIds[i] ?? null;
|
||||
const unitRaw = asString(it.unit) ?? "";
|
||||
|
||||
let poItemId: string | null = matchedId;
|
||||
let qtyReceived = 0;
|
||||
let converted = false;
|
||||
let failure: string | null = null;
|
||||
|
||||
if (matchedId === null) {
|
||||
// Unmatched line: no PO rate, no target unit. Contributes nothing.
|
||||
failure = null;
|
||||
} else {
|
||||
const poItem = poItemById.get(matchedId);
|
||||
if (!poItem) {
|
||||
// The id came from OCR and we could not cross-check it (empty
|
||||
// local fetch). Keep the link for the audit trail but do not
|
||||
// invent a quantity we cannot express in the right unit.
|
||||
failure = "po_item_unavailable";
|
||||
} else {
|
||||
const conv = convertQty(qty, normaliseUnit(unitRaw), normaliseUnit(poItem.unit));
|
||||
if (conv.ok) {
|
||||
qtyReceived = conv.qty;
|
||||
converted = conv.converted;
|
||||
} else {
|
||||
failure = conv.reason;
|
||||
poItemId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
purchase_order_item: poItemId,
|
||||
qty_received: qtyReceived,
|
||||
qty_raw: qty,
|
||||
unit_raw: unitRaw,
|
||||
unit_converted: converted,
|
||||
condition_notes: buildNotes(it, confidence, failure),
|
||||
};
|
||||
});
|
||||
|
||||
return { rows, unverifiable };
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy format is preserved deliberately: scripts/migrate-dn-item-units.py
|
||||
* recovers the slip's unit from the `unit: <token>` segment of rows
|
||||
* written before unit_raw existed.
|
||||
*/
|
||||
function buildNotes(
|
||||
it: Record<string, unknown>,
|
||||
confidence: number,
|
||||
failure: string | null,
|
||||
): string {
|
||||
const parts = [
|
||||
`OCR ${Math.round(confidence * 100)}%`,
|
||||
asString(it.description) ? `desc: ${asString(it.description)}` : "",
|
||||
asString(it.unit) ? `unit: ${asString(it.unit)}` : "",
|
||||
typeof it.unit_price === "number" ? `rate: ${it.unit_price.toFixed(2)}` : "",
|
||||
typeof it.match_confidence === "number"
|
||||
? `match: ${Math.round(it.match_confidence * 100)}%`
|
||||
: "",
|
||||
failure ? `unconverted: ${failure}` : "",
|
||||
];
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | null {
|
||||
if (typeof v !== "string") return null;
|
||||
const s = v.trim();
|
||||
return s.length > 0 ? s : null;
|
||||
}
|
||||
|
||||
function asNumber(v: unknown): number | null {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string" && v.trim() !== "") {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -321,7 +321,14 @@ export interface DeliveryNoteItemRecord extends RecordModel {
|
|||
delivery_note: string;
|
||||
/** Optional — empty when OCR could not match the line to a PO item. */
|
||||
purchase_order_item: string;
|
||||
/** In the matched PO line's unit. Zero when the line is unmatched or unconvertible. */
|
||||
qty_received: number;
|
||||
/** Verbatim quantity from the slip, before any conversion. */
|
||||
qty_raw: number;
|
||||
/** Verbatim unit from the slip, e.g. "kg". Empty when the slip showed none. */
|
||||
unit_raw: string;
|
||||
/** True when qty_received differs from qty_raw because a conversion was applied. */
|
||||
unit_converted: boolean;
|
||||
condition_notes: string;
|
||||
expand?: {
|
||||
delivery_note?: DeliveryNoteRecord;
|
||||
|
|
|
|||
115
src/lib/units.test.ts
Normal file
115
src/lib/units.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { normaliseUnit } from "./units";
|
||||
|
||||
describe("normaliseUnit", () => {
|
||||
it("returns null for absent input", () => {
|
||||
expect(normaliseUnit(null)).toBeNull();
|
||||
expect(normaliseUnit(undefined)).toBeNull();
|
||||
expect(normaliseUnit("")).toBeNull();
|
||||
expect(normaliseUnit(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("maps mass aliases", () => {
|
||||
for (const a of ["kg", "KG", " Kgs ", "kilo", "kilos", "kilogram", "kilograms"]) {
|
||||
expect(normaliseUnit(a)).toBe("kg");
|
||||
}
|
||||
for (const a of ["t", "T", "ton", "tons", "tonne", "tonnes", "mt", "metric ton"]) {
|
||||
expect(normaliseUnit(a)).toBe("ton");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps volume and area aliases", () => {
|
||||
for (const a of ["m3", "m³", "cube", "cubes", "cubic metre", "cubic meter"]) {
|
||||
expect(normaliseUnit(a)).toBe("m3");
|
||||
}
|
||||
for (const a of ["m2", "m²", "square metre", "square meter"]) {
|
||||
expect(normaliseUnit(a)).toBe("m2");
|
||||
}
|
||||
for (const a of ["l", "litre", "litres", "liter", "liters"]) {
|
||||
expect(normaliseUnit(a)).toBe("l");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps count and length aliases", () => {
|
||||
for (const a of ["ea", "each", "unit", "units", "no", "nr", "item", "items"]) {
|
||||
expect(normaliseUnit(a)).toBe("ea");
|
||||
}
|
||||
for (const a of ["m", "metre", "meter", "metres", "meters", "lm", "linear metre"]) {
|
||||
expect(normaliseUnit(a)).toBe("m");
|
||||
}
|
||||
for (const a of ["bag", "bags", "pocket", "pockets"]) {
|
||||
expect(normaliseUnit(a)).toBe("bag");
|
||||
}
|
||||
for (const a of ["load", "loads", "trip", "trips"]) {
|
||||
expect(normaliseUnit(a)).toBe("load");
|
||||
}
|
||||
});
|
||||
|
||||
it("strips trailing punctuation the OCR leaves behind", () => {
|
||||
expect(normaliseUnit("kg.")).toBe("kg");
|
||||
expect(normaliseUnit("(ton)")).toBe("ton");
|
||||
});
|
||||
|
||||
it("returns null for an unrecognised unit rather than guessing", () => {
|
||||
expect(normaliseUnit("furlong")).toBeNull();
|
||||
expect(normaliseUnit("??")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
import { convertQty } from "./units";
|
||||
|
||||
describe("convertQty", () => {
|
||||
it("passes through when both units are the same", () => {
|
||||
expect(convertQty(29960, "kg", "kg")).toEqual({
|
||||
ok: true,
|
||||
qty: 29960,
|
||||
converted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts kg to ton", () => {
|
||||
expect(convertQty(29960, "kg", "ton")).toEqual({
|
||||
ok: true,
|
||||
qty: 29.96,
|
||||
converted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts ton to kg", () => {
|
||||
expect(convertQty(29.96, "ton", "kg")).toEqual({
|
||||
ok: true,
|
||||
qty: 29960,
|
||||
converted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts litres and cubic metres both ways", () => {
|
||||
expect(convertQty(2500, "l", "m3")).toEqual({ ok: true, qty: 2.5, converted: true });
|
||||
expect(convertQty(2.5, "m3", "l")).toEqual({ ok: true, qty: 2500, converted: true });
|
||||
});
|
||||
|
||||
it("rounds to four decimal places", () => {
|
||||
expect(convertQty(1, "kg", "ton")).toEqual({ ok: true, qty: 0.001, converted: true });
|
||||
expect(convertQty(12345, "kg", "ton")).toEqual({ ok: true, qty: 12.345, converted: true });
|
||||
});
|
||||
|
||||
it("fails on an unknown unit rather than assuming a match", () => {
|
||||
expect(convertQty(10, null, "ton")).toEqual({ ok: false, reason: "unknown_unit" });
|
||||
expect(convertQty(10, "kg", null)).toEqual({ ok: false, reason: "unknown_unit" });
|
||||
expect(convertQty(10, null, null)).toEqual({ ok: false, reason: "unknown_unit" });
|
||||
});
|
||||
|
||||
it("fails on a pair that is not in the table", () => {
|
||||
expect(convertQty(10, "kg", "m3")).toEqual({ ok: false, reason: "incompatible_units" });
|
||||
expect(convertQty(10, "ea", "bag")).toEqual({ ok: false, reason: "incompatible_units" });
|
||||
expect(convertQty(10, "m2", "m")).toEqual({ ok: false, reason: "incompatible_units" });
|
||||
});
|
||||
|
||||
it("accepts raw aliases, not just canonical tokens", () => {
|
||||
expect(convertQty(1000, "Kgs", "tonnes")).toEqual({
|
||||
ok: true,
|
||||
qty: 1,
|
||||
converted: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
89
src/lib/units.ts
Normal file
89
src/lib/units.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Unit normalisation and conversion for delivery note quantities.
|
||||
*
|
||||
* Delivery notes are weighbridge tickets quoting kg; the purchase order
|
||||
* lines they belong to are priced per ton. Writing the slip's number
|
||||
* against a ton-priced line inflates received value 1000x, which is
|
||||
* exactly what happened before this module existed.
|
||||
*
|
||||
* The conversion table is deliberately closed. An unrecognised unit or
|
||||
* an unlisted pair returns a failure — callers must surface that as
|
||||
* work for a human, never fall back to "assume they match".
|
||||
*/
|
||||
|
||||
const ALIASES: Record<string, readonly string[]> = {
|
||||
kg: ["kg", "kgs", "kilo", "kilos", "kilogram", "kilograms"],
|
||||
ton: ["t", "ton", "tons", "tonne", "tonnes", "mt", "metric ton"],
|
||||
m3: ["m3", "m³", "cube", "cubes", "cubic metre", "cubic meter"],
|
||||
m2: ["m2", "m²", "square metre", "square meter"],
|
||||
m: ["m", "metre", "meter", "metres", "meters", "lm", "linear metre"],
|
||||
ea: ["ea", "each", "unit", "units", "no", "nr", "item", "items"],
|
||||
bag: ["bag", "bags", "pocket", "pockets"],
|
||||
l: ["l", "litre", "litres", "liter", "liters"],
|
||||
load: ["load", "loads", "trip", "trips"],
|
||||
};
|
||||
|
||||
const LOOKUP: Map<string, string> = new Map();
|
||||
for (const [canonical, aliases] of Object.entries(ALIASES)) {
|
||||
for (const alias of aliases) LOOKUP.set(alias, canonical);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical token for a unit as written on a slip or PO line, or null
|
||||
* when we do not recognise it. Null is a real answer, not an error —
|
||||
* callers must treat it as "cannot convert".
|
||||
*/
|
||||
export function normaliseUnit(raw: string | null | undefined): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
// Keep the superscript characters in m² / m³; strip the punctuation
|
||||
// OCR sprinkles around units ("kg.", "(ton)").
|
||||
const cleaned = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9²³ ]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!cleaned) return null;
|
||||
return LOOKUP.get(cleaned) ?? null;
|
||||
}
|
||||
|
||||
export type UnitConversion =
|
||||
| { ok: true; qty: number; converted: boolean }
|
||||
| { ok: false; reason: "unknown_unit" | "incompatible_units" };
|
||||
|
||||
/**
|
||||
* The complete set of conversions. Anything absent here fails.
|
||||
* Keyed "<from>>><to>" on canonical tokens.
|
||||
*/
|
||||
const FACTORS: Record<string, number> = {
|
||||
"kg>>>ton": 1 / 1000,
|
||||
"ton>>>kg": 1000,
|
||||
"l>>>m3": 1 / 1000,
|
||||
"m3>>>l": 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert `qty` from one unit into another. Accepts raw slip/PO text on
|
||||
* either side — it normalises both before looking anything up.
|
||||
*
|
||||
* A null on either side is `unknown_unit`, never "assume they match":
|
||||
* assuming is what made every received value 1000x too large.
|
||||
*/
|
||||
export function convertQty(
|
||||
qty: number,
|
||||
fromUnit: string | null,
|
||||
toUnit: string | null,
|
||||
): UnitConversion {
|
||||
const from = normaliseUnit(fromUnit);
|
||||
const to = normaliseUnit(toUnit);
|
||||
if (from === null || to === null) return { ok: false, reason: "unknown_unit" };
|
||||
if (from === to) return { ok: true, qty, converted: false };
|
||||
|
||||
const factor = FACTORS[`${from}>>>${to}`];
|
||||
if (factor === undefined) return { ok: false, reason: "incompatible_units" };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
qty: Math.round(qty * factor * 10000) / 10000,
|
||||
converted: true,
|
||||
};
|
||||
}
|
||||
26
src/lib/utils.test.ts
Normal file
26
src/lib/utils.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { formatNumber, formatQtyDual } from "./utils";
|
||||
|
||||
describe("formatQtyDual", () => {
|
||||
it("shows both figures when a conversion happened", () => {
|
||||
expect(formatQtyDual(29.96, "ton", 29960, "kg")).toBe(
|
||||
`${formatNumber(29.96)} ton · ${formatNumber(29960)} kg`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows one figure when the units match", () => {
|
||||
expect(formatQtyDual(5, "ton", 5, "ton")).toBe("5 ton");
|
||||
});
|
||||
|
||||
it("shows one figure when the slip recorded no unit", () => {
|
||||
expect(formatQtyDual(5, "ton", 5, "")).toBe("5 ton");
|
||||
});
|
||||
|
||||
it("omits the unit entirely when the PO line has none", () => {
|
||||
expect(formatQtyDual(5, "", 5, "")).toBe("5");
|
||||
});
|
||||
|
||||
it("falls back to the raw figure when nothing converted", () => {
|
||||
expect(formatQtyDual(0, "ton", 12, "m3")).toBe("0 ton · 12 m3");
|
||||
});
|
||||
});
|
||||
|
|
@ -50,3 +50,23 @@ export function isUnknownSupplier(supplier: string | undefined | null): boolean
|
|||
if (!supplier) return true;
|
||||
return UNKNOWN_SUPPLIER_ALIASES.has(supplier.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* A received quantity, shown in the purchase order line's unit with the
|
||||
* slip's own figure beside it.
|
||||
*
|
||||
* Site staff read the slip in kg; the PO is priced per ton. Showing only
|
||||
* one of the two means somebody eventually reconciles the wrong number
|
||||
* against the wrong document.
|
||||
*/
|
||||
export function formatQtyDual(
|
||||
qtyReceived: number,
|
||||
unit: string,
|
||||
qtyRaw: number,
|
||||
unitRaw: string,
|
||||
): string {
|
||||
const primary = unit ? `${formatNumber(qtyReceived)} ${unit}` : formatNumber(qtyReceived);
|
||||
const sameUnit = !unitRaw || unitRaw.trim().toLowerCase() === unit.trim().toLowerCase();
|
||||
if (sameUnit) return primary;
|
||||
return `${primary} · ${formatNumber(qtyRaw)} ${unitRaw}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue