1bc2de655a
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
268 lines
23 KiB
TypeScript
268 lines
23 KiB
TypeScript
// Public working demo: one fictional facility ("Riverside General Hospital") that anyone can enter
|
|
// as Admin or Issuer without signing up. All visitors share it; a timer resets it every 20 minutes.
|
|
// Everything is seeded through the real ops layer so the ledger, drafts, pickups and reports are
|
|
// exactly what the app would have produced. Nothing here is real facility data.
|
|
import { randomBytes } from "crypto";
|
|
import bcrypt from "bcryptjs";
|
|
import { prisma } from "./db";
|
|
import { runOp } from "./ops";
|
|
import type { SessionUser } from "./session";
|
|
import { addDays, facilityToday } from "./compute";
|
|
import { deletePhotoDir } from "./photostore";
|
|
|
|
export const DEMO_FACILITY = "Riverside General Hospital";
|
|
export const DEMO_RESET_MINUTES = 20;
|
|
/** The demo room stands somewhere: the seeded dates are the ones this zone calls today. */
|
|
const DEMO_TZ = "Australia/Brisbane";
|
|
const ADMIN_EMAIL = "demo-admin@threadcount.tech";
|
|
const ISSUER_EMAIL = "demo-issuer@threadcount.tech";
|
|
|
|
const TOPS = "XS|S|M|L|XL|2XL|3XL";
|
|
const PANTS = "XS|S|M|L|XL|2XL|3XL";
|
|
const TROUSERS = "77R|82R|87R|92R|97R|102R|107R";
|
|
const NL = "Northline Workwear", HE = "Harbour Embroidery";
|
|
|
|
// The demo's staff groups, spread over the three routes a facility chooses between so that a
|
|
// visitor meets all three — on the register, at the counter and on the printed order form: the
|
|
// nurses on the FTE table, Support Services on the starting kit, Kitchen and Security on manager
|
|
// approval. Plain names any Australian employer would recognise, because this is the facility every
|
|
// prospect walks into, and it must not read as any one hospital's organisation chart.
|
|
const FTE_GROUPS = ["Registered Nurse", "Enrolled Nurse"];
|
|
const KIT_GROUPS = ["Support Services"];
|
|
const APPROVAL_GROUPS = ["Kitchen", "Security"];
|
|
|
|
// sku, item, gender, supplier, cost, groups, sizes. Groups go in through the CSV importer, so several
|
|
// are separated by | exactly as a coordinator's file writes them: the scrub pant is worn by both
|
|
// nursing groups, the vest by Support Services and Security, and "All" is every group.
|
|
//
|
|
// All three cuts are stocked, because the uniform-style rule is invisible in a catalogue that is
|
|
// entirely unisex: the scrub top and the softshell come in a women's cut as well, the security
|
|
// shirt and trouser are the men's, and everything else is worn by anybody. So a visitor who opens
|
|
// the counter for a nurse set to Men's sees the men's and unisex garments and not the women's cut,
|
|
// and a visitor who blanks that nurse's style sees the whole catalogue again.
|
|
const CATALOG: [string, string, string, string, number, string, string][] = [
|
|
["RN-TOP", "RN Active Scrub Top", "Unisex", NL, 31.5, "Registered Nurse", TOPS],
|
|
["RN-PANT", "Elastic Waist Scrub Pant", "Unisex", NL, 28.0, "Registered Nurse|Enrolled Nurse", PANTS],
|
|
["EN-TOP", "EN Scrub Top", "Unisex", NL, 29.5, "Enrolled Nurse", TOPS],
|
|
["EN-PANT", "EN Scrub Pant", "Unisex", NL, 27.0, "Enrolled Nurse", PANTS],
|
|
["SS-POLO", "Support Services Polo", "Unisex", HE, 22.5, "Support Services", TOPS],
|
|
["SS-CARGO", "Support Services Cargo Pant", "Unisex", NL, 34.0, "Support Services", TROUSERS],
|
|
["SS-VEST", "Hi-Vis Safety Vest", "Unisex", HE, 16.5, "Support Services|Security", TOPS],
|
|
["SEC-SHIRT", "Security Shirt", "Male", HE, 39.0, "Security", TOPS],
|
|
["SEC-PANT", "Security Trouser", "Male", NL, 42.0, "Security", TROUSERS],
|
|
["KIT-JKT", "Chef Jacket", "Unisex", NL, 36.0, "Kitchen", TOPS],
|
|
["KIT-PANT", "Kitchen Check Pant", "Unisex", NL, 29.0, "Kitchen", PANTS],
|
|
["ALL-JKT", "Softshell Jacket", "Unisex", HE, 58.0, "All", TOPS],
|
|
["ALL-CARD", "Knit Cardigan", "Unisex", HE, 44.0, "All", TOPS],
|
|
["RN-TOP-W", "RN Active Scrub Top", "Female", NL, 31.5, "Registered Nurse", TOPS],
|
|
["ALL-JKT-W", "Softshell Jacket", "Female", HE, 58.0, "All", TOPS],
|
|
];
|
|
|
|
const DEPTS: [string, string][] = [
|
|
["Willow Ward", "RGH-3010"], ["Alder Ward", "RGH-3020"], ["ICU", "RGH-4010"], ["Emergency", "RGH-4020"],
|
|
["Theatres", "RGH-4030"], ["Support Services", "RGH-5010"], ["Security", "RGH-5020"], ["Kitchen", "RGH-5030"],
|
|
];
|
|
|
|
// num, first, last, group, dept, top, pants, phone, fte, uniform style. The nurses carry an FTE
|
|
// because on the FTE table it is what proposes their starting kit — without one, every nurse in the
|
|
// demo would read "no FTE recorded" and the route would have nothing to show. The other two routes
|
|
// never read it.
|
|
//
|
|
// The last column is the cut of uniform each one is offered, and the demo carries all four states a
|
|
// register holds: most people set to Men's or Women's, Thanh and Sam on Either because they wear
|
|
// whichever fits, and Jordan and Zoe left blank — nobody has said, which offers them everything and
|
|
// is what every row on a real register reads until a coordinator goes through it. The three
|
|
// security officers are on the men's cut because the shirt and trouser their group wears are only
|
|
// made in it; a women's-cut security shirt is the sort of gap this column is meant to expose.
|
|
const STAFF: [string, string, string, string, string, string, string, string, string, string][] = [
|
|
["100231", "Maya", "Whitfield", "Registered Nurse", "Willow Ward", "M", "M", "0400 111 201", "1.0", "Women's"],
|
|
["100244", "Kofi", "Osei", "Registered Nurse", "ICU", "L", "L", "0400 111 202", "0.8", "Men's"],
|
|
["100251", "Thanh", "Nguyen", "Enrolled Nurse", "Alder Ward", "L", "L", "0400 111 203", "1.0", "Either"],
|
|
["100263", "Riya", "Patel", "Support Services", "Support Services", "S", "87R", "0400 111 204", "", "Women's"],
|
|
["100270", "Liam", "Cartwright", "Security", "Security", "XL", "97R", "0400 111 205", "", "Men's"],
|
|
["100288", "Sofia", "Marino", "Registered Nurse", "Emergency", "S", "S", "0400 111 206", "1.0", "Women's"],
|
|
["100293", "Jordan", "Blake", "Support Services", "Willow Ward", "M", "87R", "0400 111 207", "", ""],
|
|
["100301", "Aisha", "Rahman", "Registered Nurse", "Theatres", "M", "M", "0400 111 208", "0.6", "Women's"],
|
|
["100315", "Ethan", "Kowalski", "Kitchen", "Kitchen", "L", "L", "0400 111 209", "", "Men's"],
|
|
["100322", "Grace", "O'Neill", "Enrolled Nurse", "ICU", "M", "M", "0400 111 210", "0.8", "Women's"],
|
|
["100337", "Daniel", "Park", "Support Services", "Support Services", "L", "92R", "0400 111 211", "", "Men's"],
|
|
["100349", "Priya", "Sharma", "Registered Nurse", "Alder Ward", "XS", "XS", "0400 111 212", "0.6", "Women's"],
|
|
["100356", "Marcus", "Reid", "Security", "Security", "2XL", "102R", "0400 111 213", "", "Men's"],
|
|
["100361", "Hannah", "Lindqvist", "Support Services", "Emergency", "S", "82R", "0400 111 214", "", "Women's"],
|
|
["100378", "Yusuf", "Demir", "Kitchen", "Kitchen", "M", "M", "0400 111 215", "", "Men's"],
|
|
["100384", "Chloe", "Bennett", "Registered Nurse", "ICU", "M", "M", "0400 111 216", "1.0", "Women's"],
|
|
["100392", "Noah", "Fitzgerald", "Enrolled Nurse", "Theatres", "L", "L", "0400 111 217", "0.5", "Men's"],
|
|
["100405", "Isabella", "Russo", "Registered Nurse", "Willow Ward", "S", "S", "0400 111 218", "0.9", "Women's"],
|
|
["100417", "Sam", "Taylor", "Support Services", "Support Services", "M", "87R", "0400 111 219", "", "Either"],
|
|
["100423", "Amara", "Okafor", "Support Services", "Alder Ward", "L", "92R", "0400 111 220", "", "Women's"],
|
|
["100438", "Lucas", "Moreau", "Security", "Security", "L", "92R", "0400 111 221", "", "Men's"],
|
|
["100446", "Zoe", "Campbell", "Registered Nurse", "Emergency", "M", "M", "0400 111 222", "0.5", ""],
|
|
["100459", "Oliver", "Hughes", "Kitchen", "Kitchen", "XL", "XL", "0400 111 223", "", "Men's"],
|
|
["100467", "Mei", "Tanaka", "Enrolled Nurse", "Willow Ward", "S", "S", "0400 111 224", "0.4", "Women's"],
|
|
];
|
|
|
|
function sizesOf(sku: string) { return CATALOG.find((c) => c[0] === sku)![6].split("|"); }
|
|
function si(sku: string, size: string) { return sizesOf(sku).indexOf(size); }
|
|
function topSku(group: string) { return ({ "Registered Nurse": "RN-TOP", "Enrolled Nurse": "EN-TOP", "Support Services": "SS-POLO", Security: "SEC-SHIRT", Kitchen: "KIT-JKT" } as Record<string, string>)[group]; }
|
|
function pantSku(group: string) { return ({ "Registered Nurse": "RN-PANT", "Enrolled Nurse": "EN-PANT", "Support Services": "SS-CARGO", Security: "SEC-PANT", Kitchen: "KIT-PANT" } as Record<string, string>)[group]; }
|
|
|
|
export async function findDemoFacility() {
|
|
return prisma.facility.findFirst({ where: { isDemo: true }, include: { users: true } });
|
|
}
|
|
|
|
/** Drop and rebuild the demo facility. Safe to call at any time; visitors mid-session simply see fresh data. */
|
|
export async function resetDemo() {
|
|
// The images go with the rows. Visitors sign for deliveries in the demo like anyone else, and a
|
|
// facility that is rebuilt three times an hour would otherwise leave a new orphaned directory of
|
|
// anonymous uploads on disk every twenty minutes, with nothing left in the database to name them.
|
|
const old = await prisma.facility.findMany({ where: { isDemo: true }, select: { id: true } });
|
|
await prisma.facility.deleteMany({ where: { isDemo: true } }); // every relation cascades from Facility
|
|
for (const o of old) await deletePhotoDir(o.id);
|
|
const today = facilityToday(DEMO_TZ);
|
|
// The groups and both route lists are written here, on a row built fresh every reset, rather than
|
|
// patched onto the old one: a visitor who moved a group between routes in the last twenty minutes
|
|
// leaves nothing behind, and a demo still carrying groups from before these were changed takes
|
|
// the new ones at its next reset.
|
|
const fac = await prisma.facility.create({ data: {
|
|
name: DEMO_FACILITY, location: "Linen Room, Level B1", coordinator: "Alex Demo", timezone: DEMO_TZ,
|
|
staffGroups: [...FTE_GROUPS, ...KIT_GROUPS, ...APPROVAL_GROUPS], nursingGroups: FTE_GROUPS, kitGroups: KIT_GROUPS,
|
|
glAccount: "631020", journalDesc: "Uniform issues", exceptionHigh: 8, slipOrg: "Riverside Health", isDemo: true, demoResetAt: null, // stamped when seeding completes, so a half-built facility is always rebuilt
|
|
lastBackup: addDays(today, -9),
|
|
} });
|
|
const pw = async () => bcrypt.hash(randomBytes(24).toString("hex"), 8); // nobody logs in with these — entry is via /api/auth/demo
|
|
const adminU = await prisma.user.create({ data: { facilityId: fac.id, email: ADMIN_EMAIL, passwordHash: await pw(), first: "Alex", last: "Demo", title: "Uniform Coordinator", role: "ADMIN" } });
|
|
await prisma.user.create({ data: { facilityId: fac.id, email: ISSUER_EMAIL, passwordHash: await pw(), first: "Sam", last: "Demo", title: "Linen Room Assistant", role: "ISSUER" } });
|
|
const me: SessionUser = { id: adminU.id, facilityId: fac.id, email: adminU.email, first: adminU.first, last: adminU.last, title: adminU.title, role: "ADMIN", isDemo: true, viaSso: false };
|
|
const op = (name: string, payload: unknown) => runOp(me, name, payload);
|
|
|
|
for (const [name, cc] of DEPTS) await op("dept.save", { name, cc });
|
|
for (const [name, contact, phone, account, lead] of [[NL, "Dana Whitlock", "1300 555 010", "RGH-4471", 10], [HE, "Priya Nair", "07 3555 0190", "RGH-EMB-22", 21]] as const) {
|
|
const r = (await op("supplier.add", { name })) as { id: string };
|
|
await op("supplier.update", { id: r.id, contact, phone, account, lead: String(lead) });
|
|
}
|
|
await op("import.rows", { kind: "catalog", rows: CATALOG.map(([sku, item, gender, supplier, cost, group, sizes]) => ({ sku, item, gender, supplier, cost: String(cost), group, sizes })) });
|
|
// The style column goes in through the importer like every other one — a register arrives as a
|
|
// file, and the demo is seeded the way a real facility is loaded.
|
|
await op("import.rows", { kind: "staff", rows: STAFF.map(([num, first, last, group, dept, top, pants, phone, fte, style]) => ({ num, first, last, group, dept, top, pants, phone, fte, style, start: addDays(today, -(200 + (parseInt(num, 10) % 900))) })) });
|
|
|
|
// Opening stock: a healthy shelf, with a few sizes deliberately thin so reorder flags and "sizes out" have something to show.
|
|
const opening: { sku: string; size: string; opening: string; reorder: string }[] = [];
|
|
const thin = new Set(["RN-TOP:S", "KIT-JKT:L", "SEC-SHIRT:XL", "SEC-PANT:97R", "SS-CARGO:107R", "ALL-JKT:M"]);
|
|
for (const [sku, , , , , , sizes] of CATALOG) for (const size of sizes.split("|")) {
|
|
const k = `${sku}:${size}`; const core = ["S", "M", "L", "XL", "82R", "87R", "92R"].includes(size);
|
|
opening.push({ sku, size, opening: thin.has(k) ? (k === "ALL-JKT:M" ? "0" : "2") : String(core ? 14 + ((sku.length + size.length) % 7) : 5), reorder: core ? "4" : "2" });
|
|
}
|
|
await op("import.rows", { kind: "opening", rows: opening });
|
|
await op("import.rows", { kind: "barcodes", rows: [
|
|
{ sku: "RN-TOP", size: "M", barcode: "9312345000011" }, { sku: "RN-TOP", size: "L", barcode: "9312345000028" }, { sku: "RN-PANT", size: "M", barcode: "9312345000035" },
|
|
{ sku: "EN-TOP", size: "L", barcode: "9312345000042" }, { sku: "SS-POLO", size: "M", barcode: "9312345000059" }, { sku: "SEC-SHIRT", size: "XL", barcode: "9312345000066" },
|
|
] });
|
|
|
|
const items = Object.fromEntries((await prisma.catalogItem.findMany({ where: { facilityId: fac.id } })).map((i) => [i.sku, i.id]));
|
|
const staff = Object.fromEntries((await prisma.staff.findMany({ where: { facilityId: fac.id } })).map((s) => [s.num, s.id]));
|
|
const byNum = Object.fromEntries(STAFF.map((s) => [s[0], s]));
|
|
const line = (sku: string, size: string, qty: number, src: "stock" | "order" | "preloved" = "stock") => ({ itemId: items[sku], si: si(sku, size), qty, src });
|
|
const set = (num: string, qty = 1, src: "stock" | "order" = "stock") => { const s = byNum[num]; return [line(topSku(s[3]), s[5], qty, src), line(pantSku(s[3]), s[6], qty, src)]; };
|
|
|
|
// Signed order forms for nurses on the FTE table, each against the hours on the form. One is
|
|
// signed above what the table proposes, so the note a manager's discretion leaves on the record
|
|
// has an example to show.
|
|
const approvals: [string, string, number, string][] = [["100231", "J. Barnes, Manager 3A", 5, "1.0"], ["100244", "R. Achebe, Manager ICU", 4, "0.8"], ["100288", "L. Torres, Manager ED", 3, "1.0"], ["100349", "M. Quinn, Manager 3B", 4, "0.6"], ["100384", "R. Achebe, Manager ICU", 5, "1.0"], ["100446", "L. Torres, Manager ED", 3, "0.5"]];
|
|
for (const [num, by, sets, fte] of approvals) await op("approval.add", { staffId: staff[num], by, sets: String(sets), fte, date: addDays(today, -20 - (parseInt(num, 10) % 15)) });
|
|
// Kitchen and Security are on manager approval: no starting kit, and every set they hold was signed
|
|
// for. Without these their records would tell a visitor each set needs a manager's signature and
|
|
// then show sets issued with none. One form is for two sets with one still to collect, so a
|
|
// balance from this route shows beside the FTE table's.
|
|
const approvalForms: [string, string, number][] = [["100270", "D. Walsh, Security Manager", 1], ["100356", "D. Walsh, Security Manager", 1], ["100438", "D. Walsh, Security Manager", 1], ["100315", "K. Byrne, Catering Manager", 1], ["100378", "K. Byrne, Catering Manager", 1], ["100459", "K. Byrne, Catering Manager", 2]];
|
|
for (const [num, by, sets] of approvalForms) await op("approval.add", { staffId: staff[num], by, sets: String(sets), date: addDays(today, -20 - (parseInt(num, 10) % 15)) });
|
|
|
|
// Issues. Nurses with a signed form draw it down; the other nurses take sets from the kit their
|
|
// hours propose. Support Services take a set from their starting kit, and Riya comes back for two
|
|
// more tops — more as needed, nothing handed back first. Kitchen and Security draw their signed sets.
|
|
const issued: [string, number][] = [["100231", 2], ["100244", 1], ["100288", 2], ["100349", 1], ["100384", 3], ["100446", 1]];
|
|
for (const [num, sets] of issued) await op("issue.create", { staffId: staff[num], apDeduct: sets, lines: set(num, sets) });
|
|
for (const num of ["100251", "100322", "100392", "100467"]) await op("issue.create", { staffId: staff[num], apDeduct: 0, lines: set(num, 1) });
|
|
for (const num of ["100263", "100293", "100337", "100361", "100417", "100423"]) await op("issue.create", { staffId: staff[num], lines: set(num, 1) });
|
|
for (const [num] of approvalForms) await op("issue.create", { staffId: staff[num], apDeduct: 1, lines: set(num, 1) });
|
|
await op("issue.create", { staffId: staff["100263"], lines: [line("SS-POLO", "S", 2)] });
|
|
await op("issue.create", { staffId: staff["100301"], apDeduct: 0, lines: [line("ALL-CARD", "M", 1)] });
|
|
// Order-in lines create staff orders; two have arrived and sit on the pickup call list, one is still with the supplier.
|
|
await op("issue.create", { staffId: staff["100244"], apDeduct: 0, lines: [line("ALL-JKT", "L", 1, "order")] });
|
|
await op("issue.create", { staffId: staff["100251"], apDeduct: 0, lines: [line("EN-TOP", "L", 2, "order")] });
|
|
await op("issue.create", { staffId: staff["100263"], lines: [line("SS-CARGO", "87R", 1, "order")] });
|
|
await op("issue.create", { staffId: staff["100405"], apDeduct: 0, lines: [line("ALL-JKT", "S", 1, "order")] });
|
|
|
|
const staffOrders = await prisma.order.findMany({ where: { facilityId: fac.id, orderFor: "Staff Member" }, include: { lines: true }, orderBy: { createdAt: "asc" } });
|
|
const so = (num: string) => staffOrders.find((o) => o.staffId === staff[num])!;
|
|
for (const [num, days, contacted] of [["100244", 16, true], ["100251", 4, false], ["100263", 1, false]] as const) {
|
|
const o = so(num);
|
|
await prisma.order.update({ where: { id: o.id }, data: { ref: `NL-${48100 + parseInt(num.slice(-3), 10)}`, date: addDays(today, -days - 12) } });
|
|
await op("order.receive", { id: o.id, date: addDays(today, -days), invoice: `INV-${77120 + parseInt(num.slice(-3), 10)}`, lines: o.lines.map((l) => ({ lineId: l.id, arrived: l.qty, dest: "pickup" })) });
|
|
const pk = await prisma.pickup.findFirst({ where: { orderId: o.id } });
|
|
if (pk) await prisma.pickup.update({ where: { id: pk.id }, data: { received: addDays(today, -days), contacted } });
|
|
}
|
|
await prisma.order.update({ where: { id: so("100405").id }, data: { ref: "HE-20931", expected: addDays(today, 9) } });
|
|
|
|
// Replenishment drafts were built automatically from the shelf issues. Send one, receive one short (→ back order), leave one overdue.
|
|
const drafts = await prisma.order.findMany({ where: { facilityId: fac.id, replenish: true, status: "Draft" }, include: { lines: true }, orderBy: { supplier: "asc" } });
|
|
const nl = drafts.find((d) => d.supplier === NL), he = drafts.find((d) => d.supplier === HE);
|
|
if (nl) {
|
|
await op("order.status", { id: nl.id, status: "Ordered" });
|
|
await prisma.order.update({ where: { id: nl.id }, data: { ref: "NL-48211", tracking: "AP7731920021", date: addDays(today, -18), expected: addDays(today, -6) } });
|
|
}
|
|
if (he) {
|
|
await op("order.status", { id: he.id, status: "Ordered" });
|
|
await prisma.order.update({ where: { id: he.id }, data: { ref: "HE-20877", date: addDays(today, -25), expected: addDays(today, -4) } });
|
|
await op("order.receive", { id: he.id, date: addDays(today, -3), invoice: "INV-77044", lines: he.lines.map((l, i) => ({ lineId: l.id, arrived: i === 0 ? Math.max(0, l.qty - 1) : l.qty, dest: "shelf" })) });
|
|
}
|
|
// A stock order placed by hand that has fully landed.
|
|
const landed = (await op("order.create", { orderFor: "Stock", supplier: NL, lines: [{ itemId: items["RN-TOP"], size: "M", qty: 12 }, { itemId: items["RN-PANT"], size: "M", qty: 12 }, { itemId: items["RN-TOP"], size: "L", qty: 8 }], notes: "Winter top-up" })) as { id: string };
|
|
await op("order.status", { id: landed.id, status: "Ordered" });
|
|
const lo = await prisma.order.findUniqueOrThrow({ where: { id: landed.id }, include: { lines: true } });
|
|
await prisma.order.update({ where: { id: lo.id }, data: { ref: "NL-47960", date: addDays(today, -40) } });
|
|
await op("order.receive", { id: lo.id, date: addDays(today, -28), invoice: "INV-76902", lines: lo.lines.map((l) => ({ lineId: l.id, arrived: l.qty, dest: "shelf" })) });
|
|
|
|
// A filed stocktake with a couple of variances, and one return.
|
|
await op("stocktake.apply", { lines: [
|
|
{ itemId: items["RN-TOP"], si: si("RN-TOP", "M"), counted: 21 }, { itemId: items["RN-PANT"], si: si("RN-PANT", "M"), counted: 20 },
|
|
{ itemId: items["SS-POLO"], si: si("SS-POLO", "M"), counted: 15 }, { itemId: items["SEC-SHIRT"], si: si("SEC-SHIRT", "L"), counted: 17 },
|
|
] });
|
|
const ret = await prisma.issue.findFirst({ where: { facilityId: fac.id, staffId: staff["100337"] } });
|
|
if (ret) await op("issue.return", { id: ret.id, cond: "Returned - Good" });
|
|
await op("alteration.add", { staffId: staff["100301"], garment: "Scrub pant (M)", desc: "Hem 4 cm" });
|
|
// Pre-loved pool: old try-on range added directly, a hand-in from a leaver, and one free reissue from the pool.
|
|
await op("stock.moves", { mode: "Pre-loved", lines: [line("RN-TOP", "M", 3), line("RN-PANT", "M", 2), line("SS-POLO", "L", 2), line("ALL-CARD", "S", 1)] });
|
|
await op("handin.add", { staffId: staff["100392"], credit: true, lines: [{ ...line("EN-TOP", "L", 2), cond: "Good", laundered: true }, { ...line("EN-PANT", "L", 1), cond: "Rag", laundered: false }] });
|
|
// Mei is an Enrolled Nurse, so her free reissue is the scrub pant both nursing groups wear — the
|
|
// RN-only top would need the coordinator's off-group override at the counter.
|
|
await op("issue.create", { staffId: staff["100467"], apDeduct: 0, lines: [line("RN-PANT", "M", 1, "preloved")] });
|
|
|
|
// Spread issue dates back across the last three months so the reports and trend bars have shape.
|
|
const iss = await prisma.issue.findMany({ where: { facilityId: fac.id, direct: false }, orderBy: { createdAt: "asc" } });
|
|
for (let i = 0; i < iss.length; i++) await prisma.issue.update({ where: { id: iss[i].id }, data: { date: addDays(today, -((i * 37) % 80)) } });
|
|
const st = await prisma.stocktake.findFirst({ where: { facilityId: fac.id } });
|
|
if (st) await prisma.stocktake.update({ where: { id: st.id }, data: { date: addDays(today, -12) } });
|
|
return prisma.facility.update({ where: { id: fac.id }, data: { demoResetAt: new Date() } });
|
|
}
|
|
|
|
/** The demo facility, rebuilt if it is missing or if the reset timer has slipped.
|
|
*
|
|
* The window is half again the reset interval rather than an hour: everyone shares this facility,
|
|
* so whatever one visitor types is on the screen of every visitor behind them, and the promise on
|
|
* /demo is that it resets every twenty minutes. A stalled systemd timer should cost a few extra
|
|
* minutes, not the rest of the hour. */
|
|
const DEMO_STALE_MS = DEMO_RESET_MINUTES * 90 * 1000;
|
|
let resetting: Promise<unknown> | null = null;
|
|
export async function ensureDemo() {
|
|
const f = await findDemoFacility();
|
|
if (f && f.demoResetAt && Date.now() - f.demoResetAt.getTime() < DEMO_STALE_MS) return f;
|
|
// Concurrent visitors share one rebuild instead of racing several.
|
|
if (!resetting) resetting = resetDemo().finally(() => { resetting = null; });
|
|
await resetting;
|
|
return (await findDemoFacility())!;
|
|
}
|
|
|
|
export function demoUserFor(f: NonNullable<Awaited<ReturnType<typeof findDemoFacility>>>, as: string) {
|
|
return f.users.find((u) => u.role === (as === "issuer" ? "ISSUER" : "ADMIN")) || f.users[0];
|
|
}
|