Files
threadcount-community/lib/orderdoc.ts
T
ThreadCount 0910bc32c1 ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-16 07:57:54 +10:00

80 lines
5.5 KiB
TypeScript

/* One purchase order, loaded the way the sheet, the email and the CSV all need it: the supplier's
* own product codes against each line, a unit cost, the facility's delivery details, and the
* person it was ordered for when it was. Server only — reads the tables directly. */
import { prisma } from "./db";
import { garmentGroups, groupsLabel, label, money, fmtDate } from "./compute";
export type OrderDocLine = { code: string; description: string; size: string; qty: number; unit: number; total: number };
export type OrderDoc = {
order: { id: string; code: string; date: string; status: string; ref: string; expected: string; notes: string; cc: string; emailedAt: Date | null; printedAt: Date | null };
facility: { name: string; location: string; coordinator: string; coordinatorEmail: string; coordinatorPhone: string; slipOrg: string };
supplier: { name: string; contact: string; phone: string; account: string; email: string; lead: number | null };
staff: { name: string; dept: string; cc: string } | null;
lines: OrderDocLine[];
total: number;
};
export async function loadOrderDoc(facilityId: string, orderId: string): Promise<OrderDoc> {
const o = await prisma.order.findFirstOrThrow({
where: { id: orderId, facilityId },
include: { lines: { orderBy: { sort: "asc" }, include: { item: true } }, staff: { select: { first: true, last: true, dept: true, ccOverride: true } } },
});
const [fac, sup, levels] = await Promise.all([
prisma.facility.findUniqueOrThrow({ where: { id: facilityId }, select: { name: true, location: true, coordinator: true, coordinatorEmail: true, coordinatorPhone: true, slipOrg: true } }),
prisma.supplier.findFirst({ where: { facilityId, name: o.supplier } }),
prisma.stockLevel.findMany({ where: { itemId: { in: o.lines.map((l) => l.itemId) } }, select: { itemId: true, sizeIndex: true, supplierCode: true } }),
]);
const codeOf = (itemId: string, si: number) => levels.find((l) => l.itemId === itemId && l.sizeIndex === si)?.supplierCode || "";
const lines: OrderDocLine[] = o.lines.map((l) => {
const it = l.item;
const si = it.sizes.map(String).indexOf(l.size);
const item = { id: it.id, sort: it.sort, item: it.item, gender: it.gender, type: it.type, sku: it.sku, supplier: it.supplier, cost: it.cost, groups: garmentGroups(it.groups), group: groupsLabel(it.groups), notes: it.notes, sizes: it.sizes, archived: it.archived };
const unit = it.cost || 0;
return { code: codeOf(it.id, si) || it.sku || "", description: label(item), size: l.size, qty: l.qty, unit, total: unit * l.qty };
});
return {
order: { id: o.id, code: o.code, date: o.date, status: o.status, ref: o.ref, expected: o.expected, notes: o.notes, cc: o.cc, emailedAt: o.emailedAt, printedAt: o.printedAt },
facility: fac,
supplier: { name: o.supplier, contact: sup?.contact || "", phone: sup?.phone || "", account: sup?.account || "", email: sup?.email || "", lead: sup?.lead ?? null },
staff: o.staff ? { name: `${o.staff.first} ${o.staff.last}`.trim(), dept: o.staff.dept, cc: o.cc || o.staff.ccOverride || "" } : null,
lines,
total: lines.reduce((t, l) => t + l.total, 0),
};
}
/** The email a supplier receives: the same figures as the printed sheet, in the site's mail layout. */
export function supplierOrderEmail(d: OrderDoc): { subject: string; text: string; html: string } {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { layout } = require("./mail-html.cjs") as { layout: (o: Record<string, unknown>) => string };
const refBit = d.order.ref ? ` (${d.order.ref})` : "";
const subject = `Purchase order ${d.order.code}${refBit}${d.facility.name}`;
const rows: [string, string][] = [
["Order no.", d.order.code + refBit],
["Date", fmtDate(d.order.date)],
...(d.supplier.account ? [["Account", d.supplier.account] as [string, string]] : []),
["Deliver to", `${d.facility.name} · ${d.facility.location}`],
...(d.staff ? [["Ordered for", `${d.staff.name}${d.staff.dept ? ` · ${d.staff.dept}` : ""}${d.staff.cc ? ` · cost centre ${d.staff.cc}` : ""}`] as [string, string]] : []),
...(d.order.expected ? [["Expected", fmtDate(d.order.expected)] as [string, string]] : []),
];
const textLines = d.lines.map((l) => ` ${l.code || "(no code)"} ${l.description} · ${l.size} · qty ${l.qty}`);
const text = [
`Hello ${d.supplier.contact || d.supplier.name},`,
`Please supply the following to ${d.facility.name}, ${d.facility.location}.`,
rows.map(([k, v]) => `${k}: ${v}`).join("\n"),
textLines.join("\n"),
`Please quote order ${d.order.code} on the invoice.`,
d.facility.coordinator ? `${d.facility.coordinator}${d.facility.coordinatorPhone ? ` · ${d.facility.coordinatorPhone}` : ""}\n${d.facility.name}` : d.facility.name,
].join("\n\n");
const html = layout({
eyebrow: "Purchase order",
title: `Order ${d.order.code}${refBit}`,
preheader: `${d.facility.name}${d.lines.length} line${d.lines.length === 1 ? "" : "s"}`,
intro: [`Hello ${d.supplier.contact || d.supplier.name},`, `Please supply the following to ${d.facility.name}, ${d.facility.location}.`],
rows,
list: d.lines.map((l) => `${l.code || "no code"} · ${l.description} · ${l.size} · qty ${l.qty}`),
closing: [`Please quote order ${d.order.code} on the invoice.${d.total ? ` Estimated value ${money(d.total)} ex tax at our last known unit costs.` : ""}`],
footer: { facility: d.facility.name, contact: d.facility.coordinatorEmail || "", links: [] },
});
return { subject, text, html };
}