TWO bugs, one of which was hiding the other.
1. "No signed copy on file" for a note that had just been signed,
scanned and uploaded. The upload was never the problem — the READ
path was. `delivery_notes.signed_copy` is a PB file field with
`maxSelect: 1`: we WRITE it as an array (`signed_copy: [file]`),
but PocketBase RETURNS A BARE STRING when exactly one file is
attached. Verified on the live record after a real upload:
typeof : string
Array.isArray : false
value : "image_ar52u2hjfq.jpg"
value[0] : "i" <-- indexing yields a CHARACTER
Every read site assumed `string[]`, and not one of them threw:
- signed-copy GET: `Array.isArray(x) ? x[0] : null` returned null
for notes that demonstrably had a copy — the reported error
- delivery-note detail tooltip: `signed_copy[0]` rendered the
first LETTER of the filename
- delivery-notes-service: treated every signed copy as absent, so
the quote went out with the UNSIGNED pdf attached
- deliveries-service: reported an empty filename
- deliveries page: `.join(", ")` on a string — this one THREW,
on a page people open daily
Adds `src/lib/file-fields.ts` (firstFileName / fileNames / hasFile)
and routes all of them through it. The type was the root of the
wrong assumptions, so it is now `string | string[]` with a comment
pointing at the helper.
2. QR 28pt -> 52pt, moved to the top-right corner. Measured by
rasterising the real PDF at 200 dpi and decoding: it now reads at
1.24 px/module, where the old size needed ~2.5 — roughly double the
distance and angle tolerance.
It could not simply go at top:40: the logo is an absolute 160x100pt
block in the same corner, so the QR landed on top of it. That is
not only ugly, it breaks the QR's quiet zone — logo ink inside the
light margin makes the code refuse to decode. The logo now steps
left (right: 112 -> 106, width 160 -> 132) to give the QR the true
corner, and the company name is width-bounded ONLY when a logo is
present. The Westscott letterhead has no matching .svg in
public/letterhead/, so in practice the name still gets the full
width and stays on one line.
Nine tests pin the file-field shapes, including one that asserts the
old `.join(", ")` call really does throw on a string, so the string
case cannot be quietly dropped again.
560 lines
21 KiB
TypeScript
560 lines
21 KiB
TypeScript
import { Document, Image, Page, Rect, StyleSheet, Svg, Text, View } from "@react-pdf/renderer";
|
|
|
|
import type { Letterhead } from "@/lib/letterhead";
|
|
import { defaultAccentFor } from "@/lib/letterhead";
|
|
import { formatDateLong } from "./format";
|
|
import type { QrMatrix } from "./qr";
|
|
|
|
export type DeliveryNoteDocumentHeader = {
|
|
/** Server-generated delivery note number, e.g. "DN26071401". */
|
|
deliveryNoteNumber: string;
|
|
/** ISO timestamp the DN was issued; rendered as a long-form date. */
|
|
dateIso: string;
|
|
orderNumber: string;
|
|
companyName: string;
|
|
customerName: string;
|
|
};
|
|
|
|
export type DeliveryNoteDocumentLine = {
|
|
id: string;
|
|
description: string;
|
|
/** Quantity committed for delivery (qtyCollected → orderQty → 1). */
|
|
qty: number;
|
|
};
|
|
|
|
export type DeliveryNoteDocumentProps = {
|
|
header: DeliveryNoteDocumentHeader;
|
|
lines: DeliveryNoteDocumentLine[];
|
|
letterhead: Letterhead;
|
|
/**
|
|
* Logo as a `data:image/svg+xml;base64,...` URI.
|
|
*
|
|
* The SVG here is because `public/letterhead/*.svg` IS an SVG source
|
|
* file (see `loadLetterheadLogoDataUri`), not because raster data
|
|
* needs wrapping. An earlier version of this comment claimed
|
|
* "@react-pdf v4.5.1 corrupts every byte >= 0x80 in raster image
|
|
* data" and recommended wrapping rasters in `<svg><image>`. That
|
|
* claim was wrong, and the workaround is actively harmful: a PNG
|
|
* wrapped in `<svg><image>` renders as an EMPTY box with no error,
|
|
* while the same PNG passed directly draws correctly. See the note
|
|
* at the top of `render.tsx` for what was actually going on.
|
|
*/
|
|
logoDataUri: string | null;
|
|
/**
|
|
* Captured signature as a `data:image/png;base64,...` URI, drawn
|
|
* into the "Received By" block. Null/absent leaves the blank rule —
|
|
* so every existing caller is unaffected.
|
|
*
|
|
* PNG, passed directly. Verified by rendering all three encodings
|
|
* through this exact document: a base64 PNG data URI draws
|
|
* correctly, a pure-vector SVG draws correctly, and a PNG *wrapped
|
|
* inside* `<svg><image>` renders as an empty box with no error.
|
|
*/
|
|
signatureImage?: string | null;
|
|
/**
|
|
* QR code drawn as vector modules, bottom-right of the page.
|
|
* Null/absent omits it entirely.
|
|
*
|
|
* Carries the delivery note number so a scanner app can resolve the
|
|
* note. See `src/lib/pdf/qr.ts` for the security note — the value
|
|
* is a sequential, enumerable number and this is only appropriate
|
|
* for a closed internal consumer.
|
|
*/
|
|
qr?: QrMatrix | null;
|
|
};
|
|
|
|
/**
|
|
* Physical size of the printed QR, in points.
|
|
*
|
|
* Was 28pt and sat in the bottom-right corner; the operator found it
|
|
* fiddly to line up on a phone. 52pt across a 21-module code plus the
|
|
* 4-module quiet zone is ~1.8pt (~0.63mm) per module including
|
|
* margin — comfortably above the ~0.5mm consumer scanners want, and
|
|
* roughly double the on-page width so it is easy to frame from arm's
|
|
* length. It moves to the top-right corner for the same reason: that
|
|
* is where you look first, and the bottom-right was competing with
|
|
* the signature block.
|
|
*/
|
|
const QR_SIZE_PT = 52;
|
|
|
|
/**
|
|
* Renders a printable delivery note PDF. Same visual language as the
|
|
* quote + PO documents (letterhead top-left, logo top-right, big
|
|
* title, two-column meta block, accent-coloured items table) — only
|
|
* the document title and the meta-block labels change. The picking
|
|
* slip that accompanies the DN is rendered separately and merged
|
|
* into the print bundle by the print route.
|
|
*
|
|
* Render with `renderDeliveryNotePdf` in `src/lib/pdf/render.tsx`.
|
|
*/
|
|
export function DeliveryNoteDocument({
|
|
header,
|
|
lines,
|
|
letterhead,
|
|
logoDataUri,
|
|
signatureImage = null,
|
|
qr = null,
|
|
}: DeliveryNoteDocumentProps) {
|
|
const accent =
|
|
letterhead.accent_color ||
|
|
defaultAccentFor(
|
|
letterhead.display_name?.toUpperCase().includes("CNL") ? "CNL" : "ISHERWOOD",
|
|
);
|
|
const totalQty = lines.reduce((sum, line) => sum + (Number.isFinite(line.qty) ? line.qty : 0), 0);
|
|
|
|
return (
|
|
<Document
|
|
title={`Delivery Note ${header.deliveryNoteNumber}`}
|
|
author={letterhead.display_name}
|
|
subject={`Delivery Note for ${header.companyName}`}
|
|
>
|
|
<Page size="A4" style={styles.page}>
|
|
{/* Header — company info on the left, logo floating on the
|
|
right. Same absolutely-positioned logo pattern as the
|
|
quote + PO documents: @react-pdf's flexbox doesn't
|
|
reliably place Images inside `flexDirection: row`, so we
|
|
use position: absolute with explicit top/right instead. */}
|
|
<View style={styles.headerCompany}>
|
|
{/* Bounded only when a logo is present. The logo's left edge
|
|
sits at 595 - (106 + 132) = 357pt and the name starts at
|
|
the 40pt margin, so 305pt leaves a clear gap. With no
|
|
logo — which is the case for the Westscott letterhead,
|
|
since public/letterhead/ has no matching .svg — the name
|
|
gets the full width and stays on one line. */}
|
|
<Text
|
|
style={
|
|
logoDataUri
|
|
? [styles.companyName, { width: 305 }]
|
|
: styles.companyName
|
|
}
|
|
>
|
|
{letterhead.display_name} (Pty) Ltd
|
|
</Text>
|
|
<Text style={styles.body}>{letterhead.address}</Text>
|
|
<Text style={styles.body}>{letterhead.phone}</Text>
|
|
<Text style={styles.body}>{letterhead.email}</Text>
|
|
<Text style={styles.body}>{`VAT No: ${letterhead.vat_number}`}</Text>
|
|
{letterhead.registration_number ? (
|
|
<Text style={styles.body}>{`Business ID No: ${letterhead.registration_number}`}</Text>
|
|
) : null}
|
|
</View>
|
|
{logoDataUri ? (
|
|
<View style={styles.logoAbsolute}>
|
|
<Image src={logoDataUri} style={styles.logo} />
|
|
</View>
|
|
) : null}
|
|
|
|
{/* DELIVERY NOTE heading — same type scale as the quote
|
|
("QUOTATION") and PO ("PURCHASE ORDER") so the three
|
|
documents feel like siblings when filed side-by-side. */}
|
|
<Text style={styles.title}>DELIVERY NOTE</Text>
|
|
|
|
{/* DELIVER TO + DN meta two-column row. Left = recipient
|
|
(the company we're delivering to + the customer +
|
|
order number for cross-reference). Right = DN metadata
|
|
(number + date). No "Reference" / "Valid until" lines —
|
|
those don't apply to a delivery note. */}
|
|
<View style={styles.metaRow}>
|
|
<View style={styles.metaCol}>
|
|
<Text style={styles.bold}>DELIVER TO:</Text>
|
|
<Text style={styles.body}>{header.companyName || "(no company)"}</Text>
|
|
<Text style={styles.body}>
|
|
{header.customerName ? `Attention: ${header.customerName}` : "(no customer)"}
|
|
</Text>
|
|
<Text style={styles.body}>
|
|
{header.orderNumber ? `Order: ${header.orderNumber}` : "(no order number)"}
|
|
</Text>
|
|
</View>
|
|
<View style={[styles.metaCol, styles.metaColRight]}>
|
|
<Text style={styles.metaLine}>
|
|
<Text style={styles.bold}>Delivery Note No: </Text>
|
|
{header.deliveryNoteNumber}
|
|
</Text>
|
|
<Text style={styles.metaLine}>
|
|
<Text style={styles.bold}>Date: </Text>
|
|
{formatDateLong(header.dateIso)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Items table — same accent header + alternating-row
|
|
treatment as the quote + PO. Column shape is
|
|
# / Description / Qty. No prices / supplier details / line
|
|
totals — a delivery note doesn't carry per-line money
|
|
the way a quote/PO does; the operator hands this to the
|
|
driver and the customer signs on receipt. */}
|
|
<View style={styles.table}>
|
|
<View style={[styles.tableHeaderRow, { backgroundColor: accent }]}>
|
|
<Text style={[styles.tableHeaderCell, styles.colIndex]}>No.</Text>
|
|
<Text style={[styles.tableHeaderCell, styles.colDescription]}>Description</Text>
|
|
<Text style={[styles.tableHeaderCell, styles.colQty, styles.alignRight]}>Qty</Text>
|
|
</View>
|
|
{lines.map((line, idx) => (
|
|
<View
|
|
key={line.id || idx}
|
|
style={[styles.tableRow, ...(idx % 2 === 1 ? [styles.tableRowAlt] : [])]}
|
|
>
|
|
<Text style={[styles.tableCell, styles.colIndex]}>{idx + 1}</Text>
|
|
<Text style={[styles.tableCell, styles.colDescription]}>
|
|
{line.description || "(no description)"}
|
|
</Text>
|
|
<Text style={[styles.tableCell, styles.colQty, styles.alignRight]}>{line.qty}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
{/* Total — right-aligned, smaller than the quote "Total:" line
|
|
(this is qty sum, not money). Kept on the right so the
|
|
operator's eye lands on it the same way it does on a
|
|
quote/PO. */}
|
|
<View style={styles.totals}>
|
|
<View style={styles.totalRule} />
|
|
<View style={styles.totalLine}>
|
|
<Text style={styles.totalFinalLabel}>Total Items:</Text>
|
|
<Text style={styles.totalFinalValue}>{totalQty}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Signature block — two columns (received / delivered)
|
|
laid out side-by-side so the printout is one tidy
|
|
section. Each row has its own signature line + a date
|
|
sub-line. Flows inline below the items table; if the
|
|
table runs to a new page the block follows it. The
|
|
picking slip is no longer stacked inside this document
|
|
(it's rendered separately by the print route), so we
|
|
don't need the absolute / fixed positioning that
|
|
used to keep it pinned to the bottom of every page —
|
|
that caused it to overlap the last few items when the
|
|
table filled most of the page. */}
|
|
<View style={styles.signatureSection}>
|
|
<View style={styles.signatureRow}>
|
|
<View style={styles.signatureCol}>
|
|
<Text style={styles.signatureTitle}>Received By</Text>
|
|
{/* Drawn signature, when one was captured. Replaces the
|
|
blank rule rather than sitting on it — a blank line
|
|
under a signature reads as "unsigned" at a glance,
|
|
which is the opposite of what this block is for. */}
|
|
{signatureImage ? (
|
|
<>
|
|
<Image style={styles.signatureImage} src={signatureImage} />
|
|
<View style={styles.signatureRule} />
|
|
</>
|
|
) : (
|
|
<View style={styles.signatureLine} />
|
|
)}
|
|
<Text style={styles.signatureLabel}>Name & signature</Text>
|
|
<View style={styles.signatureLineShort} />
|
|
<Text style={styles.signatureLabel}>Date</Text>
|
|
</View>
|
|
<View style={styles.signatureCol}>
|
|
<Text style={styles.signatureTitle}>Delivered By</Text>
|
|
<View style={styles.signatureLine} />
|
|
<Text style={styles.signatureLabel}>Driver name & signature</Text>
|
|
<View style={styles.signatureLineShort} />
|
|
<Text style={styles.signatureLabel}>Vehicle / Reg</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Footer text from the letterhead (e.g. "Thank you for your
|
|
business."). Same as the quote + PO — keeps the family
|
|
of documents visually consistent. */}
|
|
{letterhead.footer_text ? (
|
|
<Text style={styles.footerText}>{letterhead.footer_text}</Text>
|
|
) : null}
|
|
|
|
{/* QR, bottom-right. Vector modules, not an image — see
|
|
`src/lib/pdf/qr.ts` for why that matters. The quiet zone is
|
|
baked into the Svg viewBox so scanners see the light margin
|
|
the spec requires even though the surrounding page has the
|
|
accent-coloured table nearby. */}
|
|
{qr ? (
|
|
<View style={styles.qrBlock}>
|
|
<Svg
|
|
width={QR_SIZE_PT}
|
|
height={QR_SIZE_PT}
|
|
viewBox={`0 0 ${qr.size + qr.quietZone * 2} ${qr.size + qr.quietZone * 2}`}
|
|
>
|
|
<Rect
|
|
x={0}
|
|
y={0}
|
|
width={qr.size + qr.quietZone * 2}
|
|
height={qr.size + qr.quietZone * 2}
|
|
fill="#ffffff"
|
|
/>
|
|
{qr.modules.map((dark, i) =>
|
|
dark ? (
|
|
<Rect
|
|
key={i}
|
|
x={(i % qr.size) + qr.quietZone}
|
|
y={Math.floor(i / qr.size) + qr.quietZone}
|
|
width={1}
|
|
height={1}
|
|
fill="#000000"
|
|
/>
|
|
) : null,
|
|
)}
|
|
</Svg>
|
|
<Text style={styles.qrLabel}>Scan for delivery note</Text>
|
|
</View>
|
|
) : null}
|
|
</Page>
|
|
</Document>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
page: {
|
|
paddingTop: 40,
|
|
paddingHorizontal: 40,
|
|
paddingBottom: 96,
|
|
fontFamily: "Helvetica",
|
|
fontSize: 10,
|
|
color: "#222",
|
|
},
|
|
headerCompany: {
|
|
marginBottom: 6,
|
|
},
|
|
companyName: { fontSize: 15, fontFamily: "Helvetica-Bold", marginBottom: 6 },
|
|
body: { fontSize: 10, marginBottom: 2 },
|
|
bold: { fontFamily: "Helvetica-Bold" },
|
|
/* Steps LEFT to make room for the QR in the top-right corner:
|
|
40 (margin) + 52 (QR) + 14 (gap). The QR has to own the corner —
|
|
it is what the phone camera is aimed at, so it should be the first
|
|
thing found rather than something you have to hunt for beside the
|
|
logo. The company name is unaffected: it ends well left of 215. */
|
|
logoAbsolute: {
|
|
position: "absolute",
|
|
top: 46,
|
|
right: 106,
|
|
},
|
|
logo: { width: 132, height: 83 },
|
|
title: {
|
|
fontSize: 22,
|
|
fontFamily: "Helvetica-Bold",
|
|
marginTop: 24,
|
|
marginBottom: 18,
|
|
letterSpacing: 0.5,
|
|
},
|
|
metaRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
marginBottom: 18,
|
|
},
|
|
metaCol: { flex: 1, paddingRight: 12 },
|
|
metaColRight: { alignItems: "flex-end" },
|
|
metaLine: { fontSize: 10, marginBottom: 4 },
|
|
table: {
|
|
width: "100%",
|
|
borderTopWidth: 0.5,
|
|
borderTopColor: "#cccccc",
|
|
},
|
|
tableHeaderRow: {
|
|
flexDirection: "row",
|
|
paddingVertical: 6,
|
|
paddingHorizontal: 6,
|
|
},
|
|
tableHeaderCell: {
|
|
color: "#ffffff",
|
|
fontFamily: "Helvetica-Bold",
|
|
fontSize: 10,
|
|
},
|
|
tableRow: {
|
|
flexDirection: "row",
|
|
paddingVertical: 6,
|
|
paddingHorizontal: 6,
|
|
borderBottomWidth: 0.5,
|
|
borderBottomColor: "#dddddd",
|
|
},
|
|
tableRowAlt: { backgroundColor: "#fafafa" },
|
|
tableCell: { fontSize: 10 },
|
|
// No. column — narrow, right-aligned-friendly. Description gets
|
|
// the lion's share. Qty is narrow, right-aligned.
|
|
colIndex: { flex: 0.5 },
|
|
colDescription: { flex: 7.3 },
|
|
colQty: { flex: 0.7 },
|
|
alignRight: { textAlign: "right" },
|
|
totals: {
|
|
alignSelf: "flex-end",
|
|
marginTop: 10,
|
|
minWidth: 220,
|
|
},
|
|
totalLine: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
paddingVertical: 3,
|
|
},
|
|
totalFinalLabel: { fontFamily: "Helvetica-Bold", fontSize: 12 },
|
|
totalFinalValue: { fontFamily: "Helvetica-Bold", fontSize: 12 },
|
|
totalRule: {
|
|
borderTopWidth: 0.8,
|
|
borderTopColor: "#222",
|
|
marginVertical: 4,
|
|
},
|
|
signatureSection: {
|
|
// Inline flow — sits below the items table with some
|
|
// breathing room. Was previously position: absolute /
|
|
// bottom: 40 to pin to the page footer, but that pinned
|
|
// it on TOP of the last items when the table filled most
|
|
// of the page (the visual overlap you saw). The picking
|
|
// slip used to be stacked under this in the same doc and
|
|
// needed the pinned footer; now that the picking slip is
|
|
// rendered separately by the print route, inline flow is
|
|
// correct.
|
|
marginTop: 24,
|
|
},
|
|
signatureRow: { flexDirection: "row", gap: 24 },
|
|
signatureCol: { flex: 1 },
|
|
signatureTitle: {
|
|
fontFamily: "Helvetica-Bold",
|
|
fontSize: 10,
|
|
marginBottom: 16,
|
|
},
|
|
signatureLine: {
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#222",
|
|
height: 22,
|
|
marginBottom: 2,
|
|
},
|
|
/* Signature sits ON the rule rather than above it — the captured
|
|
PNG is transparent-background, so the line reads as the baseline
|
|
it was drawn against. Fixed height so a short scribble and a
|
|
long one both leave the same "Name & signature" position, which
|
|
keeps both columns aligned. */
|
|
/* The captured PNG is transparent-background, so the rule reads as
|
|
the baseline it was drawn against. Height is fixed so a short
|
|
scribble and a long one leave "Name & signature" in exactly the
|
|
same place — the two columns stay aligned and the page doesn't
|
|
reflow when one is signed and the other isn't. */
|
|
signatureImage: { width: 130, height: 22 },
|
|
/* Zero-height rule for the signed case: the image supplies the 22pt
|
|
of vertical space and this draws only the 1pt baseline directly
|
|
under it. A full-height `signatureLine` here would leave the
|
|
signature floating 22pt above the line it belongs on. */
|
|
signatureRule: {
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#222",
|
|
height: 0,
|
|
marginBottom: 2,
|
|
},
|
|
signatureLineShort: {
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#222",
|
|
height: 18,
|
|
marginTop: 6,
|
|
marginBottom: 2,
|
|
},
|
|
signatureLabel: { fontSize: 8, color: "#64748b", marginBottom: 0 },
|
|
/* QR sits absolute in the bottom-right of the page body, above the
|
|
absolutely-positioned footer text. 28pt on a 21-module code is
|
|
~0.96pt per module plus the 4-module quiet zone — comfortably
|
|
above the ~0.5mm/module that consumer scanners need, while
|
|
staying small enough not to crowd the signature block. */
|
|
/* Sits directly BELOW the logo, not on it. The logo is an absolute
|
|
160x100pt block at top:40, so 40 + 100 + 14 = 154 clears it. An
|
|
earlier attempt at top:40 put the QR straight over the logo —
|
|
which not only looks wrong, it breaks the QR's quiet zone: the
|
|
logo's ink sits inside the light margin the spec requires, and
|
|
the code will refuse to decode. */
|
|
qrBlock: {
|
|
position: "absolute",
|
|
top: 40,
|
|
right: 40,
|
|
alignItems: "center",
|
|
},
|
|
qrLabel: {
|
|
fontSize: 7,
|
|
color: "#64748b",
|
|
marginTop: 3,
|
|
letterSpacing: 0.4,
|
|
},
|
|
footerText: {
|
|
fontSize: 8,
|
|
color: "#94a3b8",
|
|
textAlign: "center",
|
|
position: "absolute",
|
|
bottom: 16,
|
|
left: 40,
|
|
right: 40,
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Resolve the quantity for a delivery-note line. The fallback
|
|
* chain is `quantity → qtyCollected → orderQty → 1` so the
|
|
* printed document always shows a meaningful number.
|
|
*
|
|
* `quantity` (the operator-edited value on the detail page)
|
|
* wins — that's what the printed DN should reflect. Without
|
|
* this preference the qty editor is a no-op for the printed
|
|
* document: an operator edits a line to 6, hits save, and
|
|
* the PDF still shows whatever `qtyCollected` / `orderQty`
|
|
* says (often 1). Each level of the chain treats `0` as
|
|
* "unset" and falls through, because:
|
|
* - `quantity = 0` would mean "delivering nothing",
|
|
* which is nonsensical for a delivery note that's
|
|
* about to be printed + handed to a driver;
|
|
* - `qtyCollected = 0` usually means "supplier hasn't
|
|
* picked this yet" rather than "we picked zero";
|
|
* - `orderQty = 0` shouldn't happen (operator-edited),
|
|
* but defensively we fall through rather than print
|
|
* "deliver 0 of this thing".
|
|
*/
|
|
/**
|
|
* Build one delivery-note line from a raw `order_items` row.
|
|
*
|
|
* Every renderer of DN lines must go through this. It exists because the
|
|
* four call sites that used to build lines by hand did not agree: two
|
|
* passed `quantity` to `resolveDeliveryNoteLineQty` and two forgot it, so
|
|
* the same delivery note printed the customer's 4 through the service
|
|
* layer and the supplier's 10 through the API routes. Taking the whole
|
|
* row instead of hand-picked arguments removes that failure mode.
|
|
*
|
|
* See `docs/superpowers/specs/2026-08-30-quantity-handling-audit.md`.
|
|
*/
|
|
export function buildDeliveryNoteLine(item: {
|
|
id: string;
|
|
description?: string | null;
|
|
quantity?: number | null;
|
|
order_qty?: number | null;
|
|
qty_collected?: number | null;
|
|
}): { id: string; description: string; qty: number } {
|
|
return {
|
|
id: item.id,
|
|
description: (item.description ?? "").trim() || "(no description)",
|
|
qty: resolveDeliveryNoteLineQty({
|
|
quantity: item.quantity,
|
|
orderQty: item.order_qty,
|
|
qtyCollected: item.qty_collected,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function resolveDeliveryNoteLineQty(input: {
|
|
quantity?: number | null;
|
|
orderQty?: number | null;
|
|
qtyCollected?: number | null;
|
|
}): number {
|
|
if (
|
|
typeof input.quantity === "number" &&
|
|
Number.isFinite(input.quantity) &&
|
|
input.quantity > 0
|
|
) {
|
|
return input.quantity;
|
|
}
|
|
if (
|
|
typeof input.qtyCollected === "number" &&
|
|
Number.isFinite(input.qtyCollected) &&
|
|
input.qtyCollected > 0
|
|
) {
|
|
return input.qtyCollected;
|
|
}
|
|
if (
|
|
typeof input.orderQty === "number" &&
|
|
Number.isFinite(input.orderQty) &&
|
|
input.orderQty > 0
|
|
) {
|
|
return input.orderQty;
|
|
}
|
|
return 1;
|
|
}
|