1bc2de655a
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
3426 lines
262 KiB
TypeScript
3426 lines
262 KiB
TypeScript
import bcrypt from "bcryptjs";
|
||
import { prisma } from "./db";
|
||
import { setSessionCookie, type SessionUser } from "./session";
|
||
import { buildSnapshot } from "./snapshot";
|
||
import { ALT_FLOW, FTE_CASUAL, FTE_OPTIONS, LOCATION_KINDS, UNIFORM_STYLES, addDays, approvalDeparture, capCheck, facilityToday, flaggedNeeds, formatInZone, garmentForGroup, garmentForStyle, garmentGroups, genderLabel, groupKey, groupsLabel, isKitGroup, isNursingGroup, isPantItem, isTopItem, key, ledger, locSubtree, locWouldLoop, normalUniformStyle, onhand, openApprovals, plOf, setsForFte, setsOnStart, sizeIndexOf, variantList, type Item } from "./compute";
|
||
import { Prisma, type CatalogItem } from "@prisma/client";
|
||
import { deletePhoto, deletePhotoDir, parseDataUrl, photoAsDataUrl, writePhoto } from "@/lib/photostore";
|
||
import { newActivateCode } from "./staffsession";
|
||
import { AWAITING_HANDOVER, LINE_STATUSES, OPEN_REQUEST, TRANSITIONS, approvedLines, canMove, collectionCode, holdEndsAt, requestCode } from "./staffreq";
|
||
import { approvalEmail, approvalUrl, decisionEmail, garmentBlock, signApprovalToken } from "./approvallink";
|
||
import { sendTo, transactionalConfigured } from "./mail";
|
||
import { inHouseEan13, isInHouse } from "./barcode";
|
||
// Straight from ./sets rather than through ./compute like its neighbour setsOnStart, because
|
||
// compute doesn't re-export this one. Same rule either way: the ceiling a coordinator types here
|
||
// and the ceiling a wearer's screen quotes have to come out of the one function.
|
||
import { setsCap } from "./sets";
|
||
import { PLAN_COLS, READ_ONLY_ALLOWED, READ_ONLY_REFUSAL, entitlements, staffRefusal, type PlanRow } from "./plan";
|
||
import { notifyOwners } from "./ops/alerts";
|
||
import { allow } from "./ratelimit";
|
||
|
||
type Tx = Prisma.TransactionClient;
|
||
export class OpError extends Error { status: number; constructor(msg: string, status = 400) { super(msg); this.status = status; } }
|
||
|
||
const str = (v: unknown, max = 500) => (v === undefined || v === null ? "" : String(v)).slice(0, max);
|
||
const int = (v: unknown, d = 0) => { const n = parseInt(String(v), 10); return Number.isFinite(n) ? Math.max(-1e9, Math.min(1e9, n)) : d; };
|
||
const num = (v: unknown, d = 0) => { const n = parseFloat(String(v)); return Number.isFinite(n) ? n : d; };
|
||
const admin = (u: SessionUser) => { if (u.role !== "ADMIN") throw new OpError("Admin only", 403); };
|
||
const LOGO_MAX = 400 * 1024;
|
||
|
||
/** A list of staff group names off the settings screen: trimmed, blanks dropped, and one entry per
|
||
* group as the app compares names (groupKey), keeping the first spelling sent. Refused rather than
|
||
* ignored when it isn't a list, because the screen saves a field at a time and answering "Saved."
|
||
* over a value that was thrown away is a lie the next reload exposes. */
|
||
const groupList = (v: unknown, what: string): string[] => {
|
||
if (!Array.isArray(v)) throw new OpError(`Send the ${what} as a list of staff group names`);
|
||
const seen = new Set<string>(), out: string[] = [];
|
||
for (const x of v) {
|
||
const g = str(x, 80).trim(), k = groupKey(g);
|
||
if (k && !seen.has(k)) { seen.add(k); out.push(g); }
|
||
}
|
||
return out;
|
||
};
|
||
|
||
/** The staff groups a garment is for, off whatever a screen sent — or undefined to leave them be.
|
||
* The desktop sends `groups`, a list, empty meaning every group. The phone counter app still sends
|
||
* its single `group` and cannot change, and it shows several groups as one label ("Registered
|
||
* Nurse, Enrolled Nurse"). So a `group` equal to the garment's own label is the phone saving its
|
||
* other fields and changes nothing — read as one name it would collapse a four-group garment onto a
|
||
* group nobody is in. A label copied off another garment takes that garment's groups; "All" or
|
||
* blank is every group; any other value is a list of names split at its commas, or one name if it
|
||
* has none. */
|
||
async function groupsFromPayload(db: Tx, fid: string, p: { groups?: unknown; group?: unknown }, current: string[] | null): Promise<string[] | undefined> {
|
||
if (p.groups !== undefined && p.groups !== null) {
|
||
if (!Array.isArray(p.groups) || p.groups.length > 200) throw new OpError("Send the garment's staff groups as a list of names");
|
||
return garmentGroups(p.groups.map((x: unknown) => str(x, 80)));
|
||
}
|
||
if (p.group === undefined || p.group === null) return undefined;
|
||
const v = str(p.group, 4000).trim();
|
||
if (current && groupKey(v) === groupKey(groupsLabel(current))) return undefined;
|
||
if (v.includes(",")) {
|
||
const tagged = await db.catalogItem.findMany({ where: { facilityId: fid, NOT: { groups: { isEmpty: true } } }, select: { groups: true } });
|
||
const same = tagged.find((t) => t.groups.length > 1 && groupKey(groupsLabel(t.groups)) === groupKey(v));
|
||
if (same) return garmentGroups(same.groups);
|
||
// Nobody's label, so it is a list typed on the phone. Kept whole it would be one group called
|
||
// "A, B" that nobody is in, and the garment would be refused to both A and B. A facility's own
|
||
// group name can carry a comma of its own, though, so the pieces are joined back up wherever
|
||
// they spell a name this facility already uses — on its settings, its register or a garment.
|
||
const [fac, onRegister] = await Promise.all([
|
||
db.facility.findUnique({ where: { id: fid }, select: { staffGroups: true } }),
|
||
db.staff.findMany({ where: { facilityId: fid }, select: { group: true }, distinct: ["group"] }),
|
||
]);
|
||
return garmentGroups(splitGroupNames(v, [...(fac?.staffGroups || []), ...onRegister.map((s) => s.group), ...tagged.flatMap((t) => t.groups)]));
|
||
}
|
||
return garmentGroups([str(v, 80)]);
|
||
}
|
||
|
||
/** A comma-separated list of staff groups as names, joining neighbouring pieces back together where
|
||
* they spell one of the `known` names that itself contains a comma. Longest match first, so a
|
||
* known "Food, Retail" wins over a lone "Food". The pieces are left for garmentGroups() to trim. */
|
||
function splitGroupNames(v: string, known: readonly string[]): string[] {
|
||
const commaKey = (s: string) => s.split(",").map((x) => groupKey(x)).join(",");
|
||
const names = new Map<string, string>();
|
||
for (const n of known) if (n.includes(",")) names.set(commaKey(n), n.trim());
|
||
const parts = v.split(",");
|
||
const out: string[] = [];
|
||
for (let i = 0; i < parts.length;) {
|
||
let j = parts.length;
|
||
while (j > i + 1 && !names.has(commaKey(parts.slice(i, j).join(",")))) j--;
|
||
out.push(j > i + 1 ? names.get(commaKey(parts.slice(i, j).join(",")))! : parts[i]);
|
||
i = j;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** A date-only value, normalised to YYYY-MM-DD, or "" if it isn't a real calendar date.
|
||
*
|
||
* `str(v, 10)` used to stand in for this, which is a truncation and not a check: a spreadsheet
|
||
* saved on an Australian machine writes "11/03/2024", exactly ten characters, and it was stored
|
||
* verbatim and rendered as the literal text "Invalid Date" ever after. Day-first D/M/Y and D-M-Y
|
||
* are accepted and converted, because that is what a local spreadsheet produces and refusing a
|
||
* whole staff import over a date format helps nobody; anything else is refused. */
|
||
const isoDate = (v: unknown): string => {
|
||
const s = str(v, 40).trim();
|
||
if (!s) return "";
|
||
let y: number, m: number, d: number;
|
||
// A trailing time is allowed and dropped: a date-only column occasionally receives a full
|
||
// timestamp (an older row, a date picker that sends one), and the day is the part that matters.
|
||
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ].*)?$/.exec(s);
|
||
if (iso) { y = +iso[1]; m = +iso[2]; d = +iso[3]; }
|
||
else {
|
||
const au = /^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/.exec(s);
|
||
if (!au) return "";
|
||
d = +au[1]; m = +au[2]; y = +au[3];
|
||
}
|
||
// Round-tripped through UTC so 31 February and friends are rejected rather than rolled forward.
|
||
const at = new Date(Date.UTC(y, m - 1, d));
|
||
if (at.getUTCFullYear() !== y || at.getUTCMonth() !== m - 1 || at.getUTCDate() !== d) return "";
|
||
return `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||
};
|
||
/** The statuses a request is allowed to hold, read off the state machine itself so a restored file
|
||
* can never introduce one the screens have no words for. */
|
||
const REQ_STATUSES = new Set(Object.keys(TRANSITIONS));
|
||
/** A timestamp out of a backup file, or null. `new Date("nonsense")` is an Invalid Date, and
|
||
* handing one to Prisma fails the whole restore over a single unreadable field. */
|
||
const stamp = (v: unknown): Date | null => {
|
||
if (!v) return null;
|
||
const d = new Date(String(v));
|
||
return Number.isNaN(d.getTime()) ? null : d;
|
||
};
|
||
/** An IANA zone name that this runtime actually knows, or the Brisbane default. Restoring a zone
|
||
* the box has never heard of would leave every date in the facility being computed against a
|
||
* fallback while the settings screen claimed otherwise. */
|
||
const zoneOf = (v: unknown): string => {
|
||
const z = str(v, 60).trim();
|
||
if (!z) return "Australia/Brisbane";
|
||
try { new Intl.DateTimeFormat("en-AU", { timeZone: z }); return z; } catch { return "Australia/Brisbane"; }
|
||
};
|
||
/** A combined FTE, spelt the way the order form spells it wherever it can be: "1.0" … "0.1",
|
||
* "Casual", or "" when nobody has recorded one. null means it is not a fraction at all, and that
|
||
* is the only thing refused.
|
||
*
|
||
* Any positive figure is kept, not just the eleven steps the picker offers, because the FTE table
|
||
* is what decides what a figure means and it decided long ago: setsForFte() reads a figure with no
|
||
* row of its own as the band it falls in, so 0.75 is a four-set person, deliberately, since the
|
||
* number is copied off a paper roster. Refusing 0.75 here while the table read it perfectly well
|
||
* was two answers to the same question — and the one people met first was the one that taught them
|
||
* to round the roster until the box stopped complaining, which leaves the register holding an FTE
|
||
* nobody is employed on and a manager signing for the sets that go with it. A coordinator typing a
|
||
* real roster figure is not making a mistake.
|
||
*
|
||
* A figure landing exactly on one of the form's own steps is re-spelt as the form spells it, so
|
||
* "1" and "0.50" are stored as "1.0" and "0.5" and the register reads the way the paper does;
|
||
* anything else is stored as the number it is, tidied of stray zeroes. Nought and below are the
|
||
* one arithmetic refusal: nobody is employed at nought FTE, the table proposes nothing for it, and
|
||
* storing it would only be a blank wearing a number. */
|
||
const normalFte = (v: unknown): string | null => {
|
||
const raw = str(v, 20).trim();
|
||
if (!raw) return "";
|
||
if (raw.toLowerCase() === FTE_CASUAL.toLowerCase()) return FTE_CASUAL;
|
||
const n = Number(raw);
|
||
if (!Number.isFinite(n) || n <= 0) return null;
|
||
return FTE_OPTIONS.find((o) => o !== FTE_CASUAL && Number(o) === n) ?? String(n);
|
||
};
|
||
/** What the FTE field will take, for a refusal that says so rather than just saying no. */
|
||
const FTE_ALLOWED = `a fraction like ${FTE_OPTIONS[0]} or 0.75, or ${FTE_CASUAL}`;
|
||
|
||
/** The refusal when a uniform style is a word the rule doesn't know. Blank is never refused: it is
|
||
* what every record starts as — nobody has said which cut this person wears — and it offers every
|
||
* style, so leaving it alone is always allowed. */
|
||
const STYLE_REFUSAL = `Uniform style has to be ${UNIFORM_STYLES.join(", ")} — leave it blank if nobody has said which cut they wear`;
|
||
|
||
/** A date-only field off a payload: blank falls back, anything unparseable is refused outright
|
||
* rather than stored as text that every screen downstream will fail to format. */
|
||
const dateField = (v: unknown, fallback: string, what: string): string => {
|
||
if (v === undefined || v === null || str(v, 40).trim() === "") return fallback;
|
||
const d = isoDate(v);
|
||
if (!d) throw new OpError(`${what} has to be a date, written as YYYY-MM-DD`);
|
||
return d;
|
||
};
|
||
|
||
async function nextOrderCode(tx: Tx, facilityId: string, today: string) {
|
||
const f = await tx.facility.update({ where: { id: facilityId }, data: { orderSeq: { increment: 1 } }, select: { orderSeq: true } });
|
||
return `ORD-${today.slice(0, 4)}-${String(f.orderSeq).padStart(4, "0")}`;
|
||
}
|
||
async function nextSort(tx: Tx, facilityId: string) {
|
||
const f = await tx.facility.update({ where: { id: facilityId }, data: { catalogSeq: { increment: 1 } }, select: { catalogSeq: true } });
|
||
return f.catalogSeq;
|
||
}
|
||
async function ownItem(tx: Tx, facilityId: string, id: string) {
|
||
const it = await tx.catalogItem.findFirst({ where: { id, facilityId } });
|
||
if (!it) throw new OpError("Unknown catalogue item", 404);
|
||
return it;
|
||
}
|
||
/** A catalogue row as the snapshot hands it out (lib/snapshot.ts): groups tidied through
|
||
* garmentGroups() and the display label derived from them. For a path that reads the table itself
|
||
* but asks a question of lib/compute, which speaks Item. */
|
||
function asItem(c: CatalogItem): Item {
|
||
return { id: c.id, sort: c.sort, item: c.item, gender: c.gender, type: c.type, sku: c.sku, supplier: c.supplier, cost: c.cost, groups: garmentGroups(c.groups), group: groupsLabel(c.groups), notes: c.notes, sizes: c.sizes, archived: c.archived };
|
||
}
|
||
async function ownStaff(tx: Tx, facilityId: string, id: string) {
|
||
const s = await tx.staff.findFirst({ where: { id, facilityId } });
|
||
if (!s) throw new OpError("Unknown staff member", 404);
|
||
return s;
|
||
}
|
||
/* Self-approval, as the owner decided it on 12 September 2026: anybody may approve for themselves
|
||
* — their own requests, and a signed order form for their own kit. There used to be a test here,
|
||
* managesSomebody(), that allowed it only to somebody with at least one active report on the
|
||
* register. It is gone, not relaxed: on a register where the linen-room coordinator is the only
|
||
* person, nobody manages anybody and nothing could be approved at all.
|
||
*
|
||
* What stands in for it is the record. Every self-approval is marked where it happened and can be
|
||
* read back without any flag of its own — an approval whose byStaffId is its staffId, a request
|
||
* decided by the manager it is for (its timeline says "Self-approved" in words).
|
||
*
|
||
* One rule does stand, in lib/staffops.ts and in request.reassign below: nobody approves a raise
|
||
* they made on somebody else's behalf. Approving your own kit is one person deciding about their
|
||
* own uniform where everyone can see whose it is; asking for somebody else's garments and then
|
||
* granting the ask yourself is one person doing both halves of a decision the ward is told two
|
||
* people made. */
|
||
/** Mark the facility as changed, so screens elsewhere know to reload.
|
||
*
|
||
* Called from the three routes that can change anything — the coordinator's mutate, the staff
|
||
* app's, and the emailed approve/decline — rather than from inside each op, for the same reason
|
||
* the audit trail is written there: a new case branch cannot forget to do it.
|
||
*
|
||
* Never allowed to fail a request. The write has already committed and been recorded by the time
|
||
* this runs; a facility that briefly reports a stale number costs somebody a few seconds before
|
||
* their screen catches up on its next focus, which is not worth turning a completed mutation into
|
||
* an error. */
|
||
export async function bumpRev(facilityId: string): Promise<number | null> {
|
||
try {
|
||
const f = await prisma.facility.update({ where: { id: facilityId }, data: { rev: { increment: 1 } }, select: { rev: true } });
|
||
return f.rev;
|
||
} catch (e) {
|
||
console.error("[rev] could not bump", facilityId, e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Refuse a binding that would make a scan ambiguous: a code already on another garment, or one that
|
||
* collides with the generated 93XXXXXXX code of a different item. `force` is the deliberate re-bind. */
|
||
async function assertBindable(tx: Tx | typeof prisma, facilityId: string, code: string, itemId: string, si: number, force: boolean) {
|
||
const cur = await tx.barcode.findUnique({ where: { facilityId_code: { facilityId, code } }, include: { item: { select: { id: true, item: true, sizes: true } } } });
|
||
if (cur && cur.itemId === itemId && cur.sizeIndex === si) return;
|
||
if (cur && !force) throw new OpError(`${code} is already on ${cur.item.item} · size ${cur.item.sizes[cur.sizeIndex] ?? cur.sizeIndex}. Unbind it there first, or re-bind to move it.`);
|
||
if (/^93\d{7}$/.test(code)) {
|
||
const v = +code - 930000000, sort = Math.floor(v / 100);
|
||
const owner = await tx.catalogItem.findFirst({ where: { facilityId, sort }, select: { id: true, item: true } });
|
||
if (owner && owner.id !== itemId) throw new OpError(`${code} is the generated code for ${owner.item} — binding it here would make that garment unscannable. Use the barcode printed on the label.`);
|
||
}
|
||
}
|
||
async function ownOrder(tx: Tx, facilityId: string, id: string) {
|
||
const o = await tx.order.findFirst({ where: { id, facilityId }, include: { lines: { orderBy: { sort: "asc" } }, receipts: { include: { lines: true } } } });
|
||
if (!o) throw new OpError("Unknown order", 404);
|
||
return o;
|
||
}
|
||
async function upsertLevel(tx: Tx, facilityId: string, itemId: string, si: number, data: { opening?: number; adj?: { increment: number } | number; reorder?: number | null; preloved?: { increment: number } | number }) {
|
||
await tx.stockLevel.upsert({
|
||
where: { itemId_sizeIndex: { itemId, sizeIndex: si } },
|
||
create: { facilityId, itemId, sizeIndex: si, opening: data.opening ?? 0, adj: typeof data.adj === "number" ? data.adj : data.adj?.increment ?? 0, reorder: data.reorder === undefined ? null : data.reorder, preloved: Math.max(0, typeof data.preloved === "number" ? data.preloved : data.preloved?.increment ?? 0) },
|
||
update: data,
|
||
});
|
||
}
|
||
const PHOTO_MAX = 700 * 1024;
|
||
/** What a backup file may carry in images, and what a restore will take back — one pair of numbers
|
||
* for both ends, because a file the product writes and then refuses to read is not a backup.
|
||
* The count is the restore's own limit; the byte budget keeps the finished JSON inside the 60 MB
|
||
* the import POST accepts, with room for the records themselves (base64 is ~4/3 of the file on
|
||
* disk). Both are far more than a facility accumulates in the year between a restore drill. */
|
||
const BACKUP_PHOTO_MAX = 2000;
|
||
const BACKUP_PHOTO_BYTES = 40 * 1024 * 1024;
|
||
/** How many images the shared public demo will hold. Enough to sign for every delivery on the
|
||
* seeded round; nowhere near enough for an anonymous visitor to use the box as image hosting
|
||
* between resets. */
|
||
const DEMO_PHOTO_MAX = 40;
|
||
/** Delete photos older than a day that nothing references (abandoned captures). */
|
||
async function gcPhotos(fid: string) {
|
||
const [ap, rc, is, pu] = await Promise.all([
|
||
prisma.approval.findMany({ where: { facilityId: fid, photoId: { not: null } }, select: { photoId: true } }),
|
||
prisma.receipt.findMany({ where: { order: { facilityId: fid }, photoId: { not: null } }, select: { photoId: true } }),
|
||
prisma.issue.findMany({ where: { facilityId: fid, returnPhotoId: { not: null } }, select: { returnPhotoId: true } }),
|
||
prisma.pickup.findMany({ where: { facilityId: fid, OR: [{ sigId: { not: null } }, { proofId: { not: null } }] }, select: { sigId: true, proofId: true } }),
|
||
]);
|
||
const keep = new Set<string>([...ap.map((x) => x.photoId!), ...rc.map((x) => x.photoId!), ...is.map((x) => x.returnPhotoId!), ...pu.flatMap((x) => [x.sigId, x.proofId].filter(Boolean) as string[])]);
|
||
const where = { facilityId: fid, createdAt: { lt: new Date(Date.now() - 24 * 3600 * 1000) }, id: { notIn: [...keep] } };
|
||
// Read the paths before deleting the rows: afterwards there is nothing left to say which files
|
||
// to remove, and orphaned images on disk are exactly the mess this move was meant to avoid.
|
||
const doomed = await prisma.photo.findMany({ where, select: { path: true } });
|
||
await prisma.photo.deleteMany({ where });
|
||
for (const d of doomed) if (d.path) await deletePhoto(d.path);
|
||
}
|
||
/** Delete a facility's Photo rows and hand back the files they pointed at.
|
||
*
|
||
* The paths have to be read before the rows go: afterwards nothing is left that could say which
|
||
* files to remove, and an image nobody can identify is an image nobody can ever delete. The
|
||
* unlinking is deliberately left to the caller so it happens *after* the transaction commits — a
|
||
* rolled-back wipe that had already erased the files would leave rows pointing at nothing. */
|
||
async function purgePhotoRows(tx: Tx, facilityId: string): Promise<string[]> {
|
||
const doomed = await tx.photo.findMany({ where: { facilityId }, select: { path: true } });
|
||
await tx.photo.deleteMany({ where: { facilityId } });
|
||
return doomed.map((d) => d.path).filter(Boolean);
|
||
}
|
||
const unlinkAll = async (paths: string[]) => { for (const rel of paths) await deletePhoto(rel); };
|
||
/** Serialise stock-mutating work per facility: lock the Facility row for the transaction, so two counter
|
||
* actions (double-click, two terminals) can't both pass the same shelf/pool/approval check. */
|
||
async function lockedTx<T>(facilityId: string, fn: (tx: Tx) => Promise<T>): Promise<T> {
|
||
return prisma.$transaction(async (tx) => {
|
||
await tx.$executeRaw`SELECT "id" FROM "Facility" WHERE "id" = ${facilityId} FOR UPDATE`;
|
||
return fn(tx);
|
||
}, { maxWait: 10_000, timeout: 30_000 });
|
||
}
|
||
/** Resolve a client-supplied photo id to one this facility owns (or null). */
|
||
async function ownPhoto(tx: Tx | typeof prisma, facilityId: string, id: unknown): Promise<string | null> {
|
||
const pid = str(id, 40); if (!pid) return null;
|
||
const ph = await tx.photo.findFirst({ where: { id: pid, facilityId }, select: { id: true } });
|
||
if (!ph) throw new OpError("Photo not found — take it again", 404);
|
||
return ph.id;
|
||
}
|
||
/** Move the pre-loved pool for a variant by `delta`, never below zero. */
|
||
async function poolAdd(tx: Tx, facilityId: string, itemId: string, si: number, delta: number) {
|
||
const cur = await tx.stockLevel.findUnique({ where: { itemId_sizeIndex: { itemId, sizeIndex: si } }, select: { preloved: true } });
|
||
await upsertLevel(tx, facilityId, itemId, si, { preloved: Math.max(0, (cur?.preloved || 0) + delta) });
|
||
}
|
||
/** Make sure the directory holds this supplier, and hand back the name the DIRECTORY spells it with.
|
||
*
|
||
* The match is deliberately case-insensitive, so a coordinator typing "northline workwear" on the
|
||
* counter phone finds the existing "Northline Workwear" and no second row is created. Storing what
|
||
* they typed against the garment is what did the damage: everything downstream matches the name
|
||
* exactly — the lead time behind an expected date, the contact block on a printed PO, the draft
|
||
* replenishment order a garment's lines are merged into — so the item pointed at a supplier the
|
||
* directory does not hold, and the room ended up with two draft orders for the one supplier.
|
||
* Callers store the name this returns, never the one that was typed. */
|
||
async function ensureSupplier(tx: Tx, facilityId: string, name: string): Promise<string> {
|
||
const n = str(name, 80).trim(); if (!n) return "";
|
||
const ex = await tx.supplier.findFirst({ where: { facilityId, name: { equals: n, mode: "insensitive" } } });
|
||
if (ex) return ex.name;
|
||
const count = await tx.supplier.count({ where: { facilityId } });
|
||
await tx.supplier.create({ data: { facilityId, name: n, sort: count } });
|
||
return n;
|
||
}
|
||
/** Expected delivery for an auto-created order: today + the supplier's lead time (14 days when none is set). */
|
||
async function expectedFor(tx: Tx, facilityId: string, supplier: string, today: string) {
|
||
const sp = await tx.supplier.findFirst({ where: { facilityId, name: supplier }, select: { lead: true } });
|
||
return addDays(today, sp?.lead && sp.lead > 0 ? sp.lead : 14);
|
||
}
|
||
/** Merge lines into the supplier's Draft replenishment order (creating one if needed). mode "add" increments, "max" tops up. */
|
||
async function mergeReplenish(tx: Tx, fid: string, today: string, sup: string, lines: { itemId: string; size: string; qty: number }[], mode: "add" | "max", notes: string) {
|
||
let o = await tx.order.findFirst({ where: { facilityId: fid, replenish: true, status: "Draft", supplier: sup }, include: { lines: true } });
|
||
if (!o) {
|
||
const code = await nextOrderCode(tx, fid, today);
|
||
o = await tx.order.create({ data: { facilityId: fid, code, date: today, source: "Supplier Order", orderFor: "Stock", supplier: sup, status: "Draft", expected: await expectedFor(tx, fid, sup, today), notes, replenish: true }, include: { lines: true } });
|
||
}
|
||
let n = 0;
|
||
for (const c of lines) {
|
||
const l = o.lines.find((x) => x.itemId === c.itemId && x.size === c.size);
|
||
if (l) await tx.orderLine.update({ where: { id: l.id }, data: mode === "add" ? { qty: { increment: c.qty } } : { qty: Math.max(l.qty, c.qty) } });
|
||
else { const nl = await tx.orderLine.create({ data: { orderId: o.id, itemId: c.itemId, size: c.size, qty: c.qty, sort: o.lines.length } }); o.lines.push(nl); }
|
||
n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** buildSnapshot() is shaped around a coordinator session, but the only things it reads off one are
|
||
* the facility id and the role — which gates the user list nothing here looks at. This stand-in
|
||
* lets a path with no coordinator (a ward sign-off) read the same ledger the counter reads,
|
||
* without inventing a second way to work out what is on the shelf. */
|
||
const stockReader = (facilityId: string): SessionUser =>
|
||
({ id: "", facilityId, email: "", first: "", last: "", title: "", role: "ISSUER", isDemo: false, viaSso: false });
|
||
|
||
/* Approved garments in a bag nobody has collected yet, for one person. The snapshot carries them
|
||
* for everybody, but it is read before the facility lock and, on the hand-over path, before the bag
|
||
* itself has moved — so every server path that asks the ceiling reads this person's afresh at the
|
||
* moment it decides, and replaces the snapshot's list rather than adding to it. A bag being handed
|
||
* over is therefore never counted as both owed and issued. */
|
||
async function owedRequestLines(db: Tx | typeof prisma, facilityId: string, staffId: string): Promise<{ staffId: string; itemId: string; qty: number }[]> {
|
||
const rows = await db.requestLine.findMany({
|
||
where: { status: "approved", request: { facilityId, subjectId: staffId, status: { in: AWAITING_HANDOVER } } },
|
||
select: { itemId: true, qty: true },
|
||
});
|
||
return rows.map((l) => ({ staffId, itemId: l.itemId, qty: l.qty }));
|
||
}
|
||
|
||
/* Handing a request's garments over — the half of the flow that touches stock.
|
||
*
|
||
* Two doors reach this moment. The counter is one (request.collected, below); the ward round is
|
||
* the other, where the person who signs for the bag holds a *staff* session and comes in through
|
||
* lib/staffops.ts. Both have to record the same three things or the shelf count silently loses a
|
||
* garment on one of the two routes: the shelf is re-checked, an Issue goes on the wearer's record
|
||
* at the catalogue cost, and a replenishment line puts the garment back on order.
|
||
*
|
||
* Only the APPROVED lines are in the bag. A line the manager knocked back never reaches the linen
|
||
* room, is never picked and must never come off the shelf — so approvedLines() is the one place
|
||
* "what is being handed over" is decided, here as everywhere else.
|
||
*
|
||
* `move` is the caller's own status change, run inside the same lock and after the shelf check, so
|
||
* a request only moves if the garments were actually there to hand over. It is the only part the
|
||
* two doors differ on: the counter moves ready → collected, the ward moves round → delivered.
|
||
*/
|
||
export async function handOverRequestStock(
|
||
facilityId: string,
|
||
today: string,
|
||
r: { subjectId: string; lines: readonly { itemId: string; sizeIndex: number; qty: number; status: string }[] },
|
||
move: (tx: Tx) => Promise<void>,
|
||
/* Which door the garments are leaving by. At the counter a short shelf is a reason to stop: the
|
||
* garment is still on the shelf (or isn't), and the coordinator can count or order. At the ward
|
||
* door the bag physically left the linen room on the trolley (request.round moved nothing), and
|
||
* the clerk signing for it can neither count the shelf nor order anything — and round.sign is
|
||
* the only way out of `round`, so a refusal there stranded the bag and the wearer's order for
|
||
* good. The ward door records the hand-over and lets the shelf go short, which the linen room's
|
||
* next count corrects; that is a stock discrepancy to fix at the counter, not a reason to tell
|
||
* a nurse her uniform didn't arrive. */
|
||
door: "counter" | "ward" = "counter",
|
||
): Promise<void> {
|
||
const lines = approvedLines(r.lines);
|
||
if (!lines.length) throw new OpError("Nothing on that request was approved, so there is nothing to hand over.");
|
||
await lockedTx(facilityId, async (tx) => {
|
||
const snap = await buildSnapshot(stockReader(facilityId), tx);
|
||
const led = ledger(snap);
|
||
const picked = lines.map((l) => {
|
||
const it = snap.catalog.find((x) => x.id === l.itemId);
|
||
if (!it) throw new OpError("A garment on that request is no longer in the catalogue", 404);
|
||
return { l, it, size: String(it.sizes[l.sizeIndex] ?? l.sizeIndex) };
|
||
});
|
||
// Totalled per garment and size before anything moves. Two approved lines for the same
|
||
// variant have to clear the shelf together, or the second is checked against stock the first
|
||
// has already taken and the bag leaves the room with one garment more than the shelf held.
|
||
const need = new Map<string, { qty: number; what: string }>();
|
||
for (const p of picked) {
|
||
const k = key(p.l.itemId, p.l.sizeIndex);
|
||
need.set(k, { qty: (need.get(k)?.qty || 0) + p.l.qty, what: `${p.it.item} ${p.size}` });
|
||
}
|
||
for (const [k, n] of need) {
|
||
if (door === "counter" && n.qty > onhand(snap, led, k)) {
|
||
throw new OpError(`Not enough ${n.what} on the shelf to hand over — count the shelf or order it in first.`);
|
||
}
|
||
}
|
||
await move(tx);
|
||
// This bag stopped being owed the moment `move` marked it collected or delivered, and it is about
|
||
// to become issue rows below. Every other approved bag this person hasn't collected is still owed
|
||
// and still counts, so they are read now — after the move, inside the same lock — which is what
|
||
// keeps this bag counted once, as it is handed over, rather than once as owed and again as taken.
|
||
// Counted twice, an ordinary collection at five sets would be stamped as an override.
|
||
snap.owedRequestLines = await owedRequestLines(tx, facilityId, r.subjectId);
|
||
// Past six sets can't block a hand-over the manager has already approved — the ward door records
|
||
// rather than refuses — but it is still stamped as an override so the exceptions report keeps
|
||
// seeing it. The running total matters now a request can carry several garments: the third
|
||
// garment in one bag is measured against a ceiling the first two have already filled, which the
|
||
// snapshot — read before any of these issues existed — cannot see on its own. So the bag grows a
|
||
// line at a time and the whole of it so far goes to capCheck(), which measures what this person
|
||
// holds plus what is being handed over.
|
||
//
|
||
// "Override" on these rows means what it means at the counter and nothing else: this garment took
|
||
// somebody past the six sets one person holds. Asking the counter's own question keeps it that
|
||
// way. A ward route that stamped the flag on a bag the linen room would have handed over without
|
||
// one turns the exceptions report into a list of which door somebody walked through, and every
|
||
// line here is there because a manager approved it — a kitchen hand collecting an approved set on
|
||
// the ward should read no differently from the same collection at the counter.
|
||
//
|
||
// No staff-group check either, and no uniform-style one: a manager approved this bag, and a
|
||
// hand-over is not a second chance to refuse it. But a garment outside the wearer's group is still
|
||
// stamped offGroup, and one that is not their cut offStyle, so the exceptions report sees both.
|
||
// Both doors that raise a request refuse such a garment now, yet one can still arrive here: a
|
||
// request raised before the rule existed, a wearer moved to another group or set to a style since
|
||
// they asked, or a damage replacement of a garment they were already holding.
|
||
const st = snap.staff.find((x) => x.id === r.subjectId);
|
||
const taken: { itemId: string; qty: number }[] = [];
|
||
for (const p of picked) {
|
||
taken.push({ itemId: p.l.itemId, qty: p.l.qty });
|
||
const over = st ? capCheck(snap, st, taken).over : false;
|
||
await tx.issue.create({ data: { facilityId, date: today, staffId: r.subjectId, itemId: p.l.itemId, sizeIndex: p.l.sizeIndex, qty: p.l.qty, cond: "New", cost: p.it.cost, override: over, offGroup: st ? !garmentForGroup(p.it, st.group) : false, offStyle: st ? !garmentForStyle(p.it, st.uniformStyle) : false } });
|
||
}
|
||
// Replenishment is grouped by supplier, because a replenishment order is one supplier's order:
|
||
// a bag holding a tunic from one supplier and a fleece from another puts a line on each of
|
||
// their draft orders rather than inventing a mixed one nobody can send.
|
||
const bySupplier = new Map<string, { itemId: string; size: string; qty: number }[]>();
|
||
for (const p of picked) {
|
||
const sup = p.it.supplier || snap.settings.suppliers[0] || "Supplier";
|
||
bySupplier.set(sup, [...(bySupplier.get(sup) || []), { itemId: p.l.itemId, size: p.size, qty: p.l.qty }]);
|
||
}
|
||
for (const [sup, rows] of bySupplier) {
|
||
await mergeReplenish(tx, facilityId, today, sup, rows, "add", "Replenishment — replaces issued stock");
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ---------- the garments on a request ----------
|
||
*
|
||
* A nurse who needs a tunic, trousers and a fleece makes ONE ask. Both doors that raise a request
|
||
* — the staff app's raise path in lib/staffops.ts and the counter below — read their garments
|
||
* through here, so the limits, the duplicate rule and the wording of a refusal are stated once.
|
||
*/
|
||
export type RequestLineInput = { itemId: string; sizeIndex: number; qty: number; item: string; size: string };
|
||
/** Ten garments is a long way past a full set of uniform, and a cap keeps one tap from writing a
|
||
* hundred lines the manager then has to read. Twenty of one garment is the same idea per line. */
|
||
export const REQUEST_MAX_LINES = 10;
|
||
export const REQUEST_MAX_QTY = 20;
|
||
|
||
/** Read and check the garments off a request payload: `[{ itemId, si, qty }]`. Throws OpError, so
|
||
* the staff-app door translates it into its own error type. */
|
||
export async function readRequestLines(facilityId: string, raw: unknown): Promise<RequestLineInput[]> {
|
||
const rows = Array.isArray(raw) ? raw : [];
|
||
if (!rows.length) throw new OpError("Add at least one garment to the request");
|
||
if (rows.length > REQUEST_MAX_LINES) throw new OpError(`One request covers up to ${REQUEST_MAX_LINES} garments — raise a second one for the rest.`);
|
||
const wanted = [...new Set(rows.map((r) => str((r as { itemId?: unknown } | null)?.itemId)))].filter(Boolean);
|
||
const items = await prisma.catalogItem.findMany({ where: { id: { in: wanted }, facilityId } });
|
||
const byId = new Map(items.map((i) => [i.id, i]));
|
||
const out: RequestLineInput[] = [];
|
||
for (const row of rows) {
|
||
const r = (row || {}) as { itemId?: unknown; si?: unknown; qty?: unknown };
|
||
const it = byId.get(str(r.itemId));
|
||
if (!it || it.archived) throw new OpError("That garment isn't available", 404);
|
||
const si = int(r.si, -1);
|
||
if (si < 0 || si >= it.sizes.length) throw new OpError(`Pick a size for the ${it.item}`);
|
||
const qty = int(r.qty, 1);
|
||
if (qty < 1 || qty > REQUEST_MAX_QTY) throw new OpError(`Ask for between 1 and ${REQUEST_MAX_QTY} of a garment`);
|
||
// The same garment in the same size twice is one line with the quantity added up, not two
|
||
// identical rows: it is one decision for the manager and one line on the pick list, and a
|
||
// screen that lets somebody tap a size twice should not double everything downstream.
|
||
const same = out.find((x) => x.itemId === it.id && x.sizeIndex === si);
|
||
if (same) {
|
||
same.qty += qty;
|
||
if (same.qty > REQUEST_MAX_QTY) throw new OpError(`Ask for between 1 and ${REQUEST_MAX_QTY} of ${it.item}`);
|
||
} else out.push({ itemId: it.id, sizeIndex: si, qty, item: it.item, size: String(it.sizes[si]) });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** One reason a cart is outside what this person is normally handed: each garment with the groups it
|
||
* is for, then the group the person is in. A clause, not a whole refusal — issueRefusal() adds the
|
||
* sentence about the override, so a cart wrong on two counts asks for the tick once. */
|
||
function offGroupRefusal(name: string, group: string, off: readonly { item: string; groups: string[] }[]): string {
|
||
const each = off.map((i) => `${i.item} is for ${groupsLabel(i.groups)}`).join("; ");
|
||
const g = (group || "").trim();
|
||
const who = g ? `${name} is in ${g}` : `${name} has no staff group recorded`;
|
||
return `${each} — ${who}`;
|
||
}
|
||
/** The same clause for the wrong cut: each garment with the cut it is, then the style this person is
|
||
* set to. Only ever built when the list is non-empty, which garmentForStyle() only allows for
|
||
* somebody set to Men's or Women's — blank and Either are offered everything. */
|
||
function offStyleRefusal(name: string, style: string, off: readonly { item: string; gender: string }[]): string {
|
||
const each = off.map((i) => `${i.item} is the ${genderLabel(i.gender)} cut`).join("; ");
|
||
return `${each} — ${name} is set to ${style}`;
|
||
}
|
||
/** The counter's refusal for a cart the coordinator's override would let through.
|
||
*
|
||
* EVERY reason that applies is named in it — outside their staff group, the wrong cut, past the sets
|
||
* one person holds — because one tick answers all of them at once. A refusal that named the first
|
||
* and stopped would have the coordinator tick the box for that and wave the others through without
|
||
* anybody ever having been told about them. */
|
||
function issueRefusal(reasons: readonly string[], count: number): string {
|
||
return `${reasons.join(". ")}. Tick the coordinator override to issue ${count === 1 ? "it" : "them"} anyway.`;
|
||
}
|
||
|
||
/** The garments among these that are not for this staff group, in the order asked for — the check
|
||
* both request doors (request.create in lib/staffops.ts, request.raise here) and the staff-member
|
||
* order paths make. garmentForGroup() is the rule; this only loads the catalogue rows it needs.
|
||
*
|
||
* `replacing` is the one exemption: a damage replacement for a garment the person is holding right
|
||
* now (lib/staffops.ts damage.report). Only that garment, and only while the record still has it
|
||
* out with them. A fresh request for another group's garment is refused even from somebody who
|
||
* was issued one before the rule existed — holding one is not a reason to be handed another. */
|
||
export async function offGroupGarments(facilityId: string, group: string, itemIds: readonly string[], replacing?: { staffId: string; itemId: string }) {
|
||
const ids = [...new Set(itemIds)];
|
||
if (!ids.length) return [];
|
||
const items = await prisma.catalogItem.findMany({ where: { id: { in: ids }, facilityId }, select: { id: true, item: true, groups: true } });
|
||
const off = items.filter((i) => !garmentForGroup(i, group));
|
||
if (!off.length) return [];
|
||
const exempt = replacing && off.some((i) => i.id === replacing.itemId)
|
||
&& (await prisma.issue.count({ where: { facilityId, staffId: replacing.staffId, itemId: replacing.itemId, returnedDate: null, handedIn: null } })) > 0
|
||
? replacing.itemId : null;
|
||
return ids.map((id) => off.find((i) => i.id === id)).filter((i): i is (typeof off)[number] => !!i && i.id !== exempt);
|
||
}
|
||
|
||
/** The garments among these that are not the cut this person is offered, in the order asked for —
|
||
* the same check offGroupGarments() makes, asked of Uniform style instead of the staff group.
|
||
* garmentForStyle() is the rule; this only loads the catalogue rows it needs.
|
||
*
|
||
* `replacing` is the same one exemption, for the same reason: a damage replacement for a garment
|
||
* the person is holding right now. Somebody set to Men's who was issued the women's cut before
|
||
* anybody set the field can have that garment replaced like for like, and nothing else. */
|
||
export async function offStyleGarments(facilityId: string, style: string, itemIds: readonly string[], replacing?: { staffId: string; itemId: string }) {
|
||
const ids = [...new Set(itemIds)];
|
||
if (!ids.length) return [];
|
||
const items = await prisma.catalogItem.findMany({ where: { id: { in: ids }, facilityId }, select: { id: true, item: true, gender: true } });
|
||
const off = items.filter((i) => !garmentForStyle(i, style));
|
||
if (!off.length) return [];
|
||
const exempt = replacing && off.some((i) => i.id === replacing.itemId)
|
||
&& (await prisma.issue.count({ where: { facilityId, staffId: replacing.staffId, itemId: replacing.itemId, returnedDate: null, handedIn: null } })) > 0
|
||
? replacing.itemId : null;
|
||
return ids.map((id) => off.find((i) => i.id === id)).filter((i): i is (typeof off)[number] => !!i && i.id !== exempt);
|
||
}
|
||
|
||
/** The counter's refusal when it raises a request, or writes an order, for somebody with garments
|
||
* outside their staff group on it. Neither has an override of its own: the one way to give
|
||
* somebody another group's garment is the Issue screen with the coordinator override ticked, which
|
||
* stamps the issue — or, for Order in, the order it raises — so the Exceptions report sees it.
|
||
* `onOrder` is for garments already on the order being changed, which can also simply come off. */
|
||
function offGroupElsewhere(st: { first: string; last: string; group: string }, off: readonly { item: string; groups: string[] }[], doing: "request" | "order", onOrder = false): string {
|
||
const n = off.length === 1 ? "it" : "them";
|
||
const each = off.map((i) => `${i.item} is for ${groupsLabel(i.groups)} only`).join("; ");
|
||
const name = `${st.first} ${st.last}`.trim();
|
||
const g = (st.group || "").trim();
|
||
const who = g ? `${name} is in ${g}` : `${name} has no staff group recorded`;
|
||
return doing === "request"
|
||
? `${each} — ${who}, and a request can only carry their own group's garments. To give ${n} to ${st.first} anyway, issue ${n} on the Issue screen with the coordinator override ticked.`
|
||
: `${each} — ${who}, and an order for somebody can only carry their own group's garments. ${onOrder ? `Take ${n} off this order first, or to` : "To"} order ${n} in for ${st.first} anyway, use Order in on the Issue screen with the coordinator override ticked.`;
|
||
}
|
||
|
||
/** The same refusal for the wrong cut, said the same way and pointing at the same one override. */
|
||
function offStyleElsewhere(st: { first: string; last: string; uniformStyle: string }, off: readonly { item: string; gender: string }[], doing: "request" | "order", onOrder = false): string {
|
||
const n = off.length === 1 ? "it" : "them";
|
||
const each = off.map((i) => `${i.item} is the ${genderLabel(i.gender)} cut`).join("; ");
|
||
const who = `${`${st.first} ${st.last}`.trim()} is set to ${st.uniformStyle}`;
|
||
return doing === "request"
|
||
? `${each} — ${who}, and a request can only carry their own style. To give ${n} to ${st.first} anyway, issue ${n} on the Issue screen with the coordinator override ticked.`
|
||
: `${each} — ${who}, and an order for somebody can only carry their own style. ${onOrder ? `Take ${n} off this order first, or to` : "To"} order ${n} in for ${st.first} anyway, use Order in on the Issue screen with the coordinator override ticked.`;
|
||
}
|
||
|
||
/** Every path that puts a garment on an order for a staff member asks this: creating one, copying
|
||
* one, attaching the person to one, adding a line, raising a quantity. What reaches them off it is
|
||
* issued at pickup with nobody deciding anything, so the order is the moment to refuse. Orders the
|
||
* counter raises itself (issue.create's Order in, and the back orders split off those) are not
|
||
* written through here — the override was ticked for them, and the order's note says so. */
|
||
async function refuseWrongGarmentOnOrder(facilityId: string, staff: { first: string; last: string; group: string; uniformStyle: string } | null, itemIds: readonly string[], onOrder = false) {
|
||
if (!staff) return;
|
||
const off = await offGroupGarments(facilityId, staff.group, itemIds);
|
||
if (off.length) throw new OpError(offGroupElsewhere(staff, off, "order", onOrder));
|
||
// The cut, asked after the group and refused the same way. Neither has an override of its own, so
|
||
// there is nothing for one refusal to wave through by naming only the other: whichever it names,
|
||
// the answer is the same Issue screen with the same tick.
|
||
const offStyle = await offStyleGarments(facilityId, staff.uniformStyle, itemIds);
|
||
if (offStyle.length) throw new OpError(offStyleElsewhere(staff, offStyle, "order", onOrder));
|
||
}
|
||
/** The staff member an existing order is for, or null for a stock order. */
|
||
const orderStaff = (facilityId: string, o: { staffId: string | null }) =>
|
||
o.staffId ? prisma.staff.findFirst({ where: { id: o.staffId, facilityId }, select: { first: true, last: true, group: true, uniformStyle: true } }) : Promise.resolve(null);
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
/** Ops the public demo facility refuses — everyone shares its accounts, so nobody may change them or nuke the data. */
|
||
// staff.selfCode is blocked in the demo for a reason worth stating: a claimed staff account holds a
|
||
// globally unique email, so a visitor activating one against demo data would take that address out
|
||
// of circulation for the real facility that later needs it.
|
||
const DEMO_BLOCKED = new Set(["users.add", "users.update", "users.remove", "me.password", "me.profile", "data.wipeActivity", "data.reset", "backup.restore", "me.deleteAccount", "staff.selfCode"]);
|
||
/** Settings a demo visitor may not change — see settings.update for why each one is here. Named in
|
||
* plain words because a refusal that says which field it means is the difference between a message
|
||
* and a mystery. */
|
||
const DEMO_FIXED: Record<string, string> = {
|
||
logoData: "the slip logo",
|
||
facility: "the facility name",
|
||
location: "the stock location",
|
||
coordinator: "the coordinator name",
|
||
// The linen room's own contacts, for the same reason as the coordinator's name and then one
|
||
// more: they print in the footer of every order form, and in a facility everyone is sharing a
|
||
// visitor could put a stranger's e-mail address and phone number in front of every other visitor
|
||
// and onto everything they print. The allowance settings next to them — the starting kit, the set
|
||
// ceiling, which route each group is on — stay open, because changing those moves numbers about in a
|
||
// sandbox that resets in twenty minutes and puts nobody's contact details anywhere.
|
||
coordinatorEmail: "the linen room's e-mail address",
|
||
coordinatorPhone: "the linen room's phone number",
|
||
slipOrg: "the organisation name on slips",
|
||
slipCollectionFooter: "the collection slip footer",
|
||
slipDeliveryFooter: "the delivery slip footer",
|
||
timezone: "the time zone",
|
||
};
|
||
export function demoGuard(user: SessionUser, op: string) {
|
||
if (user.isDemo && DEMO_BLOCKED.has(op)) throw new OpError("Not available in the demo — it resets every 20 minutes and its accounts are shared.", 403);
|
||
}
|
||
|
||
/** The plan's answer to a write, from the same row runOp already has. Read-only refuses everything
|
||
* but sorting the plan out and leaving (READ_ONLY_ALLOWED); the staff ceiling is checked where a
|
||
* staff row is created, with `staffRoom`. */
|
||
export function planGuard(f: PlanRow, op: string) {
|
||
if (entitlements(f).readOnly && !READ_ONLY_ALLOWED.has(op)) throw new OpError(READ_ONLY_REFUSAL, 403);
|
||
}
|
||
|
||
/** How many more staff records this facility may create, or null for no ceiling. */
|
||
async function staffRoom(fid: string, db: { facility: { findUniqueOrThrow: typeof prisma.facility.findUniqueOrThrow }; staff: { count: typeof prisma.staff.count } } = prisma): Promise<{ room: number | null; max: number | null }> {
|
||
const f = await db.facility.findUniqueOrThrow({ where: { id: fid }, select: PLAN_COLS });
|
||
const max = entitlements(f).maxStaff;
|
||
if (max === null) return { room: null, max: null };
|
||
const have = await db.staff.count({ where: { facilityId: fid } });
|
||
return { room: Math.max(0, max - have), max };
|
||
}
|
||
|
||
export async function runOp(user: SessionUser, op: string, p: any): Promise<unknown> {
|
||
demoGuard(user, op);
|
||
const fid = user.facilityId;
|
||
// Every date-only column an op writes is "today" where the linen room stands, not where the
|
||
// server does — a Perth room issuing at 8am must not file the issue against yesterday because
|
||
// the box is set to UTC. One lookup up front, shared with the name in the foot of an email — and
|
||
// with the plan columns, so a read-only room is refused before any case below runs.
|
||
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: fid }, select: { name: true, timezone: true, billingEmail: true, ...PLAN_COLS } });
|
||
planGuard(fac, op);
|
||
const tz = fac.timezone;
|
||
const today = facilityToday(tz);
|
||
const byName = `${user.first} ${user.last}`;
|
||
/** The facility's own name, for the foot of an email to a wearer. */
|
||
const signOff = () => `${fac.name || "Linen room"} · ThreadCount`;
|
||
p = p || {};
|
||
|
||
switch (op) {
|
||
// ---------- settings
|
||
case "settings.update": {
|
||
admin(user);
|
||
// Everyone shares the demo facility, so anything a visitor changes here lands on every other
|
||
// visitor's screen at once — including the director of nursing or the Play reviewer who
|
||
// clicked through from the store listing. Two kinds of field are fixed there: the chrome that
|
||
// appears on every screen and every printed slip, and the time zone, which decides the day
|
||
// every date-only column is filed against — move the demo to Honolulu and the dashboard, the
|
||
// exceptions report and the cost-centre journal re-date themselves under everyone else. The
|
||
// rest of the screen stays a sandbox on purpose. Refused here rather than quietly dropped:
|
||
// greying a control out is the browser's opinion and a POST to /api/mutate never sees it,
|
||
// and the screen — which sends one field per edit — used to answer "Saved." over a value
|
||
// the server had already thrown away, which the next reload exposed as a lie.
|
||
if (user.isDemo) {
|
||
const fixed = Object.keys(DEMO_FIXED).find((k) => p[k] !== undefined);
|
||
if (fixed) throw new OpError(`The demo is one facility shared with everyone looking at it right now, so ${DEMO_FIXED[fixed]} is fixed here. The rest of this screen is yours to change.`, 403);
|
||
}
|
||
const data: Prisma.FacilityUpdateInput = {};
|
||
if (p.facility !== undefined) data.name = str(p.facility, 120) || "Facility";
|
||
if (p.location !== undefined) data.location = str(p.location, 120);
|
||
if (p.coordinator !== undefined) data.coordinator = str(p.coordinator, 120);
|
||
if (p.coordinatorEmail !== undefined) {
|
||
// The footer of the printed order form, and the only place the linen room's own address is
|
||
// held: nothing about a customer is written into the product, so a blank here prints a
|
||
// blank footer rather than falling back to something invented. Checked because the form is
|
||
// what a manager replies to — an address with a typo in it is a form nobody can answer, and
|
||
// nobody finds out until a ward has been waiting a fortnight.
|
||
const em = str(p.coordinatorEmail, 160).trim().toLowerCase();
|
||
if (em && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(em)) throw new OpError("That doesn't look like an e-mail address. It prints on the order form, so a manager has to be able to reply to it.");
|
||
data.coordinatorEmail = em;
|
||
}
|
||
if (p.coordinatorPhone !== undefined) {
|
||
// Digits, in whatever shape the site writes them — an extension, a pager, a switchboard
|
||
// number with a prefix are all real answers, so only an entry with no number in it at all
|
||
// is refused. A footer reading "linen room" with nothing to ring is worse than a blank one.
|
||
const ph = str(p.coordinatorPhone, 40).trim();
|
||
if (ph && !/\d/.test(ph)) throw new OpError("A phone number needs some digits in it — leave it blank if the linen room doesn't take calls.");
|
||
data.coordinatorPhone = ph;
|
||
}
|
||
// Garments a year, and a reporting figure only: it is what the register, the monthly report and
|
||
// the exceptions list measure a year's drawing against. It stopped being what the counter
|
||
// enforces when the ceiling became six sets held at any time — capSets below — so a site can
|
||
// move this number, or leave it where it has always been, without a soul being turned away at
|
||
// the counter for it.
|
||
if (p.defaultEntitlement !== undefined) data.defaultEntitlement = Math.max(0, int(p.defaultEntitlement));
|
||
// The starting kit for the groups on the starting-kit route (kitGroups), in SETS — a set being
|
||
// a top and a bottom, so the number of garments is twice this. Kept apart from
|
||
// defaultEntitlement, which still counts garments per financial year for the reports that
|
||
// read it. What a blank or a nought falls back to is setsOnStart's answer rather than one of
|
||
// ours: a kit of nought is not a kit, it is an order form that proposes nothing to somebody
|
||
// starting on Monday, and the standing figure it falls back to has to be the same one the
|
||
// wearer's own app quotes them.
|
||
if (p.initialSets !== undefined) data.initialSets = setsOnStart(int(p.initialSets, 0));
|
||
// The ceiling, for everybody in the building, whichever route their group is on. In SETS, like
|
||
// the kit above, so twelve garments at the standing six, and it is a ceiling on what a person
|
||
// HOLDS at any one time rather than an allowance that starts again in July. The routes differ
|
||
// only in how somebody gets up to it — on the FTE table their hours propose a number, on the
|
||
// starting kit they start with one and collect more as needed, on manager approval they
|
||
// collect a set per signature — and this is where all three stop. Past it the ways on are a hand-in, which swaps rather than adds, and a
|
||
// coordinator's override, recorded as the exception it is.
|
||
//
|
||
// What a blank, a nought or a nonsense entry falls back to is setsCap's answer rather than one
|
||
// of ours — the standing six — for the same reason initialSets defers to setsOnStart above:
|
||
// sets.ts applies that fallback again every time an allowance is worked out, so a second rule
|
||
// here would mean the number stored from this screen and the number quoted to the wearer
|
||
// could differ. A ceiling of nought is not a strict facility either way; it is a facility where
|
||
// nobody at all can be issued so much as a shirt without a coordinator standing over it.
|
||
if (p.capSets !== undefined) data.capSets = setsCap(int(p.capSets, 0));
|
||
// Which route each of this facility's staff groups is on: nursingGroups for the FTE table,
|
||
// kitGroups for the starting kit, neither for manager approval. Each is sent as a whole list,
|
||
// and an empty one means exactly that — no group on that route. There is no list of ours to
|
||
// fall back on.
|
||
//
|
||
// Names are not checked against staffGroups. People imported from a roster can be filed under
|
||
// a group the settings list never had, and refusing that name here would leave them with no
|
||
// route anybody could put them on. A group nobody is in matches nobody.
|
||
const nursingIn = p.nursingGroups !== undefined ? groupList(p.nursingGroups, "FTE table groups") : undefined;
|
||
const kitIn = p.kitGroups !== undefined ? groupList(p.kitGroups, "starting-kit groups") : undefined;
|
||
if (p.defaultReorder !== undefined) data.defaultReorder = Math.max(0, int(p.defaultReorder));
|
||
if (p.exceptionHigh !== undefined) data.exceptionHigh = Math.max(0, int(p.exceptionHigh));
|
||
if (p.varianceReason !== undefined) data.varianceReason = Math.max(1, int(p.varianceReason, 5));
|
||
if (p.timezone !== undefined) {
|
||
// The zone every date-only column in the product is written against (see facilityToday).
|
||
// A facility that cannot change it runs on the Brisbane default forever: a Perth late
|
||
// shift issuing at 22:30 on 30 June files the issue against 1 July, so the garment counts
|
||
// against the next financial year's entitlement, drops out of June's exceptions report and
|
||
// cost-centre journal and turns up in July's. Checked against the runtime's own zone table
|
||
// rather than stored as typed — a name this box has never heard of would leave every date
|
||
// computed against a fallback while this screen claimed otherwise.
|
||
const z = str(p.timezone, 60).trim();
|
||
if (!z) throw new OpError("Pick the time zone the linen room is in");
|
||
try { new Intl.DateTimeFormat("en-AU", { timeZone: z }); } catch { throw new OpError(`${z} isn't a time zone this system knows — use a name like Australia/Perth`); }
|
||
data.timezone = z;
|
||
}
|
||
if (p.glAccount !== undefined) data.glAccount = str(p.glAccount, 40);
|
||
if (p.journalDesc !== undefined) data.journalDesc = str(p.journalDesc, 120);
|
||
const staffIn = Array.isArray(p.staffGroups) ? [...new Set(p.staffGroups.map((s: unknown) => str(s, 80).trim()).filter(Boolean))] as string[] : undefined;
|
||
if (p.slipCollectionFooter !== undefined) data.slipCollectionFooter = str(p.slipCollectionFooter, 400);
|
||
if (p.slipDeliveryFooter !== undefined) data.slipDeliveryFooter = str(p.slipDeliveryFooter, 400);
|
||
if (p.slipOrg !== undefined) data.slipOrg = str(p.slipOrg, 120);
|
||
if (p.barcodeLookup !== undefined) data.barcodeLookup = !!p.barcodeLookup;
|
||
if (p.logoData !== undefined) {
|
||
const l = String(p.logoData || "");
|
||
// SVG deliberately excluded: it can carry scripts and is served from the app origin.
|
||
if (l && !/^data:image\/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(l)) throw new OpError("Logo must be a PNG, JPG, GIF or WebP image");
|
||
if (l.length > LOGO_MAX * 1.4) throw new OpError("Logo is too large — keep it under 400 KB");
|
||
data.logoData = l;
|
||
}
|
||
if (nursingIn === undefined && kitIn === undefined && staffIn === undefined) {
|
||
await prisma.facility.update({ where: { id: fid }, data });
|
||
return { ok: true };
|
||
}
|
||
// The group lists are settled against what is stored, under the facility's lock, because each
|
||
// answer depends on the other lists: a save that read them and wrote a moment after somebody
|
||
// else's would put back whatever that person had just changed.
|
||
await lockedTx(fid, async (tx) => {
|
||
const cur = await tx.facility.findUniqueOrThrow({ where: { id: fid }, select: { staffGroups: true, nursingGroups: true, kitGroups: true } });
|
||
let nursing = nursingIn ?? cur.nursingGroups, kit = kitIn ?? cur.kitGroups;
|
||
if (staffIn) {
|
||
// A group taken off the list comes off both routes with it. Left on one, it would go on
|
||
// deciding the allowance of anybody still filed under it from a list the settings screen no
|
||
// longer shows, and nobody could see why. So a group that still has active staff filed under
|
||
// it can't be taken off at all — the same rule departments keep. Without it, a save from a
|
||
// screen opened before somebody else added or renamed a group would move a whole team to
|
||
// manager approval, with nothing on either screen to say so.
|
||
const kept = new Set(staffIn.map(groupKey));
|
||
const gone = new Set(cur.staffGroups.map(groupKey).filter((k) => !kept.has(k)));
|
||
if (gone.size) {
|
||
const filed = await tx.staff.findMany({ where: { facilityId: fid, inactive: false }, select: { group: true } });
|
||
const still = new Map<string, { name: string; n: number }>();
|
||
for (const x of filed) {
|
||
const k = groupKey(x.group);
|
||
if (gone.has(k)) still.set(k, { name: x.group.trim(), n: (still.get(k)?.n || 0) + 1 });
|
||
}
|
||
const first = [...still.values()][0];
|
||
if (first) throw new OpError(`${first.name} still has ${first.n} ${first.n === 1 ? "person" : "people"} filed under it. Move them to another group, or rename it instead, before taking it off the list.`);
|
||
}
|
||
nursing = nursing.filter((g) => !gone.has(groupKey(g)));
|
||
kit = kit.filter((g) => !gone.has(groupKey(g)));
|
||
data.staffGroups = staffIn;
|
||
}
|
||
// One route per group. Refused by name rather than settled by a rule, because a group quietly
|
||
// dropped from one list is a team's first kit changing with nothing on the screen to say so.
|
||
// Moving a group from one route to the other is one save carrying both lists. Asked only when
|
||
// a route list is being set, so adding or removing a staff group is never refused over it.
|
||
if (nursingIn || kitIn) {
|
||
const both = nursing.find((g) => isKitGroup(kit, g));
|
||
if (both) throw new OpError(`${both} can't be on the FTE table and the starting kit at once. Each group takes one route — take it off one before putting it on the other.`);
|
||
}
|
||
data.nursingGroups = nursing;
|
||
data.kitGroups = kit;
|
||
await tx.facility.update({ where: { id: fid }, data });
|
||
});
|
||
return { ok: true };
|
||
}
|
||
case "settings.renameGroup": {
|
||
admin(user);
|
||
// A staff group's name corrected everywhere the name is held — the settings list, both route
|
||
// lists, and every staff record filed under it — in one go. The name is what decides somebody's
|
||
// route, so a rename that missed any of those would move people onto a different allowance for
|
||
// the sake of a spelling, and nobody's allowance may change because a label was tidied up. Its
|
||
// own op rather than a removal and an addition, because the lists alone cannot tell the two
|
||
// apart, and a removal takes the group off its route.
|
||
const from = str(p.from, 80).trim(), to = str(p.to, 80).trim();
|
||
if (!from) throw new OpError("Say which staff group to rename");
|
||
if (!to) throw new OpError("A staff group needs a name");
|
||
const fromK = groupKey(from), toK = groupKey(to);
|
||
const moved = await lockedTx(fid, async (tx) => {
|
||
const cur = await tx.facility.findUniqueOrThrow({ where: { id: fid }, select: { staffGroups: true, nursingGroups: true, kitGroups: true } });
|
||
const filedAs = (await tx.staff.findMany({ where: { facilityId: fid }, select: { group: true }, distinct: ["group"] })).map((x) => x.group);
|
||
const names = [...cur.staffGroups, ...cur.nursingGroups, ...cur.kitGroups, ...filedAs];
|
||
if (!names.some((g) => groupKey(g) === fromK)) throw new OpError(`${from} isn't one of this facility's staff groups`, 404);
|
||
// Renaming onto a name already in use is a merge, not a rename: two teams under one name, and
|
||
// whichever route the other was on quietly becoming this one's too. The same refusal a
|
||
// department gets, for the same reason. Changing only the case or the spacing is allowed —
|
||
// that is the same group, spelt better.
|
||
if (toK !== fromK && names.some((g) => groupKey(g) === toK)) throw new OpError(`${to} is already a staff group here. Renaming ${from} to it would put two groups under one name — pick a name nobody is using.`);
|
||
const swap = (list: string[]) => [...new Set(list.map((g) => (groupKey(g) === fromK ? to : g)))];
|
||
await tx.facility.update({ where: { id: fid }, data: { staffGroups: swap(cur.staffGroups), nursingGroups: swap(cur.nursingGroups), kitGroups: swap(cur.kitGroups) } });
|
||
const spellings = filedAs.filter((g) => groupKey(g) === fromK && g !== to);
|
||
// Garments tagged for the group follow it too. Left under the old name they would be for a
|
||
// group nobody is in any more, and the staff app refuses a garment outside the person's group.
|
||
const tagged = await tx.catalogItem.findMany({ where: { facilityId: fid, NOT: { groups: { isEmpty: true } } }, select: { id: true, groups: true } });
|
||
let garments = 0;
|
||
for (const it of tagged) {
|
||
if (!it.groups.some((g) => groupKey(g) === fromK && g !== to)) continue;
|
||
await tx.catalogItem.update({ where: { id: it.id }, data: { groups: garmentGroups(it.groups.map((g) => (groupKey(g) === fromK ? to : g))) } });
|
||
garments++;
|
||
}
|
||
const staff = spellings.length ? (await tx.staff.updateMany({ where: { facilityId: fid, group: { in: spellings } }, data: { group: to } })).count : 0;
|
||
return { staff, garments };
|
||
});
|
||
return { ok: true, staff: moved.staff, garments: moved.garments };
|
||
}
|
||
|
||
// ---------- suppliers
|
||
case "supplier.add": {
|
||
admin(user);
|
||
const name = str(p.name, 80).trim(); if (!name) throw new OpError("Supplier name required");
|
||
const dup = await prisma.supplier.findFirst({ where: { facilityId: fid, name: { equals: name, mode: "insensitive" } } });
|
||
if (dup) throw new OpError("That supplier is already on the list");
|
||
const count = await prisma.supplier.count({ where: { facilityId: fid } });
|
||
const s = await prisma.supplier.create({ data: { facilityId: fid, name, sort: count } });
|
||
return { id: s.id };
|
||
}
|
||
case "supplier.update": {
|
||
admin(user);
|
||
const s = await prisma.supplier.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!s) throw new OpError("Unknown supplier", 404);
|
||
const data: Prisma.SupplierUpdateInput = {};
|
||
for (const k of ["contact", "phone", "account"] as const) if (p[k] !== undefined) data[k] = str(p[k], 120);
|
||
if (p.lead !== undefined) data.lead = p.lead === "" || p.lead === null ? null : Math.max(0, int(p.lead));
|
||
await prisma.supplier.update({ where: { id: s.id }, data });
|
||
return { ok: true };
|
||
}
|
||
case "supplier.remove": {
|
||
admin(user);
|
||
const s = await prisma.supplier.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!s) throw new OpError("Unknown supplier", 404);
|
||
const used = await prisma.catalogItem.count({ where: { facilityId: fid, supplier: s.name } }) + await prisma.order.count({ where: { facilityId: fid, supplier: s.name } });
|
||
if (used) throw new OpError("This supplier has products or orders and can't be removed.");
|
||
await prisma.supplier.delete({ where: { id: s.id } });
|
||
return { ok: true };
|
||
}
|
||
|
||
// ---------- catalogue
|
||
case "catalog.add": {
|
||
admin(user);
|
||
const sizes = (Array.isArray(p.sizes) ? p.sizes : []).map((s: unknown) => str(s, 20).trim()).filter(Boolean);
|
||
if (!str(p.item).trim()) throw new OpError("Item name required");
|
||
if (!sizes.length) throw new OpError("At least one size required");
|
||
// Quick-add from a scan passes the barcode(s) along so the code is bound in the same write —
|
||
// no window where the item exists but the code that created it isn't attached to anything.
|
||
const codes = (Array.isArray(p.barcodes) ? p.barcodes : []).map((b: { si?: unknown; code?: unknown }) => ({ si: int(b?.si, -1), code: str(b?.code, 64).trim() })).filter((b: { si: number; code: string }) => b.code);
|
||
for (const b of codes) if (b.si < 0 || b.si >= sizes.length) throw new OpError("The scanned barcode has to point at one of the sizes you picked");
|
||
if (new Set(codes.map((b: { code: string }) => b.code)).size !== codes.length) throw new OpError("The same barcode is on two sizes — each size needs its own code");
|
||
for (const b of codes) await assertBindable(prisma, fid, b.code, "", b.si, false);
|
||
// Opening stock entered on the form: the counted quantity already on the shelf, per size.
|
||
const opens = (Array.isArray(p.opening) ? p.opening : []).map((o: { si?: unknown; qty?: unknown }) => ({ si: int(o?.si, -1), qty: int(o?.qty, 0) })).filter((o: { si: number; qty: number }) => o.qty !== 0);
|
||
for (const o of opens) {
|
||
if (o.si < 0 || o.si >= sizes.length) throw new OpError("Opening stock has to point at one of the sizes you picked");
|
||
if (o.qty < 0) throw new OpError("Opening stock can't be negative");
|
||
}
|
||
const groups = (await groupsFromPayload(prisma, fid, p, null)) ?? [];
|
||
return prisma.$transaction(async (tx) => {
|
||
const sort = await nextSort(tx, fid);
|
||
const supplier = await ensureSupplier(tx, fid, str(p.supplier, 80));
|
||
const it = await tx.catalogItem.create({ data: { facilityId: fid, sort, item: str(p.item, 160).trim(), gender: str(p.gender, 20) || "Unisex", type: str(p.type, 40).trim(), groups, sku: str(p.sku, 60).trim(), supplier, cost: Math.max(0, num(p.cost)), notes: str(p.notes, 400), sizes } });
|
||
for (const b of codes) await tx.barcode.upsert({ where: { facilityId_code: { facilityId: fid, code: b.code } }, create: { facilityId: fid, code: b.code, itemId: it.id, sizeIndex: b.si, source: "bound" }, update: { itemId: it.id, sizeIndex: b.si } });
|
||
for (const o of opens) await upsertLevel(tx, fid, it.id, o.si, { opening: o.qty });
|
||
// The opening figure, so the history starts at the beginning rather than at the first edit.
|
||
if (it.cost > 0) await tx.costChange.create({ data: { facilityId: fid, itemId: it.id, cost: it.cost, previous: null, byName } });
|
||
return { id: it.id };
|
||
});
|
||
}
|
||
case "catalog.update": {
|
||
admin(user);
|
||
const it = await ownItem(prisma, fid, str(p.id));
|
||
const data: Prisma.CatalogItemUpdateInput = {};
|
||
if (p.item !== undefined) data.item = str(p.item, 160).trim() || it.item;
|
||
if (p.gender !== undefined) data.gender = str(p.gender, 20);
|
||
if (p.type !== undefined) data.type = str(p.type, 40).trim();
|
||
const groups = await groupsFromPayload(prisma, fid, p, it.groups);
|
||
if (groups !== undefined) data.groups = groups;
|
||
if (p.sku !== undefined) data.sku = str(p.sku, 60).trim();
|
||
if (p.supplier !== undefined) data.supplier = await ensureSupplier(prisma, fid, str(p.supplier, 80));
|
||
if (p.notes !== undefined) data.notes = str(p.notes, 400);
|
||
// Captured before the write so the row can say what it moved from — "we used to pay $30" is
|
||
// the half of the answer a single current-value field could never give.
|
||
let costFrom: number | null = null;
|
||
let costTo: number | null = null;
|
||
if (p.cost !== undefined) {
|
||
const c = num(p.cost, NaN);
|
||
if (!(c >= 0)) throw new OpError("Invalid cost");
|
||
data.cost = c;
|
||
if (c !== it.cost) { costFrom = it.cost; costTo = c; }
|
||
}
|
||
if (p.archived !== undefined) data.archived = !!p.archived;
|
||
if (Array.isArray(p.sizes) || p.addSize !== undefined) {
|
||
const sizes: string[] = Array.isArray(p.sizes) ? p.sizes.map((s: unknown) => str(s, 20).trim()).filter(Boolean) : [...it.sizes, str(p.addSize, 20).trim()].filter(Boolean);
|
||
if (!sizes.length) throw new OpError("At least one size required");
|
||
if (new Set(sizes).size !== sizes.length) throw new OpError("That size is already on the item");
|
||
// Only allow appending sizes once the item has history, to keep size indexes stable.
|
||
// Request lines and waitlist places count as history like anything else: both store a
|
||
// POSITION in this array, so reordering it under a pending ward request either points the
|
||
// line past the end of the run — the pick list reads "size 3" and the bag can never be
|
||
// handed over — or, worse, quietly one place further up it, and the nurse is handed the
|
||
// wrong size with every screen agreeing it is right.
|
||
const prefixOk = it.sizes.every((s, i) => sizes[i] === s);
|
||
const hasHistory = (await Promise.all([prisma.issue.count({ where: { itemId: it.id } }), prisma.stockLevel.count({ where: { itemId: it.id } }), prisma.orderLine.count({ where: { itemId: it.id } }), prisma.stockMove.count({ where: { itemId: it.id } }), prisma.barcode.count({ where: { itemId: it.id } }), prisma.stocktakeLine.count({ where: { itemId: it.id } }), prisma.receiptLine.count({ where: { itemId: it.id } }), prisma.pickupLine.count({ where: { itemId: it.id } }), prisma.requestLine.count({ where: { itemId: it.id } }), prisma.waitlistEntry.count({ where: { itemId: it.id } })])).reduce((a, b) => a + b, 0);
|
||
if (hasHistory && !prefixOk) throw new OpError("This item has history — sizes can be added but existing sizes can't be removed or reordered.");
|
||
data.sizes = sizes;
|
||
}
|
||
await prisma.catalogItem.update({ where: { id: it.id }, data });
|
||
if (costTo !== null) {
|
||
await prisma.costChange.create({ data: { facilityId: fid, itemId: it.id, cost: costTo, previous: costFrom, byName } });
|
||
}
|
||
return { ok: true };
|
||
}
|
||
case "catalog.duplicate": {
|
||
// Same garment, different colour/role: one catalogue item per colour, because group routing on
|
||
// the Issue screen and stock/barcodes are all per item. Copies the description and the size run
|
||
// (and the reorder levels, which are a property of the garment) — never barcodes, stock or
|
||
// history, which belong to the colour you're about to scan in.
|
||
admin(user);
|
||
const src = await ownItem(prisma, fid, str(p.id));
|
||
const item = str(p.item, 160).trim() || src.item;
|
||
const groups = (await groupsFromPayload(prisma, fid, p, src.groups)) ?? garmentGroups(src.groups);
|
||
const sku = p.sku !== undefined ? str(p.sku, 60).trim() : src.sku;
|
||
// The same name for the same groups, in any order, is the same garment.
|
||
const setOf = (l: string[]) => garmentGroups(l).map(groupKey).sort().join("\n");
|
||
const sameName = await prisma.catalogItem.findMany({ where: { facilityId: fid, item, archived: false }, select: { groups: true } });
|
||
if (sameName.some((d) => setOf(d.groups) === setOf(groups))) throw new OpError(`“${item}” already exists for ${groups.length ? groupsLabel(groups) : "all groups"}. Give this one a different name.`);
|
||
return prisma.$transaction(async (tx) => {
|
||
const sort = await nextSort(tx, fid);
|
||
const it = await tx.catalogItem.create({ data: { facilityId: fid, sort, item, gender: src.gender, type: src.type, sku, supplier: src.supplier, cost: src.cost, groups, notes: src.notes, sizes: src.sizes } });
|
||
const levels = await tx.stockLevel.findMany({ where: { itemId: src.id }, select: { sizeIndex: true, reorder: true } });
|
||
for (const l of levels) if (l.reorder !== null) await upsertLevel(tx, fid, it.id, l.sizeIndex, { reorder: l.reorder });
|
||
return { id: it.id, sizes: it.sizes.length };
|
||
});
|
||
}
|
||
case "catalog.bulk": {
|
||
// Inventory multi-select actions. Delete is history-safe: anything with issues/orders/stocktake/pickup lines
|
||
// or stock movement is discontinued instead, so records keep resolving.
|
||
admin(user);
|
||
const ids = (Array.isArray(p.ids) ? p.ids : []).map((x: unknown) => str(x)).filter(Boolean);
|
||
const items = await prisma.catalogItem.findMany({ where: { facilityId: fid, id: { in: ids } } });
|
||
if (!items.length) throw new OpError("Select at least one product");
|
||
const action = str(p.action);
|
||
const n = items.length, plural = `${n} product${n === 1 ? "" : "s"}`;
|
||
const idsIn = items.map((i) => i.id);
|
||
if (action === "discontinue") { await prisma.catalogItem.updateMany({ where: { id: { in: idsIn } }, data: { archived: true } }); return { ok: true, message: `${plural} discontinued.` }; }
|
||
if (action === "reinstate") { await prisma.catalogItem.updateMany({ where: { id: { in: idsIn } }, data: { archived: false } }); return { ok: true, message: `${plural} reinstated.` }; }
|
||
if (action === "supplier") { const v = str(p.value, 80).trim(); if (!v) throw new OpError("Pick a supplier"); const canon = await ensureSupplier(prisma, fid, v); await prisma.catalogItem.updateMany({ where: { id: { in: idsIn } }, data: { supplier: canon } }); return { ok: true, message: `${plural} moved to ${canon}.` }; }
|
||
if (action === "group") {
|
||
// `groups` from a tick-list, or the one `value` the select sends — a name, "All", or a
|
||
// garment's label ("A, B"), which takes that garment's groups.
|
||
if ((p.groups === undefined || p.groups === null) && !str(p.value, 4000).trim()) throw new OpError("Pick a group");
|
||
const groups = (await groupsFromPayload(prisma, fid, { groups: p.groups, group: p.value }, null)) ?? [];
|
||
await prisma.catalogItem.updateMany({ where: { id: { in: idsIn } }, data: { groups } });
|
||
return { ok: true, message: `${plural} moved to ${groups.length ? `group${groups.length > 1 ? "s" : ""} ${groupsLabel(groups)}` : "all groups"}.` };
|
||
}
|
||
if (action === "reorder") {
|
||
const lvl = int(p.value, -1); if (lvl < 0) throw new OpError("Enter a reorder level");
|
||
await prisma.$transaction(async (tx) => { for (const it of items) for (let si = 0; si < it.sizes.length; si++) await upsertLevel(tx, fid, it.id, si, { reorder: lvl }); });
|
||
return { ok: true, message: `Reorder level set to ${lvl} on every size of ${plural}.` };
|
||
}
|
||
if (action === "price") {
|
||
const raw = str(p.value, 20).trim();
|
||
const pct = /^[+-]\d+(\.\d+)?%$/.test(raw), abs = /^\$?\d+(\.\d+)?$/.test(raw);
|
||
if (!pct && !abs) throw new OpError("Enter a price like 25.50 or a change like +5%");
|
||
await prisma.$transaction(async (tx) => {
|
||
for (const it of items) {
|
||
const cost = pct ? Math.max(0.01, Math.round(it.cost * (1 + parseFloat(raw.slice(0, -1)) / 100) * 100) / 100) : Math.max(0.01, num(raw.replace("$", "")));
|
||
if (cost === it.cost) continue;
|
||
await tx.catalogItem.update({ where: { id: it.id }, data: { cost } });
|
||
// In the same transaction as the price itself: a bulk uplift that left no CostChange
|
||
// behind put a hole in the one record of what a garment used to cost, and "what did we
|
||
// used to pay for these" is a question finance asks every year.
|
||
await tx.costChange.create({ data: { facilityId: fid, itemId: it.id, cost, previous: it.cost, byName } });
|
||
}
|
||
});
|
||
return { ok: true, message: `Prices updated on ${plural}${pct ? ` (${raw})` : ""}. Past issues keep their recorded price.` };
|
||
}
|
||
if (action === "delete") {
|
||
const gone: string[] = [], kept: string[] = [];
|
||
for (const it of items) {
|
||
// The same list catalog.delete checks, request lines included. They cascade from the
|
||
// garment, so a brand-new item with no stock behind it — a fleece added last week and
|
||
// asked for on a live request — was being hard-deleted from a tidy-up multi-select, and
|
||
// Postgres quietly took the approved line with it: the ward's request lost a garment
|
||
// with no event and no message saying why, and a request whose only line went that way
|
||
// sticks at picking forever because there is nothing left to hand over.
|
||
const used = await prisma.issue.count({ where: { itemId: it.id } }) + await prisma.orderLine.count({ where: { itemId: it.id } }) + await prisma.stocktakeLine.count({ where: { itemId: it.id } })
|
||
+ await prisma.pickupLine.count({ where: { itemId: it.id } }) + await prisma.stockMove.count({ where: { itemId: it.id } }) + await prisma.handInLine.count({ where: { itemId: it.id } })
|
||
+ await prisma.requestLine.count({ where: { itemId: it.id } })
|
||
+ await prisma.stockLevel.count({ where: { itemId: it.id, OR: [{ opening: { gt: 0 } }, { adj: { not: 0 } }, { preloved: { gt: 0 } }] } });
|
||
(used ? kept : gone).push(it.id);
|
||
}
|
||
await prisma.$transaction([
|
||
prisma.catalogItem.deleteMany({ where: { id: { in: gone } } }),
|
||
prisma.catalogItem.updateMany({ where: { id: { in: kept } }, data: { archived: true } }),
|
||
]);
|
||
const pl = (k: number) => `${k} product${k === 1 ? "" : "s"}`;
|
||
return { ok: true, deleted: gone.length, discontinued: kept.length, message: (gone.length ? `${pl(gone.length)} deleted` : "") + (gone.length && kept.length ? " · " : "") + (kept.length ? `${pl(kept.length)} had history or stock on hand, so they were discontinued instead (records stay intact)` : "") + "." };
|
||
}
|
||
throw new OpError("Unknown bulk action");
|
||
}
|
||
case "catalog.delete": {
|
||
admin(user);
|
||
const it = await ownItem(prisma, fid, str(p.id));
|
||
// Request lines are counted with the rest: they cascade from the garment, so deleting one a
|
||
// ward has asked for would strip the line out from under a live request and leave the
|
||
// manager an ask with nothing on it.
|
||
const used = await prisma.issue.count({ where: { itemId: it.id } }) + await prisma.orderLine.count({ where: { itemId: it.id } }) + await prisma.stocktakeLine.count({ where: { itemId: it.id } }) + await prisma.handInLine.count({ where: { itemId: it.id } }) + await prisma.pickupLine.count({ where: { itemId: it.id } }) + await prisma.stockMove.count({ where: { itemId: it.id } }) + await prisma.requestLine.count({ where: { itemId: it.id } }) + await prisma.stockLevel.count({ where: { itemId: it.id, preloved: { gt: 0 } } });
|
||
if (used) throw new OpError("This item has history. Discontinue it instead of deleting.");
|
||
await prisma.catalogItem.delete({ where: { id: it.id } });
|
||
return { ok: true };
|
||
}
|
||
case "barcode.bind": {
|
||
admin(user);
|
||
const code = str(p.code, 64).trim();
|
||
if (!code) throw new OpError("Barcode required");
|
||
const it = await ownItem(prisma, fid, str(p.itemId));
|
||
const si = int(p.si, -1);
|
||
if (si < 0 || si >= it.sizes.length) throw new OpError("Invalid size");
|
||
await assertBindable(prisma, fid, code, it.id, si, !!p.force);
|
||
await prisma.barcode.upsert({ where: { facilityId_code: { facilityId: fid, code } }, create: { facilityId: fid, code, itemId: it.id, sizeIndex: si, source: "bound" }, update: { itemId: it.id, sizeIndex: si } });
|
||
return { ok: true };
|
||
}
|
||
case "catalog.variantAdd": {
|
||
// Scan-a-size: append the size if it's new and bind the scanned code to it, in one write, so a
|
||
// half-finished scan can't leave a size with no barcode (or a barcode on no size).
|
||
admin(user);
|
||
const it = await ownItem(prisma, fid, str(p.itemId));
|
||
const size = str(p.size, 20).trim();
|
||
if (!size) throw new OpError("Size required");
|
||
const code = str(p.code, 64).trim();
|
||
const existing = it.sizes.findIndex((s) => s.toLowerCase() === size.toLowerCase());
|
||
const si = existing >= 0 ? existing : it.sizes.length;
|
||
if (code) await assertBindable(prisma, fid, code, it.id, si, !!p.force);
|
||
await prisma.$transaction(async (tx) => {
|
||
if (existing < 0) await tx.catalogItem.update({ where: { id: it.id }, data: { sizes: [...it.sizes, size] } });
|
||
if (code) await tx.barcode.upsert({ where: { facilityId_code: { facilityId: fid, code } }, create: { facilityId: fid, code, itemId: it.id, sizeIndex: si, source: "bound" }, update: { itemId: it.id, sizeIndex: si } });
|
||
});
|
||
return { si, size, created: existing < 0 };
|
||
}
|
||
case "catalog.removeSize": {
|
||
/* Take a size off a garment.
|
||
*
|
||
* `sizeIndex` is a POSITION in the sizes array, not a name, so pulling one out shifts every
|
||
* size after it down by one — and ten tables store that position against the item. Left
|
||
* unremapped, a stock level for "14" silently starts describing "16" and an issue against
|
||
* somebody's name changes size under them. So this walks all ten in one transaction.
|
||
*
|
||
* What stops it is anything actually RECORDED against this particular size: an issue, a
|
||
* movement, a count, a hand-in, a request line, a place in a queue. Those are the history the
|
||
* size exists to explain, and deleting them to tidy the list is not a trade worth offering.
|
||
* Scaffolding goes quietly with it — an untouched stock level (a par nobody has counted
|
||
* against) and the barcode bound to that size, which describes a size that is about to stop
|
||
* existing. `catalog.update` still refuses to reorder or remove sizes wholesale for the same
|
||
* reason; this is the narrow, checked way to do one. */
|
||
admin(user);
|
||
const it = await ownItem(prisma, fid, str(p.id));
|
||
const si = int(p.si, -1);
|
||
if (si < 0 || si >= it.sizes.length) throw new OpError("Invalid size");
|
||
if (it.sizes.length <= 1) throw new OpError("A garment needs at least one size — archive it instead.");
|
||
const where = { itemId: it.id, sizeIndex: si };
|
||
|
||
// A stock level that has never been counted or adjusted is a par setting, not a record.
|
||
//
|
||
// On hand is DERIVED, not stored (see ledger()/onhand() in lib/compute.ts): opening + adj on
|
||
// the StockLevel row, plus what came in on deliveries and stock moves. So a size whose stock
|
||
// arrived entirely on a supplier delivery — ordered against an empty new garment, received
|
||
// onto the shelf, never issued — has opening/adj/preloved all zero and used to read as empty.
|
||
// Removing it deleted six real garments off the books with no StockMove to explain the loss.
|
||
// Order and receipt lines key on the size STRING rather than the index, which is why they sit
|
||
// outside the `where` above and why they matter twice over: an open order for a removed size
|
||
// stops counting as on order (onOrderMap drops si < 0), and when the box lands the receipt
|
||
// writes a line the ledger then ignores, so twenty garments enter the room and are never added.
|
||
// Only an order still outstanding blocks, on the same test onOrderMap uses: a size whose one
|
||
// order was cancelled, or received years ago, is history the receipt lines already speak for,
|
||
// and counting it would leave a discontinued size on the list with no way to ever take it off.
|
||
const sizeName = String(it.sizes[si]);
|
||
const [issues, moves, takes, handins, reqs, waits, kits, disputes, stocked, received, ordered] = await Promise.all([
|
||
prisma.issue.count({ where }),
|
||
prisma.stockMove.count({ where }),
|
||
prisma.stocktakeLine.count({ where }),
|
||
prisma.handInLine.count({ where }),
|
||
prisma.requestLine.count({ where }),
|
||
prisma.waitlistEntry.count({ where }),
|
||
prisma.kitCheckAnswer.count({ where }),
|
||
prisma.recordDispute.count({ where }),
|
||
prisma.stockLevel.count({ where: { ...where, NOT: { opening: 0, adj: 0, preloved: 0 } } }),
|
||
prisma.receiptLine.count({ where: { itemId: it.id, size: sizeName } }),
|
||
prisma.orderLine.count({ where: { itemId: it.id, size: sizeName, order: { status: { notIn: ["Received", "Cancelled"] } } } }),
|
||
]);
|
||
const blocked: string[] = [];
|
||
if (issues) blocked.push(`${issues} issue${issues === 1 ? "" : "s"}`);
|
||
if (stocked || received) blocked.push("stock on hand");
|
||
if (ordered) blocked.push("a supplier order");
|
||
if (moves) blocked.push(`${moves} stock movement${moves === 1 ? "" : "s"}`);
|
||
if (takes) blocked.push("a stocktake");
|
||
if (handins) blocked.push("a hand-in");
|
||
if (reqs) blocked.push("a ward request");
|
||
if (waits) blocked.push("somebody waiting for it");
|
||
if (kits) blocked.push("a kit check answer");
|
||
if (disputes) blocked.push("a record query");
|
||
if (blocked.length) {
|
||
throw new OpError(`Size ${it.sizes[si]} has ${blocked.join(", ")} against it, so it can't be removed. Its history would go with it.`);
|
||
}
|
||
|
||
const sizes = it.sizes.filter((_, i) => i !== si);
|
||
await prisma.$transaction(async (tx) => {
|
||
// The size itself: its par row and the code on its label, neither of which outlives it.
|
||
await tx.stockLevel.deleteMany({ where });
|
||
await tx.barcode.deleteMany({ where });
|
||
// Everything above it moves down one. Every table that stores the position has to move.
|
||
const shift = { where: { itemId: it.id, sizeIndex: { gt: si } }, data: { sizeIndex: { decrement: 1 } } };
|
||
await tx.stockMove.updateMany(shift);
|
||
await tx.issue.updateMany(shift);
|
||
await tx.barcode.updateMany(shift);
|
||
await tx.stocktakeLine.updateMany(shift);
|
||
await tx.handInLine.updateMany(shift);
|
||
await tx.requestLine.updateMany(shift);
|
||
await tx.recordDispute.updateMany(shift);
|
||
/* StockLevel, WaitlistEntry and KitCheckAnswer carry sizeIndex inside a UNIQUE index, and
|
||
* Postgres checks a unique index as each row is written, not at the end of the statement.
|
||
* One decrementing UPDATE therefore fails the moment it happens to reach XL (3 → 2) before
|
||
* L (still 2) — and the physical order of those rows is whatever the table's last write
|
||
* left behind, so removing a size worked on one garment and rolled the whole transaction
|
||
* back on the next with nothing on screen but "Something went wrong". Parked below zero
|
||
* first, where no real size index can be, then brought back: both passes only ever write
|
||
* values nothing else holds. */
|
||
const PARK = 1_000_000;
|
||
const park = { where: { itemId: it.id, sizeIndex: { gt: si } }, data: { sizeIndex: { decrement: PARK + 1 } } };
|
||
const unpark = { where: { itemId: it.id, sizeIndex: { lt: 0 } }, data: { sizeIndex: { increment: PARK } } };
|
||
await tx.stockLevel.updateMany(park); await tx.stockLevel.updateMany(unpark);
|
||
await tx.waitlistEntry.updateMany(park); await tx.waitlistEntry.updateMany(unpark);
|
||
await tx.kitCheckAnswer.updateMany(park); await tx.kitCheckAnswer.updateMany(unpark);
|
||
await tx.catalogItem.update({ where: { id: it.id }, data: { sizes } });
|
||
});
|
||
return { ok: true, sizes };
|
||
}
|
||
case "barcode.generate": {
|
||
/* Print our own barcode for a garment that arrived without one.
|
||
*
|
||
* Whole ranges turn up unlabelled — the cafe shirts came with nothing on any size — and a
|
||
* garment nobody can scan is invisible to a count and cannot be issued by scanning. The room
|
||
* prints its own label instead, carrying a number GS1 reserves for exactly this (see
|
||
* inHouseEan13): a real EAN-13 in the restricted-circulation range, which every scanner in
|
||
* the building already reads.
|
||
*
|
||
* Only ever fills the gaps. A size that already carries a supplier's code keeps it — the code
|
||
* on the garment is the one the supplier will use on the next delivery note, and replacing it
|
||
* with ours would quietly cut that tie. Pass `si` for one size, or leave it out for every
|
||
* unlabelled size on the garment, which is the usual case: somebody is standing at a rack
|
||
* about to label the lot.
|
||
*
|
||
* The counter lives on the facility and moves inside the same transaction as the binding, so
|
||
* two people labelling different racks cannot mint the same number. The uniqueness check is
|
||
* still there behind it: a supplier code that happens to start 29 would be rare but is not
|
||
* impossible, and quietly re-binding somebody's real barcode is not a failure worth risking. */
|
||
admin(user);
|
||
const it = await ownItem(prisma, fid, str(p.itemId));
|
||
const only = p.si === undefined ? -1 : int(p.si, -1);
|
||
if (only >= 0 && only >= it.sizes.length) throw new OpError("Invalid size");
|
||
|
||
const bound = await prisma.barcode.findMany({ where: { itemId: it.id }, select: { sizeIndex: true } });
|
||
const has = new Set(bound.map((b) => b.sizeIndex));
|
||
const wanted = it.sizes.map((_, i) => i).filter((i) => !has.has(i) && (only < 0 || i === only));
|
||
if (!wanted.length) {
|
||
throw new OpError(only >= 0 ? "That size already has a barcode." : "Every size on this garment already has a barcode.");
|
||
}
|
||
|
||
const made: { si: number; size: string; code: string }[] = [];
|
||
await prisma.$transaction(async (tx) => {
|
||
for (const si of wanted) {
|
||
// Walk forward past anything already taken rather than failing the whole run: one
|
||
// collision should cost a number, not the rack somebody is standing in front of.
|
||
let code = "";
|
||
for (let tries = 0; tries < 50 && !code; tries++) {
|
||
const f = await tx.facility.update({ where: { id: fid }, data: { barcodeSeq: { increment: 1 } }, select: { barcodeSeq: true } });
|
||
const candidate = inHouseEan13(f.barcodeSeq);
|
||
const clash = await tx.barcode.findUnique({ where: { facilityId_code: { facilityId: fid, code: candidate } }, select: { id: true } });
|
||
if (!clash) code = candidate;
|
||
}
|
||
if (!code) throw new OpError("Couldn't find a free number — tell whoever looks after this.");
|
||
await tx.barcode.create({ data: { facilityId: fid, code, itemId: it.id, sizeIndex: si, source: "generated" } });
|
||
made.push({ si, size: String(it.sizes[si] ?? si), code });
|
||
}
|
||
});
|
||
return { ok: true, made, count: made.length };
|
||
}
|
||
case "barcode.unbind": {
|
||
admin(user);
|
||
await prisma.barcode.deleteMany({ where: { facilityId: fid, code: str(p.code, 64).trim() } });
|
||
return { ok: true };
|
||
}
|
||
|
||
// ---------- stock levels
|
||
case "stock.reorder": {
|
||
admin(user);
|
||
const it = await ownItem(prisma, fid, str(p.itemId));
|
||
const si = int(p.si, -1);
|
||
if (si < 0 || si >= it.sizes.length) throw new OpError("Invalid size");
|
||
await upsertLevel(prisma, fid, it.id, si, { reorder: Math.max(0, int(p.reorder)) });
|
||
return { ok: true };
|
||
}
|
||
case "stock.moves": {
|
||
const mode = str(p.mode);
|
||
if (!["Set", "Receive", "Adjust", "Opening", "Pre-loved"].includes(mode)) throw new OpError("Bad mode");
|
||
if (mode === "Opening" || mode === "Adjust" || mode === "Set") admin(user); // write-offs and corrections are an Admin call; Issuers receive and count
|
||
if (mode === "Pre-loved" && (Array.isArray(p.lines) ? p.lines : []).some((l: { qty?: unknown }) => int(l?.qty) < 0)) admin(user);
|
||
const lines = Array.isArray(p.lines) ? p.lines : [];
|
||
if (!lines.length) throw new OpError("No lines");
|
||
await lockedTx(fid, async (tx) => {
|
||
// "Set" is the counted figure, not a movement: work out the difference against on-hand read
|
||
// through this transaction, and record that difference so the ledger still explains itself.
|
||
const snap = mode === "Set" ? await buildSnapshot(user, tx) : null;
|
||
const L = snap ? ledger(snap) : null;
|
||
for (const l of lines) {
|
||
const it = await ownItem(tx, fid, str(l.itemId));
|
||
const si = int(l.si, -1);
|
||
if (si < 0 || si >= it.sizes.length) throw new OpError("Invalid size");
|
||
const qty = int(l.qty);
|
||
if (mode === "Set") {
|
||
if (qty < 0) throw new OpError("A counted quantity can't be negative");
|
||
const have = onhand(snap!, L!, key(it.id, si));
|
||
const delta = qty - have;
|
||
if (delta === 0) continue;
|
||
await tx.stockMove.create({ data: { facilityId: fid, date: today, type: "adjust", itemId: it.id, sizeIndex: si, qty: delta, reason: str(p.reason, 120) || "Counted correction", byName } });
|
||
}
|
||
else if (mode === "Opening") await upsertLevel(tx, fid, it.id, si, { opening: Math.max(0, qty) });
|
||
else if (mode === "Pre-loved") { if (qty !== 0) await poolAdd(tx, fid, it.id, si, qty); }
|
||
else {
|
||
if (qty === 0) continue;
|
||
await tx.stockMove.create({ data: { facilityId: fid, date: today, type: mode.toLowerCase(), itemId: it.id, sizeIndex: si, qty: mode === "Receive" ? Math.abs(qty) : qty, reason: mode === "Receive" ? "Received without order" : str(p.reason, 120) || "Correction", byName } });
|
||
}
|
||
}
|
||
});
|
||
return { ok: true };
|
||
}
|
||
case "stock.orderFlagged": {
|
||
const snap = await buildSnapshot(user);
|
||
const L = ledger(snap);
|
||
const byId = Object.fromEntries(snap.catalog.map((i) => [i.id, i]));
|
||
const needs = flaggedNeeds(snap, L, byId);
|
||
const bySup: Record<string, typeof needs> = {};
|
||
for (const c of needs) (bySup[c.supplier] = bySup[c.supplier] || []).push(c);
|
||
let added = 0;
|
||
await lockedTx(fid, async (tx) => {
|
||
for (const sup in bySup) added += await mergeReplenish(tx, fid, today, sup, bySup[sup].map((c) => ({ itemId: c.itemId, size: c.size, qty: c.qty })), "max", "Reorder — at or below reorder level");
|
||
});
|
||
return { added };
|
||
}
|
||
|
||
// ---------- departments & staff
|
||
case "dept.save": {
|
||
admin(user);
|
||
const name = str(p.name, 120).trim(); if (!name) throw new OpError("Department name required");
|
||
if (p.id) {
|
||
const d = await prisma.department.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!d) throw new OpError("Unknown department", 404);
|
||
// A rename has to clear the same name check a new department does, and for a worse reason.
|
||
// Departments are matched by name further down — staff carry the name, so do orders' cost
|
||
// centres — so renaming Theatres to Emergency doesn't collide, it merges: two wards' staff end
|
||
// up under one entry and nothing on the screen says which of them started where. The list on
|
||
// the settings screen was the only thing stopping it, and a list in a browser stops nothing.
|
||
const clash = await prisma.department.findFirst({ where: { facilityId: fid, name: { equals: name, mode: "insensitive" }, NOT: { id: d.id } } });
|
||
if (clash) throw new OpError("That department is already on the list");
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.department.update({ where: { id: d.id }, data: { name, cc: str(p.cc, 40).trim() } });
|
||
if (d.name !== name) { await tx.staff.updateMany({ where: { facilityId: fid, dept: d.name }, data: { dept: name } }); await tx.order.updateMany({ where: { facilityId: fid, cc: d.name }, data: { cc: name } }); }
|
||
});
|
||
}
|
||
else {
|
||
const dup = await prisma.department.findFirst({ where: { facilityId: fid, name: { equals: name, mode: "insensitive" } } });
|
||
if (dup) throw new OpError("That department is already on the list");
|
||
const count = await prisma.department.count({ where: { facilityId: fid } });
|
||
await prisma.department.create({ data: { facilityId: fid, name, cc: str(p.cc, 40).trim(), sort: count } });
|
||
}
|
||
return { ok: true };
|
||
}
|
||
case "dept.delete": {
|
||
admin(user);
|
||
const d = await prisma.department.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!d) throw new OpError("Unknown department", 404);
|
||
const n = await prisma.staff.count({ where: { facilityId: fid, dept: d.name } });
|
||
if (n) throw new OpError("Departments with staff assigned can't be removed.");
|
||
await prisma.department.delete({ where: { id: d.id } });
|
||
return { ok: true };
|
||
}
|
||
case "staff.save": {
|
||
admin(user);
|
||
const numv = str(p.num, 40).trim(), first = str(p.first, 80).trim(), last = str(p.last, 80).trim();
|
||
if (!numv || !first || !last) throw new OpError("Staff number, first and last name are required");
|
||
// FTE is left alone unless this call actually carries one. It has its own control on the
|
||
// profile, which saves through staff.patch the moment it changes, so the details form doesn't
|
||
// send it — and blanking it here would wipe the FTE every time somebody corrected a phone
|
||
// number, taking the nurse's initial kit with it.
|
||
const fte = normalFte(p.fte);
|
||
if (fte === null) throw new OpError(`FTE has to be ${FTE_ALLOWED} — leave it blank if nobody has recorded one`);
|
||
// Uniform style is left alone unless the call carries one, for the same reason FTE is. It is a
|
||
// coordinator's decision about which cut somebody wears, and quietly clearing it every time a
|
||
// phone number was corrected would put that person back to "nobody has said" — offered the
|
||
// whole catalogue again — with nothing on the screen to say it had happened.
|
||
const style = normalUniformStyle(p.uniformStyle);
|
||
if (style === null) throw new OpError(STYLE_REFUSAL);
|
||
const data = { num: numv, first, last, phone: str(p.phone, 40).trim(), group: str(p.group, 80), dept: str(p.dept, 120), top: str(p.top, 20), pants: str(p.pants, 20), ccOverride: str(p.ccOverride, 40).trim(), ent: p.ent === "" || p.ent === null || p.ent === undefined ? null : Math.max(0, int(p.ent)), start: dateField(p.start, "", "Start date"), notes: str(p.notes, 2000), ...(p.fte === undefined ? {} : { fte }), ...(p.uniformStyle === undefined ? {} : { uniformStyle: style }) };
|
||
if (p.id) {
|
||
const ex = await ownStaff(prisma, fid, str(p.id));
|
||
if (ex.num !== numv) throw new OpError("Staff number can't be changed — issue history and reports are keyed to it.");
|
||
await prisma.staff.update({ where: { id: ex.id }, data }); return { id: ex.id };
|
||
}
|
||
const dup = await prisma.staff.findFirst({ where: { facilityId: fid, num: numv } });
|
||
if (dup) throw new OpError(`Staff number ${numv} is already on the register (${dup.first} ${dup.last})`);
|
||
const { room, max } = await staffRoom(fid);
|
||
if (room !== null && room < 1) throw new OpError(staffRefusal(max!), 403);
|
||
const s = await prisma.staff.create({ data: { facilityId: fid, ...data } });
|
||
return { id: s.id };
|
||
}
|
||
case "staff.patch": {
|
||
// Small field updates that don't require the whole record (notes, inactive, ccOverride,
|
||
// and the two staff-app fields).
|
||
admin(user);
|
||
const s = await ownStaff(prisma, fid, str(p.id));
|
||
const data: Prisma.StaffUpdateInput = {};
|
||
if (p.notes !== undefined) data.notes = str(p.notes, 2000);
|
||
if (p.fte !== undefined) {
|
||
// Stored as the fraction it is, whether or not the picker offers that step: 0.75 is a real
|
||
// roster figure and the table reads it as the band it falls in. What is refused is an entry
|
||
// that is no fraction at all — "full time", "N/A" — because the table proposes no kit for
|
||
// that, and a proposal silently missing is indistinguishable, on the screen, from a person
|
||
// the table genuinely has no number for. Blank is allowed and means what it has always
|
||
// meant: nobody has recorded one yet.
|
||
const f = normalFte(p.fte);
|
||
if (f === null) throw new OpError(`FTE has to be ${FTE_ALLOWED} — leave it blank if nobody has recorded one`);
|
||
data.fte = f;
|
||
}
|
||
if (p.uniformStyle !== undefined) {
|
||
// Blank is an answer here, and the one every record starts with: nobody has said which cut
|
||
// this person wears, so the counter and the staff app offer them every style exactly as
|
||
// "Either" does. Setting it back to blank is therefore allowed — a coordinator who ticked
|
||
// the wrong one has to be able to undo it — while a word the rule doesn't know is refused
|
||
// rather than stored, because a style nothing recognises would silently offer everything.
|
||
const styleIn = normalUniformStyle(p.uniformStyle);
|
||
if (styleIn === null) throw new OpError(STYLE_REFUSAL);
|
||
data.uniformStyle = styleIn;
|
||
}
|
||
if (p.inactive !== undefined) {
|
||
// Taking somebody off the register when other people still name them as their approver
|
||
// strands every one of those people: request.create refuses to address a deactivated
|
||
// manager ("The recorded manager is no longer on the register"), and the wearer has no way
|
||
// to see why or to change it. Line 504 already refuses to *set* an inactive manager; this
|
||
// is the same rule read from the other end, and it is refused here where the coordinator
|
||
// is holding the staff list and can move the reports first.
|
||
if (p.inactive && !s.inactive) {
|
||
// Not counting themselves: somebody who is their own manager names nobody else, and
|
||
// nobody is stranded by taking them off the register.
|
||
const reports = await prisma.staff.count({ where: { facilityId: fid, managerId: s.id, inactive: false, id: { not: s.id } } });
|
||
if (reports) {
|
||
throw new OpError(`${reports} ${reports === 1 ? "person still names" : "people still name"} ${s.first} as their manager, and a request can't be sent to somebody who is off the register. Give them a new manager first.`);
|
||
}
|
||
}
|
||
data.inactive = !!p.inactive;
|
||
}
|
||
// Whether this call is the moment they come off the register, read before the write so their
|
||
// waiting requests can be closed in the same transaction (below).
|
||
const closing = p.inactive !== undefined && !!p.inactive && !s.inactive;
|
||
if (p.ccOverride !== undefined) data.ccOverride = str(p.ccOverride, 40).trim();
|
||
if (p.wardDesk !== undefined) data.wardDesk = !!p.wardDesk;
|
||
if (p.managerId !== undefined) {
|
||
const mid = str(p.managerId);
|
||
if (!mid) data.manager = { disconnect: true };
|
||
else {
|
||
// Anybody may be their own manager — the owner's decision. On a facility where one person
|
||
// is the whole register there is nobody else to put here, and the staff app refuses every
|
||
// request from somebody with no manager set. What that asks of the record is answered
|
||
// where the approvals are made: every request or signed form a person approves for
|
||
// themselves is marked on it as a self-approval, so none of it passes unseen. A raise
|
||
// they make for somebody else still never lands back on them (lib/staffops.ts).
|
||
const m = mid === s.id ? s : await ownStaff(prisma, fid, mid);
|
||
if (m.inactive) throw new OpError("That manager is no longer active on the register.");
|
||
data.manager = { connect: { id: m.id } };
|
||
}
|
||
}
|
||
let closed = 0;
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.staff.update({ where: { id: s.id }, data });
|
||
if (!closing) return;
|
||
// A request still waiting on a manager outlives the person who raised it. The manager's
|
||
// home screen counts it every day from here on and the approvals queue keeps showing a
|
||
// live Approve bar, but both buttons are refused with "That person is no longer on the
|
||
// register" — and there is no op on the ward side that can clear it, so it sits there
|
||
// forever unless a coordinator happens to spot it and withdraw it. Deactivation is the
|
||
// moment somebody is actually looking at the right screen, so it closes them here.
|
||
const open = await tx.request.findMany({ where: { facilityId: fid, subjectId: s.id, status: "awaiting" }, select: { id: true } });
|
||
if (!open.length) return;
|
||
const why = `${s.first} ${s.last}`.trim() + " is no longer on the register";
|
||
for (const { id } of open) {
|
||
// Each one is re-checked as it is written, the way request.withdraw does. A manager
|
||
// sitting on the approvals screen can answer a request in the moment between the read
|
||
// above and this write, and blindly declining it would bury an approval she has already
|
||
// been told landed — with the garments then never picked for a ward that is expecting them.
|
||
const moved = await tx.request.updateMany({ where: { id, status: "awaiting" }, data: { status: "declined", decidedAt: new Date(), declineReason: why } });
|
||
if (!moved.count) continue;
|
||
// The lines are settled in the same breath as the request, as a withdrawal does: garments
|
||
// left at "awaiting approval" under a closed request tell the ward two different things.
|
||
await tx.requestLine.updateMany({ where: { requestId: id }, data: { status: "declined", declineReason: why } });
|
||
await tx.requestEvent.create({ data: { requestId: id, label: "Closed — the wearer came off the register", meta: why, actorName: byName } });
|
||
closed++;
|
||
}
|
||
});
|
||
return { ok: true, closedRequests: closed };
|
||
}
|
||
case "staff.delete": {
|
||
admin(user);
|
||
const s = await ownStaff(prisma, fid, str(p.id));
|
||
// Everything below cascades from the Staff row, so the guard has to name everything, not
|
||
// just the two tables it started with. A staff member with signed manager approvals, a hand-in,
|
||
// a live request or a claimed staff-app account was being deleted silently and taking all
|
||
// of it with her — including requests the linen room was part-way through fulfilling.
|
||
const [issues, orders, approvals, alterations, handins, requests, raised, waitlist, kitAnswers, damage, disputes, account] = await Promise.all([
|
||
prisma.issue.count({ where: { staffId: s.id } }),
|
||
prisma.order.count({ where: { staffId: s.id } }),
|
||
prisma.approval.count({ where: { staffId: s.id } }),
|
||
prisma.alteration.count({ where: { staffId: s.id } }),
|
||
prisma.handIn.count({ where: { staffId: s.id } }),
|
||
prisma.request.count({ where: { subjectId: s.id } }),
|
||
prisma.request.count({ where: { raisedByStaffId: s.id } }),
|
||
prisma.waitlistEntry.count({ where: { staffId: s.id } }),
|
||
prisma.kitCheckAnswer.count({ where: { staffId: s.id } }),
|
||
prisma.damageReport.count({ where: { staffId: s.id } }),
|
||
prisma.recordDispute.count({ where: { staffId: s.id } }),
|
||
prisma.staffAccount.count({ where: { staffId: s.id } }),
|
||
]);
|
||
const held: string[] = [];
|
||
const note = (n: number, what: string) => { if (n) held.push(`${n} ${what}${n === 1 ? "" : "s"}`); };
|
||
note(issues, "issue"); note(orders, "order"); note(approvals, "manager approval"); note(alterations, "alteration");
|
||
note(handins, "hand-in"); note(requests + raised, "request"); note(waitlist, "waitlist place");
|
||
note(kitAnswers, "kit-check answer"); note(damage, "damage report"); note(disputes, "query");
|
||
if (account) held.push("a staff-app account");
|
||
if (held.length) throw new OpError(`This staff member has history on file (${held.join(", ")}) — deactivate them instead, so the records keep resolving.`);
|
||
await prisma.staff.delete({ where: { id: s.id } });
|
||
return { ok: true };
|
||
}
|
||
|
||
// ---------- staff self-service: the code that lets a person claim their own read-only view
|
||
case "staff.selfCode": {
|
||
admin(user);
|
||
const s = await ownStaff(prisma, fid, str(p.id));
|
||
const linked = await prisma.staffAccount.findUnique({ where: { staffId: s.id }, select: { email: true } });
|
||
if (linked) throw new OpError(`${s.first} already has an account (${linked.email}). Remove it first if they need to start again.`);
|
||
// Retry on the vanishingly unlikely collision rather than handing back a confusing error.
|
||
for (let attempt = 0; attempt < 5; attempt++) {
|
||
const code = newActivateCode();
|
||
try {
|
||
// The date is stamped with the code, not merely alongside it: a slip is a bearer token
|
||
// on paper, and the activation route refuses one printed more than a fortnight ago. A
|
||
// code written without its date is refused as stale on the day it is handed over.
|
||
await prisma.staff.update({ where: { id: s.id }, data: { activateCode: code, activateCodeAt: new Date() } });
|
||
return { code, name: `${s.first} ${s.last}` };
|
||
} catch (e) {
|
||
if (!(e instanceof Prisma.PrismaClientKnownRequestError) || e.code !== "P2002") throw e;
|
||
}
|
||
}
|
||
throw new OpError("Couldn't generate a code — try again.", 500);
|
||
}
|
||
case "staff.selfClear": {
|
||
admin(user);
|
||
const s = await ownStaff(prisma, fid, str(p.id));
|
||
// The date goes with the code. Left behind, it would outlive the slip it describes and the
|
||
// register would go on reporting an outstanding code that nobody can use.
|
||
await prisma.staff.update({ where: { id: s.id }, data: { activateCode: null, activateCodeAt: null } });
|
||
return { ok: true };
|
||
}
|
||
case "staff.selfUnlink": {
|
||
admin(user);
|
||
const s = await ownStaff(prisma, fid, str(p.id));
|
||
// Deleting the account ends every session it had: currentStaff() looks the row up on each
|
||
// request, so there is no token left to expire.
|
||
await prisma.staffAccount.deleteMany({ where: { staffId: s.id, facilityId: fid } });
|
||
await prisma.staff.update({ where: { id: s.id }, data: { activateCode: null, activateCodeAt: null } });
|
||
return { ok: true };
|
||
}
|
||
|
||
/* ---------- staff-app requests: the linen room's half of the state machine
|
||
*
|
||
* The manager's half lives in lib/staffops.ts and is not reachable from here. A coordinator
|
||
* cannot approve a request on a manager's behalf — that would make the approval a formality,
|
||
* and the approval is the entire control for clinical staff.
|
||
*/
|
||
case "request.pick":
|
||
case "request.hold":
|
||
case "request.round":
|
||
case "request.collected": {
|
||
const r = await prisma.request.findFirst({
|
||
where: { id: str(p.id), facilityId: fid },
|
||
include: {
|
||
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
|
||
subject: { select: { first: true, last: true, dept: true } },
|
||
},
|
||
});
|
||
if (!r) throw new OpError("No such request", 404);
|
||
const to = op === "request.pick" ? "picking" : op === "request.hold" ? "ready" : op === "request.round" ? "round" : "collected";
|
||
if (!canMove(r.status, to)) {
|
||
throw new OpError(`A request that is "${r.status}" can't move to "${to}".`);
|
||
}
|
||
|
||
const data: Prisma.RequestUpdateInput = { status: to };
|
||
let label = "", meta = "";
|
||
if (to === "picking") { label = "Being picked"; meta = str(p.where, 60) || "In the linen room"; }
|
||
if (to === "ready") {
|
||
// The requester never chooses counter versus ward round — the linen room does, and the
|
||
// requester finds out after the fact. Both branches therefore set `route` here.
|
||
data.route = "counter";
|
||
data.holdUntil = str(p.holdUntil, 40);
|
||
// The code itself is picked below, inside the lock, against the bags already waiting.
|
||
label = "Ready at the counter";
|
||
meta = data.holdUntil ? `Held until ${data.holdUntil}` : "Held at the counter";
|
||
}
|
||
if (to === "round") {
|
||
// The round delivers to a ward, so a wearer with no ward recorded has nowhere for the bag
|
||
// to go. Left to the count below, a blank ward would have matched every ward-less desk
|
||
// clerk in the facility and the bag would have gone out to whichever of them happened to
|
||
// open /my/round. The counter is the only honest answer until somebody fills the ward in.
|
||
if (!r.subject.dept) throw new OpError(`${r.subject.first} has no ward recorded, so there is no round to send this on. Hold it at the counter, or record their ward on the staff register first.`);
|
||
// Only a ward-desk account can sign a round bag off (round → delivered lives in
|
||
// lib/staffops.ts and /my/round, which 404s for anyone without the flag). A ward with
|
||
// nobody like that has no way to finish the round, so the bag would be handed over on the
|
||
// floor and the request would sit in the linen room's open queue for ever. Refuse here,
|
||
// where the coordinator still has the counter to fall back on.
|
||
const signers = await prisma.staff.count({ where: { facilityId: fid, dept: r.subject.dept, wardDesk: true, inactive: false, account: { isNot: null } } });
|
||
if (!signers) throw new OpError(`Nobody on ${r.subject.dept} can sign for a round bag — that needs somebody with the ward-desk flag and their own staff-app account. Hold it at the counter instead, or set the flag on their staff record first.`);
|
||
data.route = "ward_round"; label = "Out on the ward round"; meta = r.subject.dept ? `Due on ${r.subject.dept}` : "On today's round";
|
||
}
|
||
if (to === "collected") { label = "Collected"; meta = r.collectCode ? `Code ${r.collectCode}` : "At the counter"; }
|
||
|
||
// Conditional on the status we read, so two coordinators on two phones can't both move it.
|
||
const move = async (db: Tx | typeof prisma) => {
|
||
const moved = await db.request.updateMany({ where: { id: r.id, status: r.status }, data: data as Prisma.RequestUpdateManyMutationInput });
|
||
if (moved.count !== 1) throw new OpError("Somebody else moved that request just now — reopen it.");
|
||
await db.requestEvent.create({ data: { requestId: r.id, label, meta, actorName: byName } });
|
||
};
|
||
// Handing the garment over is the moment it leaves the shelf, so that branch goes through
|
||
// handOverRequestStock — the shelf re-check, the Issue and the replenishment line, shared
|
||
// with the ward round so the two routes cannot drift apart. Every other transition is a
|
||
// status change and nothing more.
|
||
if (to === "collected") await handOverRequestStock(fid, today, r, move);
|
||
else if (to === "ready") {
|
||
/* The collection code has to be unique among the bags it will sit next to, which means
|
||
* reading the codes already out there and writing this one without another counter action
|
||
* getting in between — two coordinators holding two bags a second apart would otherwise
|
||
* both see the same free number and both take it. lockedTx is the facility-wide serialiser
|
||
* that already stops two counter actions passing the same shelf check; this is the same
|
||
* shape of check, so it uses the same lock rather than a second mechanism.
|
||
*
|
||
* "Could be confused with each other" is the bags still waiting at the counter: a request
|
||
* that has been collected has gone home with somebody, and its code is free again. */
|
||
await lockedTx(fid, async (tx) => {
|
||
const waiting = await tx.request.findMany({
|
||
where: { facilityId: fid, status: "ready", collectCode: { not: null }, id: { not: r.id } },
|
||
select: { collectCode: true },
|
||
});
|
||
const code = collectionCode(new Set(waiting.map((w) => w.collectCode).filter((c): c is string => c !== null)));
|
||
if (!code) throw new OpError("Couldn't find a collection code that isn't already on a bag at the counter — hand some of the waiting bags over first.", 500);
|
||
data.collectCode = code;
|
||
await move(tx);
|
||
});
|
||
} else await move(prisma);
|
||
|
||
const acct = await prisma.staffAccount.findUnique({ where: { staffId: r.subjectId }, select: { email: true } });
|
||
if (acct?.email && transactionalConfigured() && (to === "ready" || to === "round")) {
|
||
const fresh = await prisma.request.findUniqueOrThrow({ where: { id: r.id }, select: { collectCode: true, holdUntil: true } });
|
||
// The bag holds what the manager approved and nothing else, so a declined line stays off
|
||
// this email as it stays off the pick list — being told a fleece is ready to collect and
|
||
// then not finding one in the bag is worse than never hearing about it.
|
||
const detail = garmentBlock(approvedLines(r.lines).map((l) => ({ qty: l.qty, item: l.item.item, size: String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex) })));
|
||
const foot = signOff();
|
||
const subject = to === "ready" ? "Ready to collect" : "On the ward round";
|
||
/* Paragraphs joined by blank lines, with the optional slots as `null` — the same shape as
|
||
* the emails in lib/approvallink.ts, and for the same reason. This used to be a list of
|
||
* lines with blank strings written in among them and a `.filter(Boolean)` over the lot,
|
||
* which dropped every one of those blanks along with the optional lines it was aimed at:
|
||
* the collection code arrived jammed against the sign-off. */
|
||
const held = [
|
||
fresh.collectCode ? `Collection code: ${fresh.collectCode}` : null,
|
||
fresh.holdUntil ? `Held until ${fresh.holdUntil}.` : null,
|
||
].filter((l): l is string => l !== null).join("\n");
|
||
const text = (to === "ready"
|
||
? [`Hi ${r.subject.first},`, `Request ${r.code} is ready at the linen room.`, detail, held || null, foot]
|
||
: [`Hi ${r.subject.first},`, `Request ${r.code} is on today's round to ${r.subject.dept || "your ward"}.`, detail,
|
||
"Whoever signs for it at the desk will be named on your order.", foot]
|
||
).filter((l): l is string => l !== null).join("\n\n");
|
||
void sendTo(acct.email, subject, text).catch((e) => console.error("[request mail]", (e as Error).message));
|
||
}
|
||
return { ok: true, status: to };
|
||
}
|
||
|
||
case "request.raise": {
|
||
// The counter raising on somebody's behalf — the same job a manager does in 2C, for the
|
||
// person who walks in without a phone. It still goes to *their* manager: a coordinator who
|
||
// could approve as well as raise would make the approval a formality.
|
||
const staff = await ownStaff(prisma, fid, str(p.staffId));
|
||
if (staff.inactive) throw new OpError("This staff member is inactive — reactivate them first");
|
||
if (!staff.managerId) throw new OpError(`${staff.first} has no manager recorded, so there is nobody to approve this. Set one on their staff record first.`);
|
||
// Off the register means off the register: a deactivated manager cannot sign in to approve
|
||
// anything, so addressing a request to one parks it where nobody can reach it — and it would
|
||
// mint an approval link that outlives their access by a fortnight.
|
||
const manager = await prisma.staff.findFirst({ where: { id: staff.managerId, facilityId: fid, inactive: false } });
|
||
if (!manager) throw new OpError("The recorded manager is no longer on the register.");
|
||
const lines = await readRequestLines(fid, p.lines);
|
||
// The same rule request.create applies in the staff app: only their own staff group's
|
||
// garments, plus those for every group. The hand-over trusts it (handOverRequestStock), so the
|
||
// counter's door has to ask it too, or a raise here is the way round it.
|
||
const off = await offGroupGarments(fid, staff.group, lines.map((l) => l.itemId));
|
||
if (off.length) throw new OpError(offGroupElsewhere(staff, off, "request"));
|
||
// And the same for the cut they are offered. Blank — nobody has said which cut this person
|
||
// wears — and Either take every garment, so this refuses nothing that was allowed before a
|
||
// coordinator set the field.
|
||
const offStyle = await offStyleGarments(fid, staff.uniformStyle, lines.map((l) => l.itemId));
|
||
if (offStyle.length) throw new OpError(offStyleElsewhere(staff, offStyle, "request"));
|
||
|
||
const created = await prisma.$transaction(async (tx) => {
|
||
const f = await tx.facility.update({ where: { id: fid }, data: { requestSeq: { increment: 1 } }, select: { requestSeq: true } });
|
||
const req = await tx.request.create({
|
||
data: {
|
||
facilityId: fid, code: requestCode(f.requestSeq), subjectId: staff.id,
|
||
raisedByUserId: user.id, raisedByName: byName,
|
||
reason: str(p.reason, 40), note: str(p.note, 400),
|
||
status: "awaiting", managerId: manager.id, managerName: `${manager.first} ${manager.last}`.trim(),
|
||
lines: { create: lines.map((l, i) => ({ itemId: l.itemId, sizeIndex: l.sizeIndex, qty: l.qty, sort: i })) },
|
||
},
|
||
});
|
||
await tx.requestEvent.create({ data: { requestId: req.id, label: "Requested", meta: `Raised at the counter by ${byName}`, actorName: byName } });
|
||
return req;
|
||
});
|
||
|
||
const mgrAccount = await prisma.staffAccount.findUnique({ where: { staffId: manager.id }, select: { email: true } });
|
||
// `notified` says an email actually went out, not that the manager happens to have an
|
||
// account: the send below is gated on transactional mail being configured as well, and a
|
||
// counter that says "we've told them" when nothing was sent is the reason a request sits for
|
||
// three weeks with nobody chasing it.
|
||
const mgrEmail = mgrAccount?.email || "";
|
||
const notified = !!mgrEmail && transactionalConfigured();
|
||
if (notified) {
|
||
const em = approvalEmail({
|
||
managerFirst: manager.first, subjectName: `${staff.first} ${staff.last}`.trim(), raisedByName: byName,
|
||
lines, reason: str(p.reason, 40), note: str(p.note, 400),
|
||
url: approvalUrl(signApprovalToken(created.id, manager.id)),
|
||
facility: fac.name,
|
||
});
|
||
void sendTo(mgrEmail, em.subject, em.text).catch((e) => console.error("[raise mail]", (e as Error).message));
|
||
}
|
||
return { id: created.id, code: created.code, manager: `${manager.first} ${manager.last}`.trim(), notified };
|
||
}
|
||
|
||
case "request.reply": {
|
||
const r = await prisma.request.findFirst({ where: { id: str(p.id), facilityId: fid } });
|
||
if (!r) throw new OpError("No such request", 404);
|
||
const body = str(p.body, 2000).trim();
|
||
if (!body) throw new OpError("Write something first");
|
||
const m = await prisma.requestMessage.create({
|
||
data: { requestId: r.id, fromStaff: false, authorName: byName, body },
|
||
});
|
||
return { id: m.id };
|
||
}
|
||
|
||
case "damage.handedIn": {
|
||
// The counter-side half of the staff app's damage.report. The Damage screen tells the wearer
|
||
// "the damaged item comes off your record when you hand it in at the counter", and this is
|
||
// the hand that does it — a coordinator op, because it is the linen room that has the
|
||
// garment in front of them, not the person who reported it.
|
||
const d = await prisma.damageReport.findFirst({ where: { id: str(p.id), facilityId: fid } });
|
||
if (!d) throw new OpError("No such damage report", 404);
|
||
if (d.handedInAt) return { ok: true };
|
||
await prisma.damageReport.update({ where: { id: d.id }, data: { handedInAt: new Date() } });
|
||
return { ok: true };
|
||
}
|
||
|
||
case "request.withdraw":
|
||
case "request.reassign": {
|
||
// A request addressed to a manager who never claimed a staff-app account, or who has since
|
||
// left, waits for a decision that cannot arrive. Neither the wearer nor the manager can clear
|
||
// it — the wearer has no such op and the manager cannot sign in — so the linen room needs a
|
||
// way out of the dead end: send it to somebody who can decide it, or close it off honestly.
|
||
const r = await prisma.request.findFirst({
|
||
where: { id: str(p.id), facilityId: fid },
|
||
include: {
|
||
subject: { select: { id: true, first: true, last: true } },
|
||
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
|
||
},
|
||
});
|
||
if (!r) throw new OpError("No such request", 404);
|
||
if (r.status !== "awaiting") throw new OpError("That request has already been decided — only one still waiting for a manager can be withdrawn or re-addressed.");
|
||
// Nothing has been decided yet at this point in the flow, so every line is still on the ask:
|
||
// both the withdrawal note and the re-addressed approval email list the whole request.
|
||
const reqLines = r.lines.map((l) => ({ qty: l.qty, item: l.item.item, size: String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex) }));
|
||
const subjAccount = await prisma.staffAccount.findUnique({ where: { staffId: r.subject.id }, select: { email: true } });
|
||
|
||
if (op === "request.withdraw") {
|
||
const why = str(p.reason, 120).trim() || "Withdrawn by the linen room";
|
||
// The lines are settled in the same breath as the request. A withdrawal ends the whole ask,
|
||
// so leaving the garments sitting at "awaiting approval" under a request that reads
|
||
// "Declined" would have every screen telling the wearer two different things at once.
|
||
await prisma.$transaction(async (tx) => {
|
||
const moved = await tx.request.updateMany({
|
||
where: { id: r.id, status: "awaiting" },
|
||
data: { status: "declined", decidedAt: new Date(), declineReason: why },
|
||
});
|
||
if (moved.count !== 1) throw new OpError("Somebody else moved that request just now — reopen it.");
|
||
await tx.requestLine.updateMany({ where: { requestId: r.id }, data: { status: "declined", declineReason: why } });
|
||
await tx.requestEvent.create({ data: { requestId: r.id, label: "Withdrawn by the linen room", meta: why, actorName: byName } });
|
||
});
|
||
// Told, and told why. The whole flow's rule is that a request never just goes quiet on the
|
||
// person who raised it, and a withdrawal is the one ending nobody on the ward asked for.
|
||
if (subjAccount?.email && transactionalConfigured()) {
|
||
const em = decisionEmail({
|
||
staffFirst: r.subject.first, managerName: byName, approved: false, reason: why,
|
||
lines: reqLines, facility: fac.name,
|
||
});
|
||
void sendTo(subjAccount.email, em.subject, em.text).catch((e) => console.error("[withdraw mail]", (e as Error).message));
|
||
}
|
||
return { ok: true, status: "declined" };
|
||
}
|
||
|
||
// Re-addressing keeps the request exactly as it was raised and only changes who is being
|
||
// asked. The new approver has to be somebody who can actually answer: on the register, and
|
||
// not the person who raised it for somebody else (below).
|
||
const m = await ownStaff(prisma, fid, str(p.managerId));
|
||
if (m.inactive) throw new OpError("That manager is no longer active on the register.");
|
||
// Handing a request back to the person it is for is allowed — anybody may approve their own
|
||
// (the owner's decision). It is never mistaken later for an ordinary approval: the timeline
|
||
// row below says this one is going back to the wearer, and the decision itself is stamped
|
||
// "Self-approved" in lib/staffops.ts.
|
||
const selfApproval = m.id === r.subject.id;
|
||
// Nor the person who typed it. A manager raising for one of her own reports is exactly why
|
||
// the request escalated and landed on this queue with nobody to approve it — and she is the
|
||
// natural pick in the dropdown, because she IS the wearer's manager on the register, with
|
||
// nothing on the screen to say the ask came from her. Handing it back would let her approve
|
||
// her own raise, and that stands whatever else has been relaxed: a manager may sign for the
|
||
// kit she is going to wear, where everyone can see whose it is, but asking for somebody
|
||
// else's garments and then granting the ask herself is one person doing both halves of a
|
||
// decision the ward is told two people made.
|
||
if (r.raisedByStaffId && m.id === r.raisedByStaffId) throw new OpError(`${m.first} raised this request, so it can't be sent back for ${m.first} to approve. Pick somebody else.`);
|
||
// Sending a request back to the person it is for makes them its approver. That is for somebody set
|
||
// as their own manager — the staff record's Manager box is where a person is made self-approving —
|
||
// and nobody else, or their own app would show them a request it can't let them decide.
|
||
if (m.id === r.subject.id && m.managerId !== m.id) throw new OpError(`${m.first} isn't set as their own manager, so this can't be sent to them to approve. Set them as their own manager on their staff record, or pick somebody else.`);
|
||
const mgrName = `${m.first} ${m.last}`.trim();
|
||
const moved = await prisma.request.updateMany({
|
||
where: { id: r.id, status: "awaiting" },
|
||
data: { managerId: m.id, managerName: mgrName },
|
||
});
|
||
if (moved.count !== 1) throw new OpError("Somebody else moved that request just now — reopen it.");
|
||
// Who changed it, and — when it has gone back to the wearer — that it is now theirs to
|
||
// approve. Somebody reading this request in six months sees where the approval came from
|
||
// before they see the approval itself.
|
||
const how = selfApproval
|
||
? `Changed at the counter by ${byName} — ${m.first} is the person this request is for, so it will be self-approved`
|
||
: `Changed at the counter by ${byName}`;
|
||
await prisma.requestEvent.create({ data: { requestId: r.id, label: `Re-addressed to ${mgrName}`, meta: how, actorName: byName } });
|
||
|
||
const mgrAccount = await prisma.staffAccount.findUnique({ where: { staffId: m.id }, select: { email: true } });
|
||
const mgrEmail = mgrAccount?.email || "";
|
||
const notified = !!mgrEmail && transactionalConfigured();
|
||
if (notified) {
|
||
const em = approvalEmail({
|
||
managerFirst: m.first, subjectName: `${r.subject.first} ${r.subject.last}`.trim(), raisedByName: r.raisedByName,
|
||
lines: reqLines, reason: r.reason, note: r.note,
|
||
url: approvalUrl(signApprovalToken(r.id, m.id)),
|
||
facility: fac.name,
|
||
});
|
||
void sendTo(mgrEmail, em.subject, em.text).catch((e) => console.error("[reassign mail]", (e as Error).message));
|
||
}
|
||
return { ok: true, manager: mgrName, notified };
|
||
}
|
||
|
||
case "dispute.resolve": {
|
||
const d = await prisma.recordDispute.findFirst({ where: { id: str(p.id), facilityId: fid } });
|
||
if (!d) throw new OpError("No such query", 404);
|
||
await prisma.recordDispute.update({ where: { id: d.id }, data: { resolvedAt: new Date(), resolvedBy: byName } });
|
||
return { ok: true };
|
||
}
|
||
|
||
case "notice.set": {
|
||
admin(user);
|
||
const body = str(p.body, 400).trim();
|
||
// An empty body clears the board rather than posting nothing — the linen room's way of
|
||
// saying "that's over now".
|
||
await prisma.linenNotice.deleteMany({ where: { facilityId: fid } });
|
||
if (body) await prisma.linenNotice.create({ data: { facilityId: fid, body, endsAt: dateField(p.endsAt, "", "The end date") } });
|
||
return { ok: true, cleared: !body };
|
||
}
|
||
|
||
case "kitcheck.open": {
|
||
admin(user);
|
||
const open = await prisma.kitCheck.findFirst({ where: { facilityId: fid, closedAt: null } });
|
||
if (open) throw new OpError("A kit check is already running. Close that one first.");
|
||
const k = await prisma.kitCheck.create({ data: { facilityId: fid, dueBy: dateField(p.dueBy, addDays(today, 21), "The due date"), openedBy: byName } });
|
||
return { id: k.id };
|
||
}
|
||
case "kitcheck.close": {
|
||
admin(user);
|
||
const k = await prisma.kitCheck.findFirst({ where: { id: str(p.id), facilityId: fid, closedAt: null } });
|
||
if (!k) throw new OpError("No kit check to close", 404);
|
||
await prisma.kitCheck.update({ where: { id: k.id }, data: { closedAt: new Date() } });
|
||
return { ok: true };
|
||
}
|
||
|
||
case "waitlist.offer": {
|
||
// Stock landed. Offering starts the 48-hour hold; accepting is the person's own act and
|
||
// raises a request that still needs their manager.
|
||
const w = await prisma.waitlistEntry.findFirst({ where: { id: str(p.id), facilityId: fid, leftAt: null, acceptedAt: null } });
|
||
if (!w) throw new OpError("No such waitlist entry", 404);
|
||
// A place joined before the staff-group rule can be for a garment outside the person's group.
|
||
// Offering it would hold the stock for forty-eight hours for somebody whose acceptance is then
|
||
// refused, so it is refused here, where the linen room can take them off the list instead.
|
||
const wStaff = await prisma.staff.findFirst({ where: { id: w.staffId, facilityId: fid }, select: { first: true, last: true, group: true, uniformStyle: true } });
|
||
const wItem = await prisma.catalogItem.findFirst({ where: { id: w.itemId, facilityId: fid }, select: { item: true, gender: true, groups: true } });
|
||
if (wStaff && wItem && !garmentForGroup(wItem, wStaff.group)) {
|
||
throw new OpError(`${wItem.item} is for ${groupsLabel(wItem.groups)} only — ${wStaff.first} ${wStaff.last} is in ${wStaff.group || "no staff group"}, so they can't take it. Take them off this waitlist instead.`);
|
||
}
|
||
// The same for the cut, and for the same reason: a place joined before the uniform-style rule,
|
||
// or before this person was set to one, ends in a request that is now refused.
|
||
if (wStaff && wItem && !garmentForStyle(wItem, wStaff.uniformStyle)) {
|
||
throw new OpError(`${wItem.item} is the ${genderLabel(wItem.gender)} cut — ${wStaff.first} ${wStaff.last} is set to ${wStaff.uniformStyle}, so they can't take it. Take them off this waitlist instead.`);
|
||
}
|
||
const offeredAt = new Date();
|
||
const heldUntil = holdEndsAt(offeredAt);
|
||
await prisma.waitlistEntry.update({ where: { id: w.id }, data: { offeredAt } });
|
||
const acct = await prisma.staffAccount.findUnique({ where: { staffId: w.staffId }, select: { email: true } });
|
||
const it = await prisma.catalogItem.findUnique({ where: { id: w.itemId }, select: { item: true, sizes: true } });
|
||
if (acct?.email && it && transactionalConfigured()) {
|
||
const foot = signOff();
|
||
// The deadline is stated as a date and a time in the linen room's own zone rather than as
|
||
// "48 hours", because a duration in an email is unanswerable by the time it matters: the
|
||
// person behind them in the queue is waiting on this exact moment passing. The hours
|
||
// themselves come from staffreq, where the rule that enforces them lives.
|
||
const until = formatInZone(heldUntil ?? offeredAt, tz, { weekday: "short", day: "numeric", month: "short", hour: "numeric", minute: "2-digit" });
|
||
void sendTo(acct.email, "The size you were waiting for is in",
|
||
`${it.item} — size ${it.sizes[w.sizeIndex]} has come in and is held for you until ${until}.\n\nOpen ThreadCount to accept it. After that it goes to the next person waiting.\n\n${foot}`)
|
||
.catch((e) => console.error("[waitlist mail]", (e as Error).message));
|
||
}
|
||
return { ok: true, heldUntil: heldUntil?.toISOString() ?? null };
|
||
}
|
||
|
||
// ---------- Manager approvals & alterations
|
||
case "approval.add": {
|
||
const s = await ownStaff(prisma, fid, str(p.staffId));
|
||
// The FTE this approval was signed against. Taken off the staff record when the form doesn't
|
||
// carry one, because that is where it was typed in from the same paper form. Kept as written
|
||
// even when it is no fraction at all: this is a transcription of something already signed,
|
||
// and refusing it would mean a signed approval that cannot be recorded at all.
|
||
const fteIn = str(p.fte, 10).trim();
|
||
const fte = fteIn ? normalFte(fteIn) ?? fteIn : s.fte;
|
||
// What the table proposes for that FTE — null for a casual, and null when nobody has recorded
|
||
// an FTE at all, which is the same answer for a different reason: the app has no number of
|
||
// its own to offer and must not invent one.
|
||
const proposed = setsForFte(fte);
|
||
const given = p.sets === undefined || p.sets === null || String(p.sets).trim() === "" ? null : int(p.sets);
|
||
const sets = given ?? proposed ?? 0;
|
||
if (sets <= 0) {
|
||
if (given === null) throw new OpError(fte.toLowerCase() === FTE_CASUAL.toLowerCase()
|
||
? "How many sets? The form leaves a casual's number to the manager, so it has to be the one they signed for."
|
||
: "How many sets? There's no FTE on this staff record for the table to work one out from.");
|
||
throw new OpError("Sets must be at least 1");
|
||
}
|
||
/* Who signed the form. Two ways in, and both have to keep working: the staff screen picks
|
||
* the manager out of the register and sends `byStaffId`, while a restored backup, the demo
|
||
* seeder and any older screen send nothing but the typed name. A name on its own is only a
|
||
* string — finding what a manager had approved meant matching a spelling, and the match
|
||
* broke silently the day she was married, promoted, or entered twice.
|
||
*
|
||
* When both arrive the link wins, and the name is taken off the register row rather than
|
||
* from whatever was typed beside it: a record that links to one person and names another
|
||
* cannot be read either way round, and the person picked is the one the coordinator actually
|
||
* chose. It is still a copy, not a lookup, so the form goes on reading as it was signed even
|
||
* after that manager is renamed or leaves the register.
|
||
*
|
||
* An approver who is not on the register at all — an agency manager, somebody covering for
|
||
* the afternoon — still signs forms, so a typed name with no link stays a complete record. */
|
||
const byStaffId = str(p.byStaffId).trim();
|
||
const approver = byStaffId ? await ownStaff(prisma, fid, byStaffId) : null;
|
||
const by = approver ? `${approver.first} ${approver.last}`.trim() : str(p.by, 120).trim();
|
||
if (!by) throw new OpError("Approved-by (manager) is required");
|
||
// The coordinator may pick the wearer as their own approver while typing the form up —
|
||
// anybody may approve their own kit (the owner's decision). It is recorded as exactly that:
|
||
// staffId === byStaffId, which the staff record reads as "Self-approved" (below).
|
||
// A manager may sign for more than the table proposes — the form itself grants that discretion,
|
||
// and this has never been the place to refuse a decision somebody has already made in ink.
|
||
// But it is recorded, in the one column that survives on the record itself, so that months
|
||
// later the extra set reads as a decision with a name on it rather than as a typo nobody can
|
||
// account for. Written after the manager's own note and never instead of it: their words are the
|
||
// reason, this is only the arithmetic.
|
||
//
|
||
// A casual has no table row — the form hands that number to the manager outright — but it does
|
||
// name 1, 2 or 3, and signing six is as much a departure as six against a table that proposed
|
||
// five. Left unrecorded it was the one case that read, months later, as though nothing out of
|
||
// the ordinary had happened, in exactly the case where somebody exercised the most discretion.
|
||
//
|
||
// The sentence itself is approvalDeparture()'s, in lib/compute, because the staff record
|
||
// previews it while the form is being typed in, and the preview has to be the row it becomes.
|
||
const note = str(p.notes, 400);
|
||
const above = approvalDeparture({ sets, fte, by }) ?? "";
|
||
// A manager signing for her own kit lands here as staffId === byStaffId, and that pair is the
|
||
// whole marking — no flag of its own, because a flag is a second answer that can disagree
|
||
// with the two ids it was summarising. Anything showing an approval reads the pair and says
|
||
// so; the sets themselves are no different for having been self-signed.
|
||
const a = await prisma.approval.create({ data: { facilityId: fid, staffId: s.id, date: dateField(p.date, today, "The approval date"), byName: by, byStaffId: approver?.id ?? null, sets, fte, notes: note && above ? `${note} · ${above}` : note || above, photoId: await ownPhoto(prisma, fid, p.photoId) } });
|
||
return { id: a.id, sets, proposedSets: proposed, by, selfApproved: !!approver && approver.id === s.id };
|
||
}
|
||
case "approval.remove": {
|
||
admin(user);
|
||
const a = await prisma.approval.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!a) throw new OpError("Unknown approval", 404);
|
||
await prisma.approval.delete({ where: { id: a.id } });
|
||
return { ok: true };
|
||
}
|
||
case "alteration.add": {
|
||
const s = await ownStaff(prisma, fid, str(p.staffId));
|
||
const garment = str(p.garment, 120).trim(); if (!garment) throw new OpError("Garment is required");
|
||
const a = await prisma.alteration.create({ data: { facilityId: fid, staffId: s.id, date: today, garment, desc: str(p.desc, 400).trim() } });
|
||
return { id: a.id };
|
||
}
|
||
case "alteration.advance": {
|
||
const a = await prisma.alteration.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!a) throw new OpError("Unknown alteration", 404);
|
||
const ni = ALT_FLOW.indexOf(a.status) + 1;
|
||
if (ni < ALT_FLOW.length) await prisma.alteration.update({ where: { id: a.id }, data: { status: ALT_FLOW[ni] } });
|
||
return { ok: true };
|
||
}
|
||
case "alteration.remove": {
|
||
const a = await prisma.alteration.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!a) throw new OpError("Unknown alteration", 404);
|
||
await prisma.alteration.delete({ where: { id: a.id } });
|
||
return { ok: true };
|
||
}
|
||
|
||
// ---------- issuing
|
||
case "issue.create": {
|
||
const staff = await ownStaff(prisma, fid, str(p.staffId));
|
||
if (staff.inactive) throw new OpError("This staff member is inactive — reactivate them on their profile first");
|
||
const cart = Array.isArray(p.lines) ? p.lines : [];
|
||
if (!cart.length) throw new OpError("Nothing to issue");
|
||
const snap = await buildSnapshot(user);
|
||
const L = ledger(snap);
|
||
const byId = Object.fromEntries(snap.catalog.map((i) => [i.id, i]));
|
||
const stockLines: { itemId: string; si: number; qty: number }[] = [], orderLines: { itemId: string; si: number; qty: number; supplier: string }[] = [], plLines: { itemId: string; si: number; qty: number }[] = [];
|
||
for (const c of cart) {
|
||
const it = byId[str(c.itemId)]; const si = int(c.si, -1); const qty = int(c.qty);
|
||
if (!it || si < 0 || si >= it.sizes.length || qty <= 0) throw new OpError("Invalid cart line");
|
||
if (it.archived) throw new OpError(`${it.item} is discontinued`);
|
||
// Order-in supplier always comes from the product's catalogue record.
|
||
if (c.src === "order") orderLines.push({ itemId: it.id, si, qty, supplier: it.supplier || (snap.settings.suppliers[0] || "Supplier") });
|
||
else if (c.src === "preloved") plLines.push({ itemId: it.id, si, qty });
|
||
else stockLines.push({ itemId: it.id, si, qty });
|
||
}
|
||
// Pre-loved lines draw from the pool (cumulative per variant), free and uncounted.
|
||
const plWanted: Record<string, number> = {};
|
||
for (const c of plLines) plWanted[key(c.itemId, c.si)] = (plWanted[key(c.itemId, c.si)] || 0) + c.qty;
|
||
for (const k in plWanted) if (plWanted[k] > plOf(snap, k)) { const it = byId[k.slice(0, k.lastIndexOf(":"))]; throw new OpError(`Not enough pre-loved ${it?.item} ${it?.sizes[+k.slice(k.lastIndexOf(":") + 1)]} in the pool`); }
|
||
// Shelf check is cumulative per variant (two cart lines for the same size can't each pass alone).
|
||
const wanted: Record<string, number> = {};
|
||
for (const c of stockLines) wanted[key(c.itemId, c.si)] = (wanted[key(c.itemId, c.si)] || 0) + c.qty;
|
||
for (const k in wanted) if (wanted[k] > onhand(snap, L, k)) { const it = byId[k.slice(0, k.lastIndexOf(":"))]; throw new OpError(`Not enough on the shelf for ${it?.item} ${it?.sizes[+k.slice(k.lastIndexOf(":") + 1)]}`); }
|
||
// The ceiling is enforced here and not only in the UI: six sets is the most anyone holds, and
|
||
// past it an issue needs an explicit coordinator override.
|
||
//
|
||
// Six sets at any time, for every group, nursing included — what somebody has on their back and
|
||
// in their locker, not an allowance that starts again in July. So the whole sum is what they
|
||
// have out now plus what is in the bag, there is no date in it, and capCheck() in lib/compute is
|
||
// the one place it is worked out, so the counter screen's warning and this refusal cannot drift
|
||
// apart. The starting kit has stopped being a term in it: a new starter holds nothing and takes
|
||
// three sets, three is inside six, and the head-room that used to be added for them existed only
|
||
// to stop this line turning their own record into an override.
|
||
//
|
||
// The whole cart goes in. Pre-loved lines are free and draw no approval, but six pre-loved tops
|
||
// fill a locker exactly as six new ones do, and leaving them out would make a pre-loved sixth
|
||
// set the way round the ceiling.
|
||
//
|
||
// Garments ordered in count on both sides of the sum. The ones in this cart are added to it; the
|
||
// ones already committed to this person — on an order, waiting at the counter, or approved in a
|
||
// bag nobody has collected — are part of what heldGarments() says they hold. Nothing asks the
|
||
// ceiling again when any of those is handed over, so this is the one moment they are measured:
|
||
// counted only on the day they land, somebody holding nothing could order six sets in today and
|
||
// six more tomorrow, and both carts would pass. The approved bags live outside the snapshot, so
|
||
// they are read in here.
|
||
snap.owedRequestLines = await owedRequestLines(prisma, fid, staff.id);
|
||
const cartLines = [...stockLines, ...plLines, ...orderLines].map((c) => ({ itemId: c.itemId, qty: c.qty }));
|
||
const staffRec = snap.staff.find((x) => x.id === staff.id)!;
|
||
// Somebody is handed their own staff group's garments, plus anything for every group — and, of
|
||
// those, the cut they are offered: the men's range for somebody set to Men's, the women's for
|
||
// Women's, the unisex range for everybody, and the whole catalogue for somebody set to Either or
|
||
// whom nobody has decided yet. Anything else — stock, pre-loved or ordered in — needs the
|
||
// coordinator's override, and is recorded as offGroup or offStyle rather than as `override`,
|
||
// which stays the six-set ceiling's and nothing else's.
|
||
const cartItemIds = [...new Set(cartLines.map((c) => c.itemId))];
|
||
const offGroupOf = (catalog: readonly { id: string; item: string; groups: string[] }[], group: string) =>
|
||
cartItemIds.map((id) => catalog.find((i) => i.id === id)).filter((it): it is NonNullable<typeof it> => !!it && !garmentForGroup(it, group));
|
||
const offStyleOf = (catalog: readonly { id: string; item: string; gender: string }[], style: string) =>
|
||
cartItemIds.map((id) => catalog.find((i) => i.id === id)).filter((it): it is NonNullable<typeof it> => !!it && !garmentForStyle(it, style));
|
||
const offGroup = offGroupOf(snap.catalog, staffRec.group);
|
||
const offStyle = offStyleOf(snap.catalog, staffRec.uniformStyle);
|
||
const fullName = `${staff.first} ${staff.last}`;
|
||
/** Every reason this cart needs the tick, in one refusal — see issueRefusal(). `capNote` is
|
||
* blank where the ceiling isn't part of it. */
|
||
const refusalFor = (og: readonly { id: string; item: string; groups: string[] }[], os: readonly { id: string; item: string; gender: string }[], group: string, style: string, capNote: string) =>
|
||
issueRefusal([
|
||
og.length ? offGroupRefusal(fullName, group, og) : null,
|
||
os.length ? offStyleRefusal(fullName, style, os) : null,
|
||
capNote ? `It would also take them past what one person holds: ${capNote}` : null,
|
||
].filter((x): x is string => x !== null), new Set([...og, ...os].map((i) => i.id)).size);
|
||
// Measured before any refusal, because one override tick answers all of them: a cart that is
|
||
// outside the group, or the wrong cut, AND past six sets must say so in one refusal, or ticking
|
||
// the box for the first would wave the rest through without anybody having been told about them.
|
||
const cap = capCheck(snap, staffRec, cartLines);
|
||
if ((offGroup.length || offStyle.length) && !p.override) throw new OpError(refusalFor(offGroup, offStyle, staffRec.group, staffRec.uniformStyle, cap.over ? cap.note : ""));
|
||
if (cap.over && !p.override) {
|
||
// Read by a coordinator with the person standing in front of them, so it says what they have
|
||
// out, what the ceiling is and what makes room — all three checkable against the pile on the
|
||
// counter. A bare refusal sends somebody back to the ward with nothing to tell their manager.
|
||
const plural = (x: number, one: string, many: string) => `${x} ${x === 1 ? one : many}`;
|
||
const holds = cap.breach === "other" ? `${plural(cap.other, "garment", "garments")} outside a set` : `${plural(cap.tops, "top", "tops")} and ${plural(cap.pants, "pair", "pairs")}`;
|
||
const hasSome = cap.breach === "other" ? cap.other > 0 : cap.tops + cap.pants > 0;
|
||
// Part of what they hold may not have reached them yet, and nobody can see a garment on order
|
||
// in their locker, so the sentence says how much is still to come — and only when some is, so
|
||
// a refusal for somebody with nothing on the way reads exactly as it always has.
|
||
const coming = cap.breach === "other" ? cap.owed.other : cap.owed.tops + cap.owed.pants;
|
||
throw new OpError(`${staff.first} ${staff.last} ${hasSome ? `is holding ${holds}${coming ? `, ${coming} of them still to come` : ""}` : "has nothing out"}. ${cap.note}`);
|
||
}
|
||
// Manager approvals: draw the requested sets down oldest-first, rolling into the next approval.
|
||
//
|
||
// Never for a pre-loved garment. An approval is a ward manager agreeing to pay for new
|
||
// uniform; a pre-loved garment was handed back by somebody else, costs the ward nothing and
|
||
// is reissued free — every other money surface in the product already filters preloved out.
|
||
// Spending a set on one takes something from the wearer that the ward never spent, and
|
||
// nothing on their screen would ever show it happened. cartQty is already "the garments here
|
||
// that are not pre-loved", so an issue with none of those draws nothing down. Clamped on the
|
||
// server because both the counter phone and the desktop send apDeduct, and a rule enforced
|
||
// in a screen is not enforced at all.
|
||
const cartQty = cart.reduce((t: number, c: { qty: unknown; src?: unknown }) => t + (c.src === "preloved" ? 0 : int(c.qty)), 0);
|
||
const aps = openApprovals(snap, staff.id);
|
||
const apAsked = cartQty > 0 ? int(p.apDeduct, 0) : 0;
|
||
let apDeduct = Math.max(0, Math.min(apAsked, aps.reduce((t, a) => t + a.sets - a.used, 0)));
|
||
const apPlan: { id: string; n: number }[] = [];
|
||
for (const a of aps) { if (apDeduct <= 0) break; const n = Math.min(a.sets - a.used, apDeduct); apPlan.push({ id: a.id, n }); apDeduct -= n; }
|
||
const apDeducted = apPlan.reduce((t, x) => t + x.n, 0);
|
||
await lockedTx(fid, async (tx) => {
|
||
// Re-check under the facility lock: the pre-lock snapshot may be stale if another issue just landed.
|
||
const fresh = await buildSnapshot(user, tx); const Lf = ledger(fresh);
|
||
for (const k in wanted) if (wanted[k] > onhand(fresh, Lf, k)) throw new OpError("Someone just issued from that shelf — not enough left, refresh and try again");
|
||
for (const k in plWanted) if (plWanted[k] > plOf(fresh, k)) throw new OpError("The pre-loved pool just changed — refresh and try again");
|
||
const freshAps = openApprovals(fresh, staff.id);
|
||
for (const x of apPlan) { const a = freshAps.find((q) => q.id === x.id); if (!a || a.sets - a.used < x.n) throw new OpError("The manager's approval balance just changed — refresh and try again"); }
|
||
// The ceiling asked again, of what is on the record now. Two counters can serve the same
|
||
// person at once, and without this both hand over the sixth set, both pass, and neither is
|
||
// recorded — which is the one thing the override is for.
|
||
//
|
||
// Stamped only where the ceiling was really passed, and only where somebody chose to pass it:
|
||
// a screen that sends the flag out of habit must not put the word "override" on an ordinary
|
||
// collection, because that column is all anybody has, months later, to tell a decision
|
||
// somebody made from a hand-over that went exactly as it should.
|
||
fresh.owedRequestLines = await owedRequestLines(tx, fid, staff.id);
|
||
const freshRec = fresh.staff.find((x) => x.id === staff.id);
|
||
const freshCap = freshRec ? capCheck(fresh, freshRec, cartLines) : cap;
|
||
if (freshCap.over && !p.override) throw new OpError("Someone just issued to them — that would now take them past what one person holds. Refresh and try again");
|
||
const override = freshCap.over && !!p.override;
|
||
// The staff group and the uniform style asked again, of the catalogue and register as they are
|
||
// now: a garment re-tagged, a person moved group or set to a cut since the screen loaded is
|
||
// measured as it stands. Only the lines that are really outside the group, or really the wrong
|
||
// cut, are stamped, whatever the tick said.
|
||
const freshGroup = freshRec ? freshRec.group : staffRec.group;
|
||
const freshStyle = freshRec ? freshRec.uniformStyle : staffRec.uniformStyle;
|
||
const freshOff = offGroupOf(fresh.catalog, freshGroup);
|
||
const freshOffStyle = offStyleOf(fresh.catalog, freshStyle);
|
||
if ((freshOff.length || freshOffStyle.length) && !p.override) throw new OpError(refusalFor(freshOff, freshOffStyle, freshGroup, freshStyle, ""));
|
||
const offIds = new Set(freshOff.map((i) => i.id));
|
||
const offStyleIds = new Set(freshOffStyle.map((i) => i.id));
|
||
for (const c of stockLines) await tx.issue.create({ data: { facilityId: fid, date: today, staffId: staff.id, itemId: c.itemId, sizeIndex: c.si, qty: c.qty, cond: "New", cost: byId[c.itemId].cost, override, offGroup: offIds.has(c.itemId), offStyle: offStyleIds.has(c.itemId) } });
|
||
// Pre-loved rows carry the override too. They cost the ward nothing and draw no approval, but
|
||
// they fill a locker like anything else and the ceiling counts them, so a bag of pre-loved
|
||
// garments handed over past six sets was a rule bent — and with the flag left off these rows
|
||
// a wholly pre-loved override would show on nobody's exceptions report at all.
|
||
for (const c of plLines) { await tx.issue.create({ data: { facilityId: fid, date: today, staffId: staff.id, itemId: c.itemId, sizeIndex: c.si, qty: c.qty, cond: "Pre-loved", cost: 0, preloved: true, override, offGroup: offIds.has(c.itemId), offStyle: offStyleIds.has(c.itemId) } }); await poolAdd(tx, fid, c.itemId, c.si, -c.qty); }
|
||
const bySup: Record<string, typeof stockLines> = {};
|
||
for (const c of stockLines) { const sup = byId[c.itemId].supplier || snap.settings.suppliers[0] || "Supplier"; (bySup[sup] = bySup[sup] || []).push(c); }
|
||
for (const sup in bySup) await mergeReplenish(tx, fid, today, sup, bySup[sup].map((c) => ({ itemId: c.itemId, size: String(byId[c.itemId].sizes[c.si]), qty: c.qty })), "add", "Replenishment — replaces issued stock");
|
||
const ordBySup: Record<string, typeof orderLines> = {};
|
||
for (const c of orderLines) (ordBySup[c.supplier] = ordBySup[c.supplier] || []).push(c);
|
||
// An override that went on ordered-in lines is written on the order. There is no issue row to
|
||
// stamp yet, and the rows made when the garments are collected come from the order rather than
|
||
// from this decision, so without the note a cart of nothing but ordered-in lines could go past
|
||
// six and leave no trace of it anywhere.
|
||
// The same goes for ordered-in garments outside the person's staff group, and for ones that
|
||
// are not the cut they are offered.
|
||
for (const sup in ordBySup) {
|
||
const code = await nextOrderCode(tx, fid, today);
|
||
const offNames = [...new Set(ordBySup[sup].filter((c) => offIds.has(c.itemId)).map((c) => byId[c.itemId].item))];
|
||
const offNote = offNames.length ? ` · Outside ${staff.first}'s staff group: ${offNames.join(", ")} — override recorded by ${byName}` : "";
|
||
const styleNames = [...new Set(ordBySup[sup].filter((c) => offStyleIds.has(c.itemId)).map((c) => byId[c.itemId].item))];
|
||
const styleNote = styleNames.length ? ` · Not ${staff.first}'s uniform style: ${styleNames.join(", ")} — override recorded by ${byName}` : "";
|
||
await tx.order.create({ data: { facilityId: fid, code, date: today, source: "Supplier Order", orderFor: "Staff Member", staffId: staff.id, supplier: sup, status: "Ordered", expected: await expectedFor(tx, fid, sup, today), cc: staff.ccOverride || staff.dept, notes: `Ordered at issue for ${staff.first} ${staff.last}` + (override ? ` · Past the ${freshCap.cap} sets one person holds — override recorded by ${byName}` : "") + offNote + styleNote, lines: { create: ordBySup[sup].map((c, i) => ({ itemId: c.itemId, size: String(byId[c.itemId].sizes[c.si]), qty: c.qty, sort: i })) } } });
|
||
}
|
||
for (const x of apPlan) await tx.approval.update({ where: { id: x.id }, data: { used: { increment: x.n } } });
|
||
});
|
||
return { stock: stockLines.reduce((t, c) => t + c.qty, 0), ordered: orderLines.reduce((t, c) => t + c.qty, 0), preloved: plLines.reduce((t, c) => t + c.qty, 0), apDeducted, apRemaining: aps.reduce((t, a) => t + a.sets - a.used, 0) - apDeducted, offGroup: offGroup.length, offStyle: offStyle.length };
|
||
}
|
||
case "handin.add": {
|
||
// Uniform hand-in: Good lines join the pre-loved pool, Rag lines are tallied for disposal, matched past
|
||
// issues are stamped handedIn — pre-loved ones too — and (coordinator's call) the year's figure / manager's
|
||
// approval balance is credited for the new garments among them.
|
||
//
|
||
// The stamp is what makes room, and it happens whether or not the coordinator ticks credit: a
|
||
// garment marked handed in stops counting towards the six sets this person holds, so somebody
|
||
// who brings a set back can be given a replacement without anybody overriding anything. The
|
||
// tick is a separate kindness — it hands a drawn set back to the manager's approval balance and
|
||
// credits the year's reporting figure — and forgetting it can no longer strand a wearer at the
|
||
// counter with an empty locker.
|
||
const staff = await ownStaff(prisma, fid, str(p.staffId));
|
||
const raw = Array.isArray(p.lines) ? p.lines : [];
|
||
if (!raw.length) throw new OpError("Add at least one garment");
|
||
const snap = await buildSnapshot(user);
|
||
const byId = Object.fromEntries(snap.catalog.map((i) => [i.id, i]));
|
||
const lines: { itemId: string; si: number; qty: number; cond: string; laundered: boolean }[] = [];
|
||
for (const l of raw) {
|
||
const it = byId[str(l.itemId)]; const si = int(l.si, -1); const qty = int(l.qty);
|
||
if (!it || si < 0 || si >= it.sizes.length || qty <= 0) throw new OpError("Invalid hand-in line");
|
||
lines.push({ itemId: it.id, si, qty, cond: l.cond === "Rag" ? "Rag" : "Good", laundered: l.laundered !== false });
|
||
}
|
||
const credit = !!p.credit;
|
||
const good = lines.filter((l) => l.cond === "Good"), rag = lines.filter((l) => l.cond === "Rag");
|
||
const goodN = good.reduce((t, l) => t + l.qty, 0), ragN = rag.reduce((t, l) => t + l.qty, 0);
|
||
let setsBack = 0;
|
||
const rec = await lockedTx(fid, async (tx) => {
|
||
// Stamp the garments' original issues as handed in — pre-loved ones included. A pre-loved garment
|
||
// counts towards the six sets somebody holds, so giving one back has to make the same room as
|
||
// giving back a new one: left unstamped, a wearer at six who hands a pre-loved top back is
|
||
// still at six and is refused its replacement. Credit is the part that still ignores
|
||
// pre-loved. It hands a drawn set back to the manager's approval balance and credits the
|
||
// year's reporting figure, and a pre-loved garment never drew on either — so only Good lines
|
||
// matched to a new issue earn it.
|
||
//
|
||
// When somebody holds a new and a pre-loved copy of the same size, the new one is matched
|
||
// first. Two tunics of one size can't be told apart across the counter, so this is a choice,
|
||
// and it is the one that changes nothing that was already true: every hand-in that matched
|
||
// before matches the same rows and earns the same credit, and pre-loved rows only take up what
|
||
// used to fall through unmatched. Matching pre-loved first would quietly withhold credit a
|
||
// coordinator had ticked for somebody handing back a garment the ward paid for. Newest first
|
||
// within each, as before.
|
||
const credited: number[] = lines.map(() => 0);
|
||
for (let ix = 0; ix < lines.length; ix++) {
|
||
const l = lines[ix]; let left = l.qty;
|
||
const past = await tx.issue.findMany({ where: { facilityId: fid, staffId: staff.id, itemId: l.itemId, sizeIndex: l.si, returnedDate: null, handedIn: null }, orderBy: [{ preloved: "asc" }, { date: "desc" }] });
|
||
for (const i of past) {
|
||
if (left <= 0) break;
|
||
const m = Math.min(left, i.qty);
|
||
if (m < i.qty) {
|
||
// Only part of that line came back. Stamping the whole row would take the garments
|
||
// still in the wearer's possession off her record — five issued, one handed in, four
|
||
// gone from the books — so the row is split the same way a partial exchange splits
|
||
// it: the handed-in quantity becomes its own row and the rest stays out with her.
|
||
await tx.issue.update({ where: { id: i.id }, data: { qty: i.qty - m } });
|
||
await tx.issue.create({ data: { facilityId: fid, date: i.date, staffId: i.staffId, itemId: i.itemId, sizeIndex: i.sizeIndex, qty: m, cond: i.cond, cost: i.cost, orderCode: i.orderCode, receipt: i.receipt, preloved: i.preloved, override: i.override, offGroup: i.offGroup, offStyle: i.offStyle, direct: i.direct, handedIn: today } });
|
||
} else {
|
||
await tx.issue.update({ where: { id: i.id }, data: { handedIn: today } });
|
||
}
|
||
left -= m;
|
||
if (credit && l.cond === "Good" && !i.preloved) credited[ix] += m;
|
||
}
|
||
}
|
||
const h = await tx.handIn.create({ data: { facilityId: fid, date: today, staffId: staff.id, byName, credit, lines: { create: lines.map((l, ix) => ({ itemId: l.itemId, sizeIndex: l.si, qty: l.qty, cond: l.cond, laundered: l.laundered, credited: credited[ix] })) } } });
|
||
for (const l of good) await poolAdd(tx, fid, l.itemId, l.si, l.qty);
|
||
// Nursing credit = credited sets (1 set = 1 top + 1 pants), returned to drawn approvals newest-first.
|
||
if (credit) {
|
||
const gt = lines.reduce((t, l, ix) => t + (isTopItem(byId[l.itemId]) ? credited[ix] : 0), 0), gp = lines.reduce((t, l, ix) => t + (isPantItem(byId[l.itemId]) ? credited[ix] : 0), 0);
|
||
setsBack = Math.max(gt, gp);
|
||
let left = setsBack;
|
||
const aps = await tx.approval.findMany({ where: { facilityId: fid, staffId: staff.id, used: { gt: 0 } }, orderBy: { date: "desc" } });
|
||
for (const ap of aps) { if (left <= 0) break; const give = Math.min(ap.used, left); await tx.approval.update({ where: { id: ap.id }, data: { used: ap.used - give } }); left -= give; }
|
||
}
|
||
return h;
|
||
});
|
||
return { id: rec.id, good: goodN, rag: ragN, credit, setsBack, message: `Hand-in recorded for ${staff.first} ${staff.last}: ${goodN ? `${goodN} to the pre-loved pool` : ""}${goodN && ragN ? " · " : ""}${ragN ? `${ragN} to rag disposal` : ""}${credit ? " · allowance credited" : ""}.` };
|
||
}
|
||
case "photo.put": {
|
||
// Camera capture / signature from the device: JPEG or PNG data URL, already downscaled client-side.
|
||
const data = str(p.data, PHOTO_MAX + 200);
|
||
if (!/^data:image\/(jpeg|png);base64,[A-Za-z0-9+/=]+$/.test(data)) throw new OpError("Photo must be a JPEG or PNG");
|
||
if (data.length > PHOTO_MAX) throw new OpError("Photo is too large — try again");
|
||
const kind = ["approval", "receipt", "return", "sig", "proof"].includes(str(p.kind)) ? str(p.kind) : "photo";
|
||
// A demo visitor signs for a delivery like anyone else, but the demo is open to the internet
|
||
// on a shared admin session: without a ceiling that is an anonymous upload endpoint, and the
|
||
// per-facility rate limit resets with the facility every twenty minutes.
|
||
if (user.isDemo && (await prisma.photo.count({ where: { facilityId: fid } })) >= DEMO_PHOTO_MAX) throw new OpError("The demo only keeps a handful of photos — it resets every 20 minutes.");
|
||
const parsed = parseDataUrl(data);
|
||
if (!parsed) throw new OpError("Photo must be a JPEG or PNG");
|
||
// The row is created first so the file can be named after its id, which is what makes the
|
||
// path derivable from data we generated rather than from anything the request supplied.
|
||
const ph = await prisma.photo.create({ data: { facilityId: fid, kind, mime: parsed.mime, bytes: parsed.bytes.length } });
|
||
try {
|
||
const rel = await writePhoto(fid, ph.id, parsed);
|
||
await prisma.photo.update({ where: { id: ph.id }, data: { path: rel } });
|
||
} catch (e) {
|
||
// A row pointing at a file that was never written is worse than no row at all.
|
||
await prisma.photo.delete({ where: { id: ph.id } }).catch(() => {});
|
||
console.error("[photo] could not write to disk:", (e as Error).message);
|
||
throw new OpError("Couldn't save the photo — try again");
|
||
}
|
||
if (Math.random() < 0.05) await gcPhotos(fid).catch(() => {});
|
||
return { id: ph.id };
|
||
}
|
||
case "pickup.deliver": {
|
||
// Delivery round: the receiver signs on screen; same stock effect as a counter pickup (direct issues).
|
||
const pu = await prisma.pickup.findFirst({ where: { id: str(p.id), facilityId: fid }, include: { lines: true, order: true } }); if (!pu) throw new OpError("Unknown pickup", 404);
|
||
if (pu.pickedUp) throw new OpError("Already handed over");
|
||
const sigId = await ownPhoto(prisma, fid, p.sigId), proofId = await ownPhoto(prisma, fid, p.proofId);
|
||
const items = await prisma.catalogItem.findMany({ where: { facilityId: fid } });
|
||
const byId: Record<string, Item> = Object.fromEntries(items.map((i) => [i.id, asItem(i)]));
|
||
await lockedTx(fid, async (tx) => {
|
||
const cur = await tx.pickup.findUnique({ where: { id: pu.id }, select: { pickedUp: true } });
|
||
if (cur?.pickedUp) throw new OpError("Already handed over");
|
||
await tx.pickup.update({ where: { id: pu.id }, data: { pickedUp: today, deliveredTo: str(p.deliveredTo, 120).trim(), sigId, proofId, deliveredRound: true } });
|
||
// No ceiling check and no override stamp here, on purpose. These garments have counted towards
|
||
// the six this person holds since the day they were ordered — as an order line, then as this
|
||
// pickup — so handing them over moves them from owed to issued and the total doesn't change.
|
||
// Asking capCheck() with them as a cart would count them twice and brand an ordinary
|
||
// collection an override; any decision to go past six was made when they were ordered.
|
||
//
|
||
// The staff group and the uniform style are different: neither is refused here — the only way
|
||
// a garment outside either reaches a staff order is the counter's Order in with the override
|
||
// ticked — but both are stamped, so the Exceptions report sees the garment in the month it
|
||
// reaches them.
|
||
const wearer = await tx.staff.findUnique({ where: { id: pu.staffId }, select: { group: true, uniformStyle: true } });
|
||
for (const l of pu.lines) {
|
||
const it = byId[l.itemId] as Item | undefined;
|
||
const si = sizeIndexOf(it, l.size);
|
||
if (si < 0) throw new OpError(`Size ${l.size} is no longer on ${it?.item || "the item"} — fix the catalogue before marking this delivered`);
|
||
await tx.issue.create({ data: { facilityId: fid, date: today, staffId: pu.staffId, itemId: l.itemId, sizeIndex: si, qty: l.qty, cond: "New", cost: it?.cost || 0, orderCode: pu.order.code, direct: true, receipt: !!sigId, offGroup: !!it && !garmentForGroup(it, wearer?.group), offStyle: !!it && !garmentForStyle(it, wearer?.uniformStyle) } });
|
||
}
|
||
});
|
||
return { ok: true };
|
||
}
|
||
case "issue.receipt": {
|
||
const i = await prisma.issue.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!i) throw new OpError("Unknown issue", 404);
|
||
await prisma.issue.update({ where: { id: i.id }, data: { receipt: !!p.receipt } });
|
||
return { ok: true };
|
||
}
|
||
case "issue.return": {
|
||
const i = await prisma.issue.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!i) throw new OpError("Unknown issue", 404);
|
||
if (i.returnedDate) throw new OpError("This issue has already been returned / written off");
|
||
if (i.handedIn) throw new OpError("This garment was handed in on " + i.handedIn + " — it's already back in the pool");
|
||
const cond = str(p.cond, 40);
|
||
if (!["Returned - Good", "Returned - Damaged", "Lost", "Written Off"].includes(cond)) throw new OpError("Bad condition");
|
||
const photoId = await ownPhoto(prisma, fid, p.photoId);
|
||
/* Part of a line can come back on its own.
|
||
*
|
||
* An issue row is a quantity — three tunics handed over in one act — and returning the row
|
||
* used to be all-or-nothing, so bringing one of the three back credited the shelf with all
|
||
* three and cleared the other two off the wearer's record. ledger() reads the row's qty, not
|
||
* how many garments were physically handed over the counter, so the stock figure was simply
|
||
* wrong from that moment on. Splitting the row the way issue.exchange already does keeps the
|
||
* rest out in the person's name. A missing or oversized qty still means the whole line, so
|
||
* every caller that never knew about this carries on behaving exactly as it did. */
|
||
const qty = Math.min(i.qty, Math.max(1, int(p.qty, i.qty)));
|
||
await lockedTx(fid, async (tx) => {
|
||
if (qty < i.qty) {
|
||
await tx.issue.update({ where: { id: i.id }, data: { qty: i.qty - qty } });
|
||
await tx.issue.create({ data: { facilityId: fid, date: i.date, staffId: i.staffId, itemId: i.itemId, sizeIndex: i.sizeIndex, qty, cond: i.cond, cost: i.cost, orderCode: i.orderCode, preloved: i.preloved, override: i.override, offGroup: i.offGroup, offStyle: i.offStyle, direct: i.direct, returnedDate: today, returnedCond: cond, returnPhotoId: photoId } });
|
||
} else {
|
||
await tx.issue.update({ where: { id: i.id }, data: { returnedDate: today, returnedCond: cond, returnPhotoId: photoId } });
|
||
}
|
||
if (i.preloved && cond === "Returned - Good") await poolAdd(tx, fid, i.itemId, i.sizeIndex, qty); // a pre-loved garment returns to the pool, not the shelf
|
||
});
|
||
return { ok: true };
|
||
}
|
||
case "issue.delete": {
|
||
admin(user);
|
||
const i = await prisma.issue.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!i) throw new OpError("Unknown issue", 404);
|
||
await prisma.issue.delete({ where: { id: i.id } });
|
||
return { ok: true };
|
||
}
|
||
|
||
// ---------- orders
|
||
case "order.create": {
|
||
const orderFor = p.orderFor === "Staff Member" ? "Staff Member" : "Stock";
|
||
const staff = orderFor === "Staff Member" ? await ownStaff(prisma, fid, str(p.staffId)) : null;
|
||
const lines = Array.isArray(p.lines) ? p.lines : [];
|
||
if (!lines.length) throw new OpError("Add at least one line");
|
||
await refuseWrongGarmentOnOrder(fid, staff, lines.map((l: { itemId?: unknown }) => str(l?.itemId)));
|
||
return prisma.$transaction(async (tx) => {
|
||
const code = await nextOrderCode(tx, fid, today);
|
||
const create: { itemId: string; size: string; qty: number; sort: number }[] = [];
|
||
for (const [i, l] of lines.entries()) { const it = await ownItem(tx, fid, str(l.itemId)); const size = str(l.size, 20); if (!it.sizes.map(String).includes(size)) throw new OpError("Invalid size"); const qty = int(l.qty); if (qty <= 0) throw new OpError("Invalid quantity"); create.push({ itemId: it.id, size, qty, sort: i }); }
|
||
const o = await tx.order.create({ data: { facilityId: fid, code, date: today, source: "Supplier Order", orderFor, staffId: staff?.id ?? null, supplier: str(p.supplier, 80), status: "Draft", expected: dateField(p.expected, addDays(today, 14), "The expected date"), cc: str(p.cc, 120) || (staff ? staff.ccOverride || staff.dept : ""), notes: str(p.notes, 400), replenish: !!p.replenish, lines: { create } } });
|
||
return { id: o.id, code };
|
||
});
|
||
}
|
||
case "order.duplicate": {
|
||
const o = await ownOrder(prisma, fid, str(p.id));
|
||
// A copy is a new decision. An order the counter raised on the override (Order in) carries
|
||
// garments outside the person's group, and copying it must not carry the override with it.
|
||
await refuseWrongGarmentOnOrder(fid, await orderStaff(fid, o), o.lines.map((l) => l.itemId));
|
||
return prisma.$transaction(async (tx) => {
|
||
const code = await nextOrderCode(tx, fid, today);
|
||
const n = await tx.order.create({ data: { facilityId: fid, code, date: today, source: "Supplier Order", orderFor: o.orderFor, staffId: o.staffId, supplier: o.supplier, status: "Draft", expected: "", cc: o.cc, notes: `Duplicated from ${o.code}`, lines: { create: o.lines.map((l, i) => ({ itemId: l.itemId, size: l.size, qty: l.qty, sort: i })) } } });
|
||
return { id: n.id, code };
|
||
});
|
||
}
|
||
case "order.update": {
|
||
const o = await ownOrder(prisma, fid, str(p.id));
|
||
if (o.status === "Received" || o.status === "Cancelled") throw new OpError(`This order is ${o.status.toLowerCase()} — its details are locked`);
|
||
const data: Prisma.OrderUpdateInput = {};
|
||
for (const k of ["ref", "invoice", "tracking", "cc", "notes", "supplier"] as const) if (p[k] !== undefined) data[k] = str(p[k], k === "notes" ? 400 : 120);
|
||
// The expected date is a date, not free text: stored unvalidated it reaches the orders
|
||
// screen as something no formatter can read, on the one column the overdue list sorts on.
|
||
if (p.expected !== undefined) data.expected = dateField(p.expected, "", "The expected date");
|
||
if (p.staffId !== undefined) {
|
||
const sid = str(p.staffId);
|
||
if (sid) {
|
||
const s = await ownStaff(prisma, fid, sid);
|
||
// Only a change of person is measured. Saving the same person back — the order screen's
|
||
// picker does — must not refuse an order the counter raised on the override for them.
|
||
if (s.id !== o.staffId) await refuseWrongGarmentOnOrder(fid, s, o.lines.map((l) => l.itemId), true);
|
||
data.staff = { connect: { id: s.id } }; data.orderFor = "Staff Member"; data.cc = s.ccOverride || s.dept;
|
||
}
|
||
else { data.staff = { disconnect: true }; data.orderFor = "Stock"; data.cc = ""; }
|
||
}
|
||
await prisma.order.update({ where: { id: o.id }, data });
|
||
return { ok: true };
|
||
}
|
||
case "order.status": {
|
||
const o = await ownOrder(prisma, fid, str(p.id));
|
||
const st = str(p.status, 20);
|
||
if (st === "Cancelled") { admin(user); if (!["Draft", "Ordered", "Back Order", "Shipped"].includes(o.status)) throw new OpError("Order can't be cancelled"); }
|
||
else if (st === "Ordered") { if (o.status !== "Draft") throw new OpError("Only drafts can be marked ordered"); if (!o.lines.length) throw new OpError("Order has no lines"); }
|
||
else if (st === "Shipped") { if (!["Ordered", "Back Order"].includes(o.status)) throw new OpError("Not an open order"); }
|
||
else throw new OpError("Bad status");
|
||
await prisma.order.update({ where: { id: o.id }, data: { status: st } });
|
||
return { ok: true };
|
||
}
|
||
case "order.lineRemove": {
|
||
const o = await ownOrder(prisma, fid, str(p.id));
|
||
if (o.status !== "Draft") throw new OpError("Only draft lines can be removed");
|
||
if (o.lines.length <= 1) throw new OpError("An order needs at least one line");
|
||
await prisma.orderLine.deleteMany({ where: { id: str(p.lineId), orderId: o.id } });
|
||
return { ok: true };
|
||
}
|
||
case "order.lineAdd": {
|
||
const o = await ownOrder(prisma, fid, str(p.id));
|
||
if (o.status !== "Draft") throw new OpError("Only drafts can be edited");
|
||
const it = await ownItem(prisma, fid, str(p.itemId)); const size = str(p.size, 20); const qty = Math.max(1, int(p.qty, 1));
|
||
if (!it.sizes.map(String).includes(size)) throw new OpError("Invalid size");
|
||
await refuseWrongGarmentOnOrder(fid, await orderStaff(fid, o), [it.id]);
|
||
const ex = o.lines.find((l) => l.itemId === it.id && l.size === size);
|
||
if (ex) await prisma.orderLine.update({ where: { id: ex.id }, data: { qty: { increment: qty } } });
|
||
else await prisma.orderLine.create({ data: { orderId: o.id, itemId: it.id, size, qty, sort: o.lines.length } });
|
||
return { ok: true };
|
||
}
|
||
case "order.lineQty": {
|
||
const o = await ownOrder(prisma, fid, str(p.id));
|
||
if (o.status !== "Draft") throw new OpError("Only drafts can be edited");
|
||
const qty = int(p.qty);
|
||
if (qty <= 0) throw new OpError("Quantity must be at least 1");
|
||
// More of a garment is more of it on their record; fewer is always allowed, so a line that
|
||
// would now be refused can still be brought down or taken off.
|
||
const line = o.lines.find((l) => l.id === str(p.lineId));
|
||
if (line && qty > line.qty) await refuseWrongGarmentOnOrder(fid, await orderStaff(fid, o), [line.itemId], true);
|
||
await prisma.orderLine.updateMany({ where: { id: str(p.lineId), orderId: o.id }, data: { qty } });
|
||
return { ok: true };
|
||
}
|
||
case "order.receive": {
|
||
const o0 = await ownOrder(prisma, fid, str(p.id));
|
||
if (!["Ordered", "Shipped", "Back Order"].includes(o0.status)) throw new OpError(o0.status === "Draft" ? "Mark the order as ordered before receiving it" : "Order isn't receivable");
|
||
const lines = Array.isArray(p.lines) ? p.lines : [];
|
||
const items = await prisma.catalogItem.findMany({ where: { facilityId: fid } });
|
||
const byId = Object.fromEntries(items.map((i) => [i.id, i]));
|
||
const date = dateField(p.date, today, "The delivery date"), invoice = str(p.invoice, 80), note = str(p.note, 400);
|
||
await lockedTx(fid, async (tx) => {
|
||
// Re-read inside the transaction so two simultaneous receives can't both see the full quantity outstanding.
|
||
const o = await ownOrder(tx, fid, o0.id);
|
||
if (o.status === "Received") throw new OpError("This order has already been received");
|
||
const recLines: { itemId: string; size: string; qty: number; dest: string; cost: number }[] = [], shorts: { itemId: string; size: string; qty: number }[] = [], puLines: { itemId: string; size: string; qty: number }[] = [];
|
||
for (const ol of o.lines) {
|
||
const l = lines.find((x: { lineId?: string; itemId?: string; size?: string }) => (x.lineId && x.lineId === ol.id) || (x.itemId === ol.itemId && String(x.size) === ol.size));
|
||
const it = byId[ol.itemId]; if (!it) continue;
|
||
const already = o.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === ol.itemId && x.size === ol.size).reduce((s, x) => s + x.qty, 0), 0);
|
||
const outstanding = Math.max(0, ol.qty - already);
|
||
if (int(l?.arrived, 0) > outstanding) throw new OpError(`${it.item} ${ol.size}: ${int(l?.arrived, 0)} arrived but only ${outstanding} outstanding — receive the surplus as stock without an order`);
|
||
const a = Math.max(0, Math.min(int(l?.arrived, 0), outstanding));
|
||
const dest = l?.dest === "pickup" && o.staffId ? "pickup" : "shelf";
|
||
const cost = l && l.cost !== "" && l.cost !== undefined ? num(l.cost, it.cost) : it.cost;
|
||
if (!(cost >= 0 && cost <= 100000)) throw new OpError("Invoiced cost must be between $0 and $100,000");
|
||
if (a > 0) { const line = { itemId: it.id, size: ol.size, qty: a, dest, cost }; recLines.push(line); if (dest === "pickup") puLines.push(line); }
|
||
if (a < outstanding) shorts.push({ itemId: it.id, size: ol.size, qty: outstanding - a });
|
||
// Correcting the catalogue from the invoice is a price change like any other, so it
|
||
// leaves the same trail: without it the cost history jumps from the old figure straight
|
||
// to the next manual edit, and the "up from …" line on that entry is wrong.
|
||
if (l?.priceAction === "update" && user.role === "ADMIN" && Number.isFinite(cost) && cost > 0 && cost !== it.cost) {
|
||
await tx.catalogItem.update({ where: { id: it.id }, data: { cost } });
|
||
await tx.costChange.create({ data: { facilityId: fid, itemId: it.id, cost, previous: it.cost, byName } });
|
||
}
|
||
}
|
||
if (!recLines.length) throw new OpError("Nothing arrived — enter at least one quantity");
|
||
await tx.receipt.create({ data: { orderId: o.id, date, invoice, note, photoId: await ownPhoto(tx, fid, p.photoId), lines: { create: recLines } } });
|
||
await tx.order.update({ where: { id: o.id }, data: { invoice: invoice || o.invoice, received: date, status: "Received", notes: note ? (o.notes ? o.notes + " · " : "") + note : o.notes } });
|
||
if (shorts.length) {
|
||
const code = await nextOrderCode(tx, fid, today);
|
||
await tx.order.create({ data: { facilityId: fid, code, date: today, source: "Supplier Order", orderFor: o.orderFor, staffId: o.staffId, supplier: o.supplier, status: "Back Order", ref: o.ref, expected: await expectedFor(tx, fid, o.supplier, today), cc: o.cc, notes: `Back order — short on ${o.code}`, replenish: o.replenish, parentId: o.id, lines: { create: shorts.map((s, i) => ({ ...s, sort: i })) } } });
|
||
}
|
||
if (puLines.length && o.staffId) await tx.pickup.create({ data: { facilityId: fid, orderId: o.id, staffId: o.staffId, received: date, lines: { create: puLines.map((l) => ({ itemId: l.itemId, size: l.size, qty: l.qty })) } } });
|
||
});
|
||
return { ok: true };
|
||
}
|
||
|
||
// ---------- pickups
|
||
case "pickup.contacted": {
|
||
const pu = await prisma.pickup.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!pu) throw new OpError("Unknown pickup", 404);
|
||
await prisma.pickup.update({ where: { id: pu.id }, data: { contacted: p.contacted === undefined ? true : !!p.contacted } });
|
||
return { ok: true };
|
||
}
|
||
case "pickup.pickedUp": {
|
||
const pu = await prisma.pickup.findFirst({ where: { id: str(p.id), facilityId: fid }, include: { lines: true, order: true } }); if (!pu) throw new OpError("Unknown pickup", 404);
|
||
if (pu.pickedUp) return { ok: true };
|
||
const items = await prisma.catalogItem.findMany({ where: { facilityId: fid } });
|
||
const byId: Record<string, Item> = Object.fromEntries(items.map((i) => [i.id, asItem(i)]));
|
||
await lockedTx(fid, async (tx) => {
|
||
const cur = await tx.pickup.findUnique({ where: { id: pu.id }, select: { pickedUp: true } });
|
||
if (cur?.pickedUp) return; // already collected by a concurrent click
|
||
await tx.pickup.update({ where: { id: pu.id }, data: { pickedUp: today } });
|
||
// The same as a delivery on the round: no ceiling check and no override stamp. These garments
|
||
// have counted towards the six this person holds since the day they were ordered — as an order
|
||
// line, then as this pickup — so handing them over moves them from owed to issued and the
|
||
// total doesn't change.
|
||
// Asking capCheck() with them as a cart would count them twice and brand an ordinary
|
||
// collection an override; any decision to go past six was made when they were ordered.
|
||
//
|
||
// And, as on the round, a garment outside their staff group is stamped offGroup — and one
|
||
// that is not their cut offStyle — rather than refused: either can only be on their order
|
||
// because the counter ordered it in on the override.
|
||
const wearer = await tx.staff.findUnique({ where: { id: pu.staffId }, select: { group: true, uniformStyle: true } });
|
||
for (const l of pu.lines) {
|
||
const it = byId[l.itemId] as Item | undefined;
|
||
const si = sizeIndexOf(it, l.size);
|
||
if (si < 0) throw new OpError(`Size ${l.size} is no longer on ${it?.item || "the item"} — fix the catalogue before marking this picked up`);
|
||
await tx.issue.create({ data: { facilityId: fid, date: today, staffId: pu.staffId, itemId: l.itemId, sizeIndex: si, qty: l.qty, cond: "New", cost: it?.cost || 0, orderCode: pu.order.code, direct: true, offGroup: !!it && !garmentForGroup(it, wearer?.group), offStyle: !!it && !garmentForStyle(it, wearer?.uniformStyle) } });
|
||
}
|
||
});
|
||
return { ok: true };
|
||
}
|
||
|
||
// ---------- stocktake
|
||
case "stocktake.apply": {
|
||
const counts = Array.isArray(p.lines) ? p.lines : [];
|
||
if (!counts.length) throw new OpError("Nothing counted");
|
||
const snap = await buildSnapshot(user);
|
||
const L = ledger(snap);
|
||
const variants = variantList(snap);
|
||
const vk = new Set(variants.map((v) => v.key));
|
||
const plMode = p.mode === "preloved"; // pre-loved counts write the pool directly (at $0)
|
||
// A count can be scoped to a location (and everything under it), so a shelf take files as a shelf take.
|
||
const locationId = str(p.locationId) || null;
|
||
if (locationId && !snap.locations.some((l) => l.id === locationId)) throw new OpError("Unknown location", 404);
|
||
const scope = locationId ? locSubtree(snap, locationId) : null;
|
||
// Every counted line is filed (so "last counted" is right even when it matched); only variances adjust stock.
|
||
const lines: { itemId: string; si: number; sys: number; counted: number; reason: string }[] = [];
|
||
const seen = new Set<string>();
|
||
for (const c of counts) {
|
||
const it = str(c.itemId), si = int(c.si, -1); const k = key(it, si);
|
||
if (!vk.has(k) || seen.has(k)) continue;
|
||
if (scope && !scope.has(snap.placed[k] || "")) throw new OpError("A counted line isn't on that shelf — refresh and start the count again");
|
||
seen.add(k);
|
||
lines.push({ itemId: it, si, sys: plMode ? plOf(snap, k) : onhand(snap, L, k), counted: Math.max(0, int(c.counted)), reason: str(c.reason, 40) });
|
||
}
|
||
if (!lines.length) throw new OpError("Nothing counted");
|
||
const variances = lines.filter((l) => l.counted !== l.sys);
|
||
// A large gap has to say why, or a shelf quietly loses garments with nothing on the record.
|
||
const gate = Math.max(1, snap.settings.varianceReason);
|
||
const unexplained = variances.find((l) => Math.abs(l.counted - l.sys) >= gate && !l.reason);
|
||
if (unexplained) throw new OpError("A gap that size needs a reason before the count can be committed");
|
||
await lockedTx(fid, async (tx) => {
|
||
for (const l of variances) { if (plMode) await upsertLevel(tx, fid, l.itemId, l.si, { preloved: l.counted }); else await upsertLevel(tx, fid, l.itemId, l.si, { adj: { increment: l.counted - l.sys } }); }
|
||
await tx.stocktake.create({ data: { facilityId: fid, date: today, byName, counted: lines.length, variances: variances.length, mode: plMode ? "preloved" : "shelf", locationId, lines: { create: lines.map((l) => ({ itemId: l.itemId, sizeIndex: l.si, sys: l.sys, counted: l.counted, reason: l.reason })) } } });
|
||
});
|
||
return { counted: lines.length, variances: variances.length };
|
||
}
|
||
|
||
// ---------- locations
|
||
case "location.save": {
|
||
admin(user);
|
||
const id = str(p.id);
|
||
const name = str(p.name, 60).trim();
|
||
if (!name) throw new OpError("Give the location a name");
|
||
const kind = LOCATION_KINDS.includes(str(p.kind)) ? str(p.kind) : "Shelf";
|
||
const parentId = str(p.parentId) || null;
|
||
if (parentId) {
|
||
const par = await prisma.location.findFirst({ where: { id: parentId, facilityId: fid } });
|
||
if (!par) throw new OpError("Unknown parent location", 404);
|
||
}
|
||
const clash = await prisma.location.findFirst({ where: { facilityId: fid, name, NOT: id ? { id } : undefined } });
|
||
if (clash) throw new OpError(`There is already a location called ${name}`);
|
||
if (id) {
|
||
const cur = await prisma.location.findFirst({ where: { id, facilityId: fid } });
|
||
if (!cur) throw new OpError("Unknown location", 404);
|
||
// A location can't be moved inside itself — that would orphan the whole branch from the tree.
|
||
if (parentId) {
|
||
const snap = await buildSnapshot(user);
|
||
if (locWouldLoop(snap, id, parentId)) throw new OpError("A location can't sit inside itself");
|
||
}
|
||
await prisma.location.update({ where: { id }, data: { name, kind, parentId, sort: int(p.sort, cur.sort), archived: p.archived === undefined ? cur.archived : !!p.archived } });
|
||
return { id };
|
||
}
|
||
const made = await prisma.location.create({ data: { facilityId: fid, name, kind, parentId, sort: int(p.sort, 0) } });
|
||
return { id: made.id };
|
||
}
|
||
case "location.delete": {
|
||
admin(user);
|
||
const l = await prisma.location.findFirst({ where: { id: str(p.id), facilityId: fid } });
|
||
if (!l) throw new OpError("Unknown location", 404);
|
||
// Children and placements survive: the children move up to this location's parent and the
|
||
// garments become unplaced, so deleting a shelf never quietly loses a bay or a garment.
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.location.updateMany({ where: { parentId: l.id }, data: { parentId: l.parentId } });
|
||
await tx.location.delete({ where: { id: l.id } });
|
||
});
|
||
return { ok: true };
|
||
}
|
||
case "location.place": {
|
||
// Give variants a home shelf. Coordinators do this while they tidy, so it isn't admin-only.
|
||
const raw = Array.isArray(p.lines) ? p.lines : [{ itemId: p.itemId, si: p.si }];
|
||
const locationId = str(p.locationId) || null;
|
||
if (locationId) {
|
||
const l = await prisma.location.findFirst({ where: { id: locationId, facilityId: fid } });
|
||
if (!l) throw new OpError("Unknown location", 404);
|
||
}
|
||
// Checked before anything is written, because the sizes may have moved under the screen that
|
||
// sent this: another coordinator removing a size shifts every later size down one, so a Stock
|
||
// page opened before that still shows the old run and its last row now points past the end.
|
||
// Writing that placement creates a stock level for a size that doesn't exist — variantList()
|
||
// only walks it.sizes, so it is invisible on every screen, the shelf label she meant to set is
|
||
// never set, and the garment reads as unplaced on the next stocktake of that shelf.
|
||
const places: { itemId: string; si: number }[] = [];
|
||
for (const r of raw) {
|
||
const itemId = str(r.itemId), si = int(r.si, -1);
|
||
if (!itemId || si < 0) continue;
|
||
const it = await ownItem(prisma, fid, itemId);
|
||
if (si >= it.sizes.length) throw new OpError(`That size isn't on the ${it.item} any more — reload the page and place it again.`);
|
||
places.push({ itemId, si });
|
||
}
|
||
let n = 0;
|
||
for (const { itemId, si } of places) {
|
||
await upsertLevel(prisma, fid, itemId, si, {});
|
||
await prisma.stockLevel.update({ where: { itemId_sizeIndex: { itemId, sizeIndex: si } }, data: { locationId } });
|
||
n++;
|
||
}
|
||
if (!n) throw new OpError("Nothing to place");
|
||
return { placed: n };
|
||
}
|
||
|
||
// ---------- size exchange: one movement, not a return followed by an issue
|
||
case "issue.exchange": {
|
||
const old = await prisma.issue.findFirst({ where: { id: str(p.id), facilityId: fid } });
|
||
if (!old) throw new OpError("Unknown issue", 404);
|
||
if (old.returnedDate) throw new OpError("That garment has already been returned");
|
||
if (old.handedIn) throw new OpError("That garment was handed in on " + old.handedIn);
|
||
const si = int(p.si, -1);
|
||
if (si === old.sizeIndex) throw new OpError("Pick a different size");
|
||
const snap = await buildSnapshot(user);
|
||
const it = snap.catalog.find((x) => x.id === old.itemId);
|
||
if (!it) throw new OpError("Unknown garment", 404);
|
||
if (si < 0 || si >= it.sizes.length) throw new OpError("Unknown size");
|
||
const qty = Math.min(old.qty, Math.max(1, int(p.qty, old.qty)));
|
||
const k = key(it.id, si);
|
||
if (!old.preloved && qty > onhand(snap, ledger(snap), k)) throw new OpError(`Not enough size ${it.sizes[si]} on the shelf`);
|
||
if (old.preloved && qty > plOf(snap, k)) throw new OpError(`Not enough pre-loved size ${it.sizes[si]} in the pool`);
|
||
const staff = await ownStaff(prisma, fid, old.staffId);
|
||
await lockedTx(fid, async (tx) => {
|
||
const fresh = await buildSnapshot(user, tx);
|
||
if (!old.preloved && qty > onhand(fresh, ledger(fresh), k)) throw new OpError("Someone just took that size — refresh and try again");
|
||
if (old.preloved && qty > plOf(fresh, k)) throw new OpError("The pre-loved pool just changed — refresh and try again");
|
||
if (qty < old.qty) {
|
||
// Only part of the line is swapped: split it so the rest stays out with the staff member.
|
||
await tx.issue.update({ where: { id: old.id }, data: { qty: old.qty - qty } });
|
||
await tx.issue.create({ data: { facilityId: fid, date: old.date, staffId: old.staffId, itemId: old.itemId, sizeIndex: old.sizeIndex, qty, cond: old.cond, cost: old.cost, orderCode: old.orderCode, preloved: old.preloved, override: old.override, offGroup: old.offGroup, offStyle: old.offStyle, direct: old.direct, returnedDate: today, returnedCond: "Returned - Good" } });
|
||
} else {
|
||
await tx.issue.update({ where: { id: old.id }, data: { returnedDate: today, returnedCond: "Returned - Good" } });
|
||
}
|
||
if (old.preloved) await poolAdd(tx, fid, old.itemId, old.sizeIndex, qty);
|
||
// The other size of the same garment, so whatever took it outside their staff group or their
|
||
// cut still stands: the new row carries the old one's offGroup and offStyle, as the split
|
||
// above does.
|
||
await tx.issue.create({ data: { facilityId: fid, date: today, staffId: old.staffId, itemId: old.itemId, sizeIndex: si, qty, cond: old.preloved ? "Pre-loved" : "New", cost: old.preloved ? 0 : it.cost, preloved: old.preloved, offGroup: old.offGroup, offStyle: old.offStyle } });
|
||
if (old.preloved) await poolAdd(tx, fid, old.itemId, si, -qty);
|
||
// The wrong size is on the staff record too, or they'll be handed it again next time.
|
||
if (p.updateSizes !== false) {
|
||
const size = String(it.sizes[si]);
|
||
if (isTopItem(it) && staff.top !== size) await tx.staff.update({ where: { id: staff.id }, data: { top: size } });
|
||
if (isPantItem(it) && staff.pants !== size) await tx.staff.update({ where: { id: staff.id }, data: { pants: size } });
|
||
}
|
||
});
|
||
return { size: String(it.sizes[si]), qty };
|
||
}
|
||
|
||
// ---------- users
|
||
case "users.add": {
|
||
admin(user);
|
||
const email = str(p.email, 160).trim().toLowerCase();
|
||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new OpError("Valid email required");
|
||
if (str(p.password).length < 8) throw new OpError("Password must be at least 8 characters");
|
||
if (await prisma.user.findUnique({ where: { email } })) throw new OpError("That email already has an account");
|
||
const u = await prisma.user.create({ data: { facilityId: fid, email, passwordHash: await bcrypt.hash(str(p.password, 200), 12), first: str(p.first, 80).trim() || "New", last: str(p.last, 80).trim() || "User", title: str(p.title, 80), role: p.role === "ADMIN" ? "ADMIN" : "ISSUER" } });
|
||
return { id: u.id };
|
||
}
|
||
case "users.update": {
|
||
admin(user);
|
||
const u = await prisma.user.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!u) throw new OpError("Unknown user", 404);
|
||
const data: Prisma.UserUpdateInput = {};
|
||
if (p.first !== undefined) data.first = str(p.first, 80).trim() || u.first;
|
||
if (p.last !== undefined) data.last = str(p.last, 80).trim() || u.last;
|
||
if (p.title !== undefined) data.title = str(p.title, 80);
|
||
if (p.role !== undefined) {
|
||
const role = p.role === "ADMIN" ? "ADMIN" : "ISSUER";
|
||
if (role === "ISSUER" && u.role === "ADMIN" && !u.inactive) { const admins = await prisma.user.count({ where: { facilityId: fid, role: "ADMIN", inactive: false } }); if (admins <= 1) throw new OpError("Keep at least one active admin"); }
|
||
data.role = role;
|
||
}
|
||
if (p.inactive === false && u.inactive) data.inactive = false; // reactivate
|
||
// The fire escape when the facility requires single sign-on: an admin who keeps a password.
|
||
if (p.ssoBreakGlass !== undefined) { if (u.role !== "ADMIN" && p.role !== "ADMIN") throw new OpError("Only an admin can be the break-glass account"); data.ssoBreakGlass = !!p.ssoBreakGlass; }
|
||
if (p.password !== undefined && p.password !== "") { if (str(p.password).length < 8) throw new OpError("Password must be at least 8 characters"); data.passwordHash = await bcrypt.hash(str(p.password, 200), 12); }
|
||
await prisma.user.update({ where: { id: u.id }, data });
|
||
return { ok: true };
|
||
}
|
||
case "users.remove": {
|
||
admin(user);
|
||
const u = await prisma.user.findFirst({ where: { id: str(p.id), facilityId: fid } }); if (!u) throw new OpError("Unknown user", 404);
|
||
if (u.id === user.id) throw new OpError("You can't remove yourself");
|
||
if (u.role === "ADMIN" && !u.inactive) { const admins = await prisma.user.count({ where: { facilityId: fid, role: "ADMIN", inactive: false } }); if (admins <= 1) throw new OpError("Keep at least one active admin"); }
|
||
// Soft removal: history is stamped with the user's name, and the account can be reactivated.
|
||
await prisma.user.update({ where: { id: u.id }, data: { inactive: true } });
|
||
return { ok: true };
|
||
}
|
||
// ---------- plan (Settings › Plan). Both stay open in a read-only room: they are how it stops being one.
|
||
case "plan.billing": {
|
||
admin(user);
|
||
const email = str(p.email, 200).trim().toLowerCase();
|
||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new OpError("That doesn't look like an email address");
|
||
await prisma.facility.update({ where: { id: fid }, data: { billingEmail: email } });
|
||
return { ok: true };
|
||
}
|
||
case "plan.invoice": {
|
||
/* "Request an invoice": a notice to the owner with the facility's name and plan, never the
|
||
* coordinator's. The invoice itself is raised in the accounting tool and recorded on the
|
||
* console with `paid`. Three a day: the button is easy to press twice. */
|
||
admin(user);
|
||
if (!allow("invoice:" + fid, 3, 24 * 60 * 60 * 1000)) throw new OpError("An invoice has already been requested today — it is on its way.", 429);
|
||
const email = str(p.email, 200).trim().toLowerCase();
|
||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new OpError("That doesn't look like an email address");
|
||
const to = email || fac.billingEmail;
|
||
if (!to) throw new OpError("Add a billing contact first — that is where the invoice goes.");
|
||
if (email && email !== fac.billingEmail) await prisma.facility.update({ where: { id: fid }, data: { billingEmail: email } });
|
||
const ent = entitlements(fac);
|
||
const want = str(p.plan, 40) === "health_service" ? "Health Service" : "Hosted Facility";
|
||
notifyOwners(`[ops] Invoice requested — ${fac.name}`, [
|
||
`A facility asked for an invoice from Settings › Plan.`,
|
||
"",
|
||
`Facility: ${fac.name}`,
|
||
`Plan now: ${ent.label} · ${ent.state}`,
|
||
`Wants: ${want}, annual`,
|
||
`Console: https://ops.threadcount.tech/ops/facilities/${fid}`,
|
||
"",
|
||
"The billing contact is on the facility's record (reveal it there). Raise the invoice, then record the payment on the console.",
|
||
].join("\n"));
|
||
return { ok: true };
|
||
}
|
||
case "me.profile": {
|
||
const data: Prisma.UserUpdateInput = {};
|
||
if (p.first !== undefined) { const v = str(p.first, 80).trim(); if (!v) throw new OpError("First name is required"); data.first = v; }
|
||
if (p.last !== undefined) { const v = str(p.last, 80).trim(); if (!v) throw new OpError("Last name is required"); data.last = v; }
|
||
if (p.title !== undefined) data.title = str(p.title, 80).trim();
|
||
await prisma.user.update({ where: { id: user.id }, data });
|
||
return { ok: true };
|
||
}
|
||
case "me.password": {
|
||
const u = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
|
||
if (!(await bcrypt.compare(str(p.current, 200), u.passwordHash))) throw new OpError("Current password doesn't match");
|
||
if (str(p.next).length < 8) throw new OpError("New password must be at least 8 characters");
|
||
const passwordHash = await bcrypt.hash(str(p.next, 200), 12);
|
||
await prisma.user.update({ where: { id: u.id }, data: { passwordHash } });
|
||
// Re-issued in the same breath, as the staff app does. A session token carries a fingerprint
|
||
// of the password hash, so a new password ends every session signed against the old one —
|
||
// which is the point, except that it also ends the one belonging to the person who just
|
||
// changed it. They were thrown out to /auth before the confirmation was readable, read it as
|
||
// a failure, and retyped the OLD password at the sign-in screen — each attempt counting
|
||
// against the login lockout. This is the one session that should survive.
|
||
await setSessionCookie(u.id, passwordHash);
|
||
return { ok: true };
|
||
}
|
||
|
||
case "me.deleteAccount": {
|
||
// Google Play requires a deletion path that actually completes. Removing the last active
|
||
// admin would otherwise orphan a facility — data nobody can reach and nobody can erase — so
|
||
// that case takes the whole facility with it, behind the password and the typed name.
|
||
const u = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
|
||
if (!(await bcrypt.compare(str(p.password, 200), u.passwordHash))) throw new OpError("That password doesn't match");
|
||
const isDemo = (await prisma.facility.findUniqueOrThrow({ where: { id: fid }, select: { isDemo: true } })).isDemo;
|
||
if (isDemo) throw new OpError("The demo facility is shared — it resets on its own every 20 minutes.");
|
||
const others = await prisma.user.count({ where: { facilityId: fid, inactive: false, id: { not: u.id } } });
|
||
const last = others === 0;
|
||
if (last && user.role !== "ADMIN") throw new OpError("You're the last person who can sign in, so deleting you would delete the facility — that needs an admin account.");
|
||
if (last && str(p.confirm).trim() !== fac.name) throw new OpError(`Type the facility name exactly — ${fac.name} — to confirm`);
|
||
if (last) {
|
||
// Everything hangs off Facility with onDelete: Cascade, so one delete takes the lot — and
|
||
// then the images, which the cascade cannot reach. Photo rows carry the only record of
|
||
// which files on disk belonged to this facility, so they go in the same breath: a
|
||
// signature or a photograph of somebody's damaged uniform must not outlive the deletion
|
||
// the user was told had happened.
|
||
await prisma.facility.delete({ where: { id: fid } });
|
||
await deletePhotoDir(fid);
|
||
return { deleted: "facility", facility: fac.name };
|
||
}
|
||
// Somebody else still runs this facility: only the person goes. History keeps the name it was
|
||
// stamped with at the time, which is what an audit trail is for.
|
||
if (u.role === "ADMIN") {
|
||
const admins = await prisma.user.count({ where: { facilityId: fid, role: "ADMIN", inactive: false, id: { not: u.id } } });
|
||
if (admins === 0) throw new OpError("You're the only admin left. Make someone else an admin first, or delete the whole facility.");
|
||
}
|
||
await prisma.user.delete({ where: { id: u.id } });
|
||
return { deleted: "user" };
|
||
}
|
||
|
||
// ---------- import / data
|
||
case "import.rows": return importRows(user, str(p.kind), Array.isArray(p.rows) ? p.rows : []);
|
||
case "data.wipeActivity": {
|
||
admin(user);
|
||
if (p.confirm !== "WIPE") throw new OpError("Type WIPE to confirm");
|
||
const wiped = await prisma.$transaction(async (tx) => {
|
||
await tx.issue.deleteMany({ where: { facilityId: fid } });
|
||
await tx.pickup.deleteMany({ where: { facilityId: fid } });
|
||
await tx.order.deleteMany({ where: { facilityId: fid } });
|
||
await tx.stocktake.deleteMany({ where: { facilityId: fid } });
|
||
await tx.stockMove.deleteMany({ where: { facilityId: fid } });
|
||
await tx.approval.deleteMany({ where: { facilityId: fid } });
|
||
await tx.alteration.deleteMany({ where: { facilityId: fid } });
|
||
await tx.handIn.deleteMany({ where: { facilityId: fid } });
|
||
// The staff app's side of the same activity. Leaving it behind is what made the screens
|
||
// disagree: a wearer's collected orders still listed while the issues behind them were
|
||
// gone, an open kit check still asking about holdings that no longer exist, and a request
|
||
// queue against a facility that has just been cleared.
|
||
await tx.request.deleteMany({ where: { facilityId: fid } }); // events and messages cascade
|
||
await tx.waitlistEntry.deleteMany({ where: { facilityId: fid } });
|
||
await tx.kitCheck.deleteMany({ where: { facilityId: fid } }); // answers cascade
|
||
await tx.damageReport.deleteMany({ where: { facilityId: fid } });
|
||
await tx.recordDispute.deleteMany({ where: { facilityId: fid } });
|
||
await tx.linenNotice.deleteMany({ where: { facilityId: fid } });
|
||
const paths = await purgePhotoRows(tx, fid);
|
||
await tx.stockLevel.updateMany({ where: { facilityId: fid }, data: { adj: 0, preloved: 0 } });
|
||
// Request numbering restarts with the order numbering, so R-0001 and ORD-YYYY-0001 mean
|
||
// the same thing again after a wipe.
|
||
await tx.facility.update({ where: { id: fid }, data: { orderSeq: 0, requestSeq: 0 } });
|
||
return paths;
|
||
}, { timeout: 120000 });
|
||
await unlinkAll(wiped);
|
||
return { ok: true };
|
||
}
|
||
case "data.reset": {
|
||
// Start fresh: everything the facility has entered goes, logins and the facility's own settings
|
||
// stay. Deliberately harder to reach than wipeActivity — this also takes the catalogue and staff.
|
||
admin(user);
|
||
if (p.confirm !== "RESET") throw new OpError("Type RESET to confirm");
|
||
const reset = await prisma.$transaction(async (tx) => {
|
||
await tx.issue.deleteMany({ where: { facilityId: fid } });
|
||
await tx.pickup.deleteMany({ where: { facilityId: fid } });
|
||
await tx.order.deleteMany({ where: { facilityId: fid } });
|
||
await tx.stocktake.deleteMany({ where: { facilityId: fid } });
|
||
await tx.handIn.deleteMany({ where: { facilityId: fid } });
|
||
const paths = await purgePhotoRows(tx, fid);
|
||
await tx.stockMove.deleteMany({ where: { facilityId: fid } });
|
||
await tx.stockLevel.deleteMany({ where: { facilityId: fid } });
|
||
await tx.barcode.deleteMany({ where: { facilityId: fid } });
|
||
await tx.approval.deleteMany({ where: { facilityId: fid } });
|
||
await tx.alteration.deleteMany({ where: { facilityId: fid } });
|
||
await tx.catalogItem.deleteMany({ where: { facilityId: fid } });
|
||
await tx.department.deleteMany({ where: { facilityId: fid } });
|
||
await tx.staff.deleteMany({ where: { facilityId: fid } });
|
||
await tx.location.deleteMany({ where: { facilityId: fid } });
|
||
// Requests, the waitlist, kit-check answers, damage reports, disputes and staff accounts
|
||
// all cascade from the staff and catalogue rows above. These two hang off the facility
|
||
// alone, so a reset would otherwise leave an open kit check and a notice board addressing
|
||
// a facility that no longer has anybody to answer them.
|
||
await tx.kitCheck.deleteMany({ where: { facilityId: fid } });
|
||
await tx.linenNotice.deleteMany({ where: { facilityId: fid } });
|
||
if (p.keepSuppliers !== true) await tx.supplier.deleteMany({ where: { facilityId: fid } });
|
||
// Numbering restarts too, so the first order of the fresh facility is ORD-YYYY-0001.
|
||
await tx.facility.update({ where: { id: fid }, data: { orderSeq: 0, catalogSeq: 0, requestSeq: 0 } });
|
||
return paths;
|
||
}, { timeout: 120000 });
|
||
await unlinkAll(reset);
|
||
return { ok: true };
|
||
}
|
||
default:
|
||
throw new OpError(`Unknown op ${op}`, 404);
|
||
}
|
||
}
|
||
|
||
// CSV / row importers. Row keys are lower-case headers.
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
export async function importRows(user: SessionUser, kind: string, rows: any[]) {
|
||
admin(user);
|
||
const fid = user.facilityId;
|
||
// `styles` counts the uniform styles the staff import set, and stays 0 for every other kind. It is
|
||
// declared here beside the rest because they are all reported back from the one return below.
|
||
let created = 0, updated = 0, skipped = 0, styles = 0; const errors: string[] = [];
|
||
if (rows.length > 20000) throw new OpError("Import at most 20,000 rows at a time");
|
||
const g = (r: Record<string, unknown>, ...ks: string[]) => { for (const k of ks) { const v = r[k]; if (v !== undefined && v !== null && String(v).trim() !== "") return String(v).trim().slice(0, 400); } return ""; };
|
||
const norm = (r: Record<string, unknown>) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k.toLowerCase().replace(/[^a-z0-9]/g, ""), v]));
|
||
if (kind === "catalog") {
|
||
const existing = await prisma.catalogItem.findMany({ where: { facilityId: fid } });
|
||
for (const [i, raw] of rows.entries()) {
|
||
const r = norm(raw);
|
||
const item = g(r, "item", "name", "itemname", "garment"); if (!item) { skipped++; continue; }
|
||
const genderRaw = g(r, "gender").toLowerCase();
|
||
const gender = genderRaw.startsWith("m") ? "Male" : genderRaw.startsWith("f") || genderRaw.startsWith("w") ? "Female" : "Unisex";
|
||
const sku = g(r, "sku", "code", "productcode");
|
||
const sizes = g(r, "sizes", "size").split(/[|,;/]/).map((s) => s.trim()).filter(Boolean);
|
||
const cost = parseFloat(g(r, "cost", "unitcost", "price").replace(/[^0-9.]/g, "")) || 0;
|
||
const supplierIn = g(r, "supplier", "vendor");
|
||
// Blank means "don't change it", not "All". A supplier price list carries item, sku and cost
|
||
// and no group column at all, and re-importing one used to move every matched garment back
|
||
// to All — which silently re-scopes what the Issue screen offers each staff group.
|
||
// Several groups are separated by |; "All" is every group, stored as no list at all.
|
||
const groupIn = g(r, "group", "staffgroup");
|
||
const groups = garmentGroups(groupIn.split("|"));
|
||
const notes = g(r, "notes", "note");
|
||
const type = g(r, "type", "producttype", "garmenttype").slice(0, 40);
|
||
try {
|
||
// The directory's spelling, not the spreadsheet's: a price list that writes the supplier in
|
||
// lower case must not leave the garment pointing at a name the supplier directory doesn't hold.
|
||
const supplier = supplierIn ? await ensureSupplier(prisma, fid, supplierIn) : "";
|
||
const ex = existing.find((e) => e.item === item && e.gender === gender && (sku ? e.sku === sku : true));
|
||
if (ex) {
|
||
const merged = [...ex.sizes]; for (const s of sizes) if (!merged.includes(s)) merged.push(s);
|
||
const patch = { sku: sku || ex.sku, cost: cost || ex.cost, supplier: supplier || ex.supplier, groups: groupIn ? groups : ex.groups, notes: notes || ex.notes, type: type || ex.type, sizes: merged };
|
||
await prisma.catalogItem.update({ where: { id: ex.id }, data: patch });
|
||
// Folded back into the list this loop matches against, because one garment routinely spans
|
||
// several rows — a supplier price list has a row per SKU-size, and the templates are all
|
||
// one row per size. `existing` is read once before the loop, so without this the next row
|
||
// for the same garment merges its size into the sizes the item had BEFORE the import and
|
||
// writes that back: every size but the last is silently dropped, and a nurse who wears M
|
||
// can never be issued one although the CSV plainly listed it.
|
||
Object.assign(ex, patch);
|
||
updated++;
|
||
} else {
|
||
if (!sizes.length) { errors.push(`Row ${i + 1}: ${item} has no sizes`); skipped++; continue; }
|
||
await prisma.$transaction(async (tx) => { const sort = await nextSort(tx, fid); const c = await tx.catalogItem.create({ data: { facilityId: fid, sort, item, gender, type, sku, supplier, cost, groups, notes, sizes } }); existing.push(c); });
|
||
created++;
|
||
}
|
||
} catch (e) { errors.push(`Row ${i + 1}: ${(e as Error).message}`); }
|
||
}
|
||
} else if (kind === "staff") {
|
||
// Counted and reported back, because `gender` is among the column names this reads: a coordinator
|
||
// re-importing a roster export sets the cut for the whole register in one go, and a change that
|
||
// size should say how many records it touched rather than happen quietly. The counter itself is
|
||
// declared with the others at the top, because the return that reports it is shared by every kind.
|
||
const depts = await prisma.department.findMany({ where: { facilityId: fid } });
|
||
// The plan's ceiling, read once: rows past it are skipped and the refusal reported once, so a
|
||
// roster of eighty into a free room still updates the sixty already there.
|
||
const { room: staffRoomLeft, max: staffMax } = await staffRoom(fid);
|
||
let ceilingNoted = false;
|
||
for (const [i, raw] of rows.entries()) {
|
||
const r = norm(raw);
|
||
const numv = g(r, "num", "staffnumber", "staffno", "number", "payroll", "payrollnumber", "id");
|
||
let first = g(r, "first", "firstname", "given"); let last = g(r, "last", "lastname", "surname", "family");
|
||
const name = g(r, "name", "fullname"); if (!first && !last && name) { const parts = name.split(/\s+/); first = parts.shift() || ""; last = parts.join(" "); }
|
||
if (!numv || !first) { skipped++; continue; }
|
||
const dept = g(r, "dept", "department", "ward", "wardunit"); const cc = g(r, "cc", "costcentre", "costcenter", "departmentcostcentre");
|
||
if (dept && cc && !depts.find((d) => d.name === dept)) { const d = await prisma.department.create({ data: { facilityId: fid, name: dept, cc, sort: depts.length } }); depts.push(d); }
|
||
const entRaw = g(r, "ent", "entitlement", "annualentitlement");
|
||
const entN = parseInt(entRaw, 10);
|
||
// A start date typed in a spreadsheet arrives as whatever the machine's locale writes.
|
||
// Day-first is converted; anything else is reported and left blank rather than stored as
|
||
// text the register would then render as "Invalid Date" for the rest of the person's career.
|
||
const startRaw = g(r, "start", "startdate", "commenced");
|
||
const start = isoDate(startRaw);
|
||
if (startRaw && !start) errors.push(`Row ${i + 1}: start date “${startRaw}” isn't a date — write it as YYYY-MM-DD. Left blank.`);
|
||
// Combined FTE, off whatever the roster export calls the column. Whatever fraction the export
|
||
// carries is kept — a roster is where these figures come from, and the table reads one it has
|
||
// no row for as the band it falls in. A cell that is no fraction at all is reported and left
|
||
// blank, the way a bad start date is: it proposes no kit, and a whole register import is not
|
||
// worth failing over one odd cell — but nor should it pass silently, because the kit an FTE
|
||
// works out to is the number a manager will be asked to sign for.
|
||
const fteRaw = g(r, "fte", "combinedfte", "totalcombinedfte", "totalfte", "employmentfraction", "fraction");
|
||
const fteIn = normalFte(fteRaw);
|
||
if (fteRaw && fteIn === null) errors.push(`Row ${i + 1}: FTE “${fteRaw}” isn't a fraction — write ${FTE_ALLOWED}. Left blank.`);
|
||
// Which cut of uniform this person is offered, off whatever the file calls the column —
|
||
// `gender` among the names, because a register exported from a payroll or a roster system is
|
||
// the file this gets loaded from and that is the header it will have. The words are read
|
||
// through normalUniformStyle(), so "M", "Male" and "Men's" all land on the same value.
|
||
//
|
||
// A cell the rule can't read is reported and left blank, the way a bad FTE is: blank is what
|
||
// every record is today and it offers every style, so nothing is refused at the counter over
|
||
// it — but nor does it pass in silence, because a coordinator who meant to set the whole
|
||
// register would otherwise never learn that a column of "M/F" spelt some other way did nothing.
|
||
const styleRaw = g(r, "style", "uniformstyle", "uniform", "cut", "gender");
|
||
const styleIn = normalUniformStyle(styleRaw);
|
||
if (styleRaw && styleIn === null) errors.push(`Row ${i + 1}: uniform style “${styleRaw}” isn't ${UNIFORM_STYLES.join(", ")} or blank. Left blank.`);
|
||
const data = { first, last: last || "—", phone: g(r, "phone", "mobile", "contact"), group: g(r, "group", "staffgroup", "classification"), dept, top: g(r, "top", "topsize", "shirt", "shirtsize"), pants: g(r, "pants", "pantsize", "pant", "trouser"), ccOverride: g(r, "ccoverride", "costcentreoverride"), ent: Number.isFinite(entN) ? entN : null, fte: fteIn ?? "", uniformStyle: styleIn ?? "", start, notes: g(r, "notes", "note") };
|
||
try {
|
||
const ex = await prisma.staff.findFirst({ where: { facilityId: fid, num: numv } });
|
||
if (ex) {
|
||
// Re-import only overwrites columns the row actually provides — blank cells keep the existing value.
|
||
const patch: Record<string, unknown> = {};
|
||
for (const [k, v] of Object.entries(data)) if (v !== "" && v !== null && v !== "—") patch[k] = v;
|
||
await prisma.staff.update({ where: { id: ex.id }, data: patch }); updated++;
|
||
if (patch.uniformStyle) styles++;
|
||
} else {
|
||
if (staffRoomLeft !== null && created >= staffRoomLeft) {
|
||
if (!ceilingNoted) { errors.push(staffRefusal(staffMax!)); ceilingNoted = true; }
|
||
skipped++; continue;
|
||
}
|
||
await prisma.staff.create({ data: { facilityId: fid, num: numv, ...data } }); created++; if (styleIn) styles++;
|
||
}
|
||
} catch (e) { errors.push(`Row ${i + 1}: ${(e as Error).message}`); }
|
||
}
|
||
// Managers are linked in a second pass, on purpose: a manager can appear further down the same
|
||
// file than the people reporting to them, so nothing can be linked until every row exists.
|
||
// The column holds a staff number, not a name — names repeat on a big register, and a wrong
|
||
// link here would send somebody's approvals to a stranger.
|
||
const byNum = new Map((await prisma.staff.findMany({ where: { facilityId: fid }, select: { id: true, num: true } })).map((x) => [x.num, x.id] as const));
|
||
for (const [i, raw] of rows.entries()) {
|
||
const r = norm(raw);
|
||
const numv = g(r, "num", "staffnumber", "staffno", "number", "payroll", "payrollnumber", "id");
|
||
const mgr = g(r, "manager", "managernum", "managernumber", "approver", "reportsto");
|
||
if (!numv || !mgr) continue;
|
||
const meId = byNum.get(numv), mgrId = byNum.get(mgr);
|
||
if (!meId) continue;
|
||
if (!mgrId) { errors.push(`Row ${i + 1}: no staff member with number ${mgr} to be the manager`); continue; }
|
||
// A row naming its own staff number as the manager is kept as written: anybody may be their
|
||
// own manager, and staff.patch sets it the same way.
|
||
try { await prisma.staff.update({ where: { id: meId }, data: { managerId: mgrId } }); }
|
||
catch (e) { errors.push(`Row ${i + 1}: ${(e as Error).message}`); }
|
||
}
|
||
} else if (kind === "depts") {
|
||
for (const [i, raw] of rows.entries()) {
|
||
const r = norm(raw);
|
||
const name = g(r, "dept", "department", "name", "ward"); const cc = g(r, "cc", "costcentre", "costcenter", "code");
|
||
if (!name) { skipped++; continue; }
|
||
// A blank cost-centre cell means "not in this file", not "clear it" — the same rule the staff
|
||
// importer follows. A ward that had no cost centre when the list was first imported and had
|
||
// one typed in on Settings afterwards was losing it again the next time the unedited file was
|
||
// re-imported to add a couple of wards, and every issue on that ward then reported with an
|
||
// empty cost centre and landed in the monthly journal under UNALLOCATED.
|
||
try { const ex = await prisma.department.findFirst({ where: { facilityId: fid, name } }); if (ex) { await prisma.department.update({ where: { id: ex.id }, data: { cc: cc || ex.cc, sort: i } }); updated++; } else { await prisma.department.create({ data: { facilityId: fid, name, cc, sort: i } }); created++; } }
|
||
catch (e) { errors.push(`Row ${i + 1}: ${(e as Error).message}`); }
|
||
}
|
||
} else if (kind === "barcodes" || kind === "opening" || kind === "reorder") {
|
||
const items = await prisma.catalogItem.findMany({ where: { facilityId: fid } });
|
||
for (const [i, raw] of rows.entries()) {
|
||
const r = norm(raw);
|
||
const sku = g(r, "sku", "code", "productcode"), item = g(r, "item", "name", "itemname", "garment"), size = g(r, "size"), genderRaw = g(r, "gender").toLowerCase();
|
||
const gender = genderRaw ? (genderRaw.startsWith("m") ? "Male" : genderRaw.startsWith("f") || genderRaw.startsWith("w") ? "Female" : "Unisex") : "";
|
||
const cands = items.filter((it) => (sku ? it.sku === sku : true) && (item ? it.item === item : true) && (gender ? it.gender === gender : true));
|
||
if (cands.length !== 1) { errors.push(`Row ${i + 1}: ${cands.length === 0 ? "no" : "several"} catalogue items match sku=${sku || "?"} item=${item || "?"} gender=${gender || "?"}`); skipped++; continue; }
|
||
const it = cands[0]; const si = it.sizes.map(String).indexOf(size);
|
||
if (si < 0) { errors.push(`Row ${i + 1}: size ${size} not on ${it.item}`); skipped++; continue; }
|
||
try {
|
||
if (kind === "barcodes") {
|
||
const code = g(r, "barcode", "ean", "code128", "scan"); if (!code) { skipped++; continue; }
|
||
// A supplier price list often carries one code per STYLE rather than per size, so the
|
||
// same number arrives on the S, M and L rows. The upsert is keyed on the code alone, so
|
||
// it just walked the single binding down the rows: S and M ended up unlabelled while L
|
||
// looked fine, and every garment on the rack then scanned as an L — tallying against L in
|
||
// a stocktake and decrementing L when a size S was handed over. Refused here, naming
|
||
// where the code already sits, so the coordinator can see the list is per style.
|
||
await assertBindable(prisma, fid, code, it.id, si, false);
|
||
await prisma.barcode.upsert({ where: { facilityId_code: { facilityId: fid, code } }, create: { facilityId: fid, code, itemId: it.id, sizeIndex: si, source: "supplier" }, update: { itemId: it.id, sizeIndex: si } });
|
||
}
|
||
else if (kind === "opening") {
|
||
// A blank quantity is "I haven't counted this shelf", not "there are none". These sheets
|
||
// are usually every size of every garment with only this week's recount filled in, and
|
||
// writing 0 for the rest wiped opening balances set months earlier through the UI or a
|
||
// previous import — the shelf reads empty with garments on it, the row is counted as a
|
||
// success, and the reorder engine starts ordering stock the room already has.
|
||
const qRaw = g(r, "opening", "qty", "quantity", "onhand", "count");
|
||
const ro = g(r, "reorder", "reorderat", "reorderlevel");
|
||
const q = parseInt(qRaw, 10);
|
||
if (qRaw && !Number.isFinite(q)) { errors.push(`Row ${i + 1}: opening “${qRaw}” isn't a number — that size was left as it was`); skipped++; continue; }
|
||
if (!qRaw && !ro) { skipped++; continue; }
|
||
if (qRaw) await upsertLevel(prisma, fid, it.id, si, { opening: Math.max(0, q) });
|
||
if (ro) await upsertLevel(prisma, fid, it.id, si, { reorder: Math.max(0, parseInt(ro, 10) || 0) });
|
||
}
|
||
else { const ro = parseInt(g(r, "reorder", "reorderat", "reorderlevel", "level"), 10); if (!Number.isFinite(ro)) { skipped++; continue; } await upsertLevel(prisma, fid, it.id, si, { reorder: Math.max(0, ro) }); }
|
||
created++;
|
||
} catch (e) { errors.push(`Row ${i + 1}: ${(e as Error).message}`); }
|
||
}
|
||
} else throw new OpError("Unknown import kind");
|
||
return { created, updated, skipped, styles, errors: errors.slice(0, 40) };
|
||
}
|
||
|
||
export async function exportBackup(user: SessionUser) {
|
||
const fid = user.facilityId;
|
||
const [fac, items, barcodes, stock, moves, depts, suppliers, staff, approvals, alterations, issues, orders, pickups, stocktakes, handins, photos, locations, costs, requests, waitlist, kitChecks, damage, disputes, notices] = await Promise.all([
|
||
prisma.facility.findUniqueOrThrow({ where: { id: fid } }),
|
||
prisma.catalogItem.findMany({ where: { facilityId: fid }, orderBy: { sort: "asc" } }),
|
||
prisma.barcode.findMany({ where: { facilityId: fid } }),
|
||
prisma.stockLevel.findMany({ where: { facilityId: fid } }),
|
||
prisma.stockMove.findMany({ where: { facilityId: fid } }),
|
||
prisma.department.findMany({ where: { facilityId: fid } }),
|
||
// In the directory's own order: the first supplier is the facility's default, and the file has
|
||
// to carry that rather than whatever order the rows happen to come back in.
|
||
prisma.supplier.findMany({ where: { facilityId: fid }, orderBy: [{ sort: "asc" }, { name: "asc" }] }),
|
||
prisma.staff.findMany({ where: { facilityId: fid } }),
|
||
prisma.approval.findMany({ where: { facilityId: fid } }),
|
||
prisma.alteration.findMany({ where: { facilityId: fid } }),
|
||
prisma.issue.findMany({ where: { facilityId: fid } }),
|
||
prisma.order.findMany({ where: { facilityId: fid }, include: { lines: true, receipts: { include: { lines: true } } } }),
|
||
prisma.pickup.findMany({ where: { facilityId: fid }, include: { lines: true } }),
|
||
prisma.stocktake.findMany({ where: { facilityId: fid }, include: { lines: true } }),
|
||
prisma.handIn.findMany({ where: { facilityId: fid }, include: { lines: true } }),
|
||
prisma.photo.findMany({ where: { facilityId: fid } }),
|
||
prisma.location.findMany({ where: { facilityId: fid }, orderBy: { sort: "asc" } }),
|
||
prisma.costChange.findMany({ where: { facilityId: fid }, orderBy: { at: "asc" } }),
|
||
// The staff app's half of the facility. It used to be missing entirely, which made "one JSON
|
||
// file with everything in it" untrue in the worst possible way: restoring wiped every request,
|
||
// every message thread and every manager link, and none of it was in the file to come back.
|
||
prisma.request.findMany({ where: { facilityId: fid }, orderBy: { createdAt: "asc" }, include: { lines: { orderBy: { sort: "asc" } }, events: { orderBy: { at: "asc" } }, messages: { orderBy: { createdAt: "asc" } } } }),
|
||
prisma.waitlistEntry.findMany({ where: { facilityId: fid } }),
|
||
prisma.kitCheck.findMany({ where: { facilityId: fid }, include: { answers: true } }),
|
||
prisma.damageReport.findMany({ where: { facilityId: fid } }),
|
||
prisma.recordDispute.findMany({ where: { facilityId: fid } }),
|
||
prisma.linenNotice.findMany({ where: { facilityId: fid } }),
|
||
]);
|
||
/* Backups stay self-contained: images are read back off disk and embedded, so the format is the
|
||
* same as before the move and "take the data and go" still means everything.
|
||
*
|
||
* Everything except the images beyond what a restore will take back. A room that signs for a
|
||
* delivery round every day passes the 2000-photo cap inside a year or two and the import POST's
|
||
* size limit well before that, and neither end said a word: the export succeeded, Settings
|
||
* showed "Last backup: today", and the file turned out to be unrestorable months later when it
|
||
* was the only copy left. Records are the point of a backup and images are attachments to them,
|
||
* so the newest images travel, the rest are counted in `photosOmitted`, and the file restores. */
|
||
const newestFirst = [...photos].sort((a, c) => c.createdAt.getTime() - a.createdAt.getTime());
|
||
const photosOut: (typeof photos[number] & { data: string })[] = [];
|
||
let budget = BACKUP_PHOTO_BYTES, photosOmitted = 0;
|
||
for (const ph of newestFirst) {
|
||
const data = ph.path ? (await photoAsDataUrl(ph.path, ph.mime)) ?? "" : ph.data;
|
||
if (!data || photosOut.length >= BACKUP_PHOTO_MAX || data.length > budget) { photosOmitted++; continue; }
|
||
budget -= data.length;
|
||
photosOut.push({ ...ph, data });
|
||
}
|
||
await prisma.facility.update({ where: { id: fid }, data: { lastBackup: facilityToday(fac.timezone) } });
|
||
// Staff accounts are deliberately absent: the row is an email and a password hash, and a backup
|
||
// is a file that gets emailed to people. They survive a restore in place instead — see
|
||
// restoreBackup, which re-attaches them by staff number.
|
||
// Each garment carries its `groups` list and, beside it, `group` written the way the catalogue CSV
|
||
// writes it — the names joined by |, or All — so a file read by hand or by an older build still
|
||
// says who the garment is for. Restore reads the list whenever it is there.
|
||
// The plan label and note are the operations console's notes about the facility, not the
|
||
// facility's own record: they stay out of the file, and restore never reads them either.
|
||
const { plan: _plan, planNote: _planNote, ...facOut } = fac;
|
||
void _plan; void _planNote;
|
||
return { format: "threadcount-backup-v2", exportedAt: new Date().toISOString(), photosOmitted, facility: facOut, items: items.map((it) => ({ ...it, group: it.groups.length ? it.groups.join("|") : "All" })), barcodes, stock, moves, depts, suppliers, staff, approvals, alterations, issues, orders, pickups, stocktakes, handins, photos: photosOut, locations, costs, requests, waitlist, kitChecks, damage, disputes, notices };
|
||
}
|
||
|
||
/** How the starting kit was decided before a facility could list its own kit groups: any group with
|
||
* these letters in its name. Read only off a backup file written under that rule, so that it
|
||
* restores exactly as it ran. Nothing live asks it. */
|
||
const KIT_BEFORE_THE_LIST = /operational/i;
|
||
/* The FTE-table groups a facility was assumed to have before it could name its own — what an empty
|
||
* list stood for in a backup written before the starting-kit list existed. Only restore reads it, to
|
||
* read such a file the way the facility that wrote it was running; nothing offers these names to
|
||
* anybody, and a facility created now starts with an empty list that stays empty until it says. */
|
||
const NURSING_BEFORE_THE_LIST = ["Registered Nurse", "Enrolled Nurse", "Assistant in Nursing", "USINS"];
|
||
|
||
// Full restore into the current facility: wipes facility data (not users) and re-creates with fresh ids.
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
export async function restoreBackup(user: SessionUser, b: any) {
|
||
const cap = (arr: unknown, n: number, what: string) => { if (Array.isArray(arr) && arr.length > n) throw new OpError(`Backup has too many ${what} (${arr.length} > ${n})`); };
|
||
// Photos are trimmed rather than refused, unlike every other cap here. The rest of the file is
|
||
// the facility's records; images are attachments to them, and turning away a year of issues,
|
||
// orders and requests because an older file carries more signatures than we will take back
|
||
// leaves the room with nothing at all. The extras are dropped and counted, the records come back.
|
||
const photoRows: { id?: unknown; kind?: unknown; data?: unknown; createdAt?: unknown }[] = Array.isArray(b?.photos) ? b.photos.slice(0, BACKUP_PHOTO_MAX) : [];
|
||
const photosSkipped = Array.isArray(b?.photos) ? b.photos.length - photoRows.length : 0;
|
||
cap(b?.items, 5000, "items"); cap(b?.staff, 20000, "staff"); cap(b?.issues, 300000, "issues"); cap(b?.orders, 50000, "orders"); cap(b?.stocktakes, 5000, "stocktakes"); cap(b?.pickups, 50000, "pickups"); cap(b?.handins, 50000, "hand-ins"); cap(b?.moves, 100000, "moves"); cap(b?.barcodes, 50000, "barcodes"); cap(b?.stock, 100000, "stock lines"); cap(b?.locations, 5000, "locations"); cap(b?.costs, 100000, "cost changes");
|
||
cap(b?.requests, 200000, "requests"); cap(b?.waitlist, 50000, "waitlist entries"); cap(b?.kitChecks, 5000, "kit checks"); cap(b?.damage, 50000, "damage reports"); cap(b?.disputes, 50000, "queries"); cap(b?.notices, 1000, "notices");
|
||
admin(user);
|
||
if (!b || !["threadcount-backup-v1", "threadcount-backup-v2"].includes(b.format) || !Array.isArray(b.items)) throw new OpError("Not a ThreadCount backup file");
|
||
const fid = user.facilityId;
|
||
// Reached from the route directly, not through runOp, so the plan is asked here. A restore is the
|
||
// one write that could carry a whole register past the free tier's ceiling in one go.
|
||
const planRow = await prisma.facility.findUniqueOrThrow({ where: { id: fid }, select: PLAN_COLS });
|
||
const ent = entitlements(planRow);
|
||
if (ent.readOnly) throw new OpError(READ_ONLY_REFUSAL, 403);
|
||
if (ent.maxStaff !== null && Array.isArray(b.staff) && b.staff.length > ent.maxStaff) throw new OpError(`This backup holds ${b.staff.length} staff records and the plan allows ${ent.maxStaff}. Settings › Plan has the next step.`, 403);
|
||
// Every image this restore lays down, so a restore that doesn't commit can take them back off
|
||
// disk again — see the catch below.
|
||
const written: string[] = [];
|
||
const orphanedFiles = await prisma.$transaction(async (tx) => {
|
||
// Read before anything is deleted. Staff accounts are the one part of the staff app that is
|
||
// not in the file and must never be — the row is an email and a password hash — so they are
|
||
// carried across the restore in place and re-attached by staff number, the only identifier
|
||
// that survives it. Without this a restore silently ends every wearer's login.
|
||
const keptAccounts = (await tx.staffAccount.findMany({ where: { facilityId: fid }, include: { staff: { select: { num: true } } } }))
|
||
.map((a) => ({ num: a.staff.num, email: a.email, passwordHash: a.passwordHash, createdAt: a.createdAt, lastSeenAt: a.lastSeenAt }));
|
||
const ourUsers = new Set((await tx.user.findMany({ where: { facilityId: fid }, select: { id: true } })).map((u) => u.id));
|
||
await tx.issue.deleteMany({ where: { facilityId: fid } });
|
||
await tx.pickup.deleteMany({ where: { facilityId: fid } });
|
||
await tx.order.deleteMany({ where: { facilityId: fid } });
|
||
await tx.stocktake.deleteMany({ where: { facilityId: fid } });
|
||
await tx.handIn.deleteMany({ where: { facilityId: fid } });
|
||
const orphans = await purgePhotoRows(tx, fid);
|
||
await tx.stockMove.deleteMany({ where: { facilityId: fid } });
|
||
await tx.stockLevel.deleteMany({ where: { facilityId: fid } });
|
||
await tx.barcode.deleteMany({ where: { facilityId: fid } });
|
||
await tx.approval.deleteMany({ where: { facilityId: fid } });
|
||
await tx.alteration.deleteMany({ where: { facilityId: fid } });
|
||
// The staff app's tables go explicitly rather than by cascade, so that what a restore removes
|
||
// is written down here where anyone reading it can see the whole list.
|
||
await tx.request.deleteMany({ where: { facilityId: fid } }); // events and messages cascade
|
||
await tx.waitlistEntry.deleteMany({ where: { facilityId: fid } });
|
||
await tx.kitCheck.deleteMany({ where: { facilityId: fid } }); // answers cascade
|
||
await tx.damageReport.deleteMany({ where: { facilityId: fid } });
|
||
await tx.recordDispute.deleteMany({ where: { facilityId: fid } });
|
||
await tx.linenNotice.deleteMany({ where: { facilityId: fid } });
|
||
await tx.catalogItem.deleteMany({ where: { facilityId: fid } });
|
||
await tx.department.deleteMany({ where: { facilityId: fid } });
|
||
await tx.supplier.deleteMany({ where: { facilityId: fid } });
|
||
await tx.staff.deleteMany({ where: { facilityId: fid } });
|
||
await tx.location.deleteMany({ where: { facilityId: fid } });
|
||
const f = b.facility || {};
|
||
/* The in-house barcode counter travels with the file. Left at zero in a fresh facility, the
|
||
* first press of "Generate a barcode" mints 2900000000018 — a code the restore has just put
|
||
* back — then walks through 49 more that are all taken and gives up; and because the throw
|
||
* aborts the transaction, the 50 increments roll back too, so the next press does exactly the
|
||
* same thing. Generating a label becomes permanently impossible and unlabelled stock can never
|
||
* be scanned into a count. Floored at the highest 29… code actually restored as well as the
|
||
* stored counter, because a file written before this column existed carries no counter at all. */
|
||
const restoredSeq: number = (Array.isArray(b.barcodes) ? b.barcodes : []).reduce((mx: number, bc: { code?: unknown }) => {
|
||
const c = str(bc?.code, 64);
|
||
return isInHouse(c) ? Math.max(mx, int(c.slice(2, 12))) : mx;
|
||
}, 0);
|
||
/* Which route each group is on. The export writes the whole facility row, so a file from now on
|
||
* carries both lists and they come back as written.
|
||
*
|
||
* The FTE-table list is left as it is when the file hasn't got one, rather than emptied like
|
||
* staffGroups. Such a file was written before the list existed, when a different test decided
|
||
* who was on the table, so the facility's own list as it stands is a better answer than the
|
||
* file's silence — and an empty list now means no group on the table at all, which would take
|
||
* every first kit the table proposes away with it.
|
||
*
|
||
* The starting-kit list, when the file hasn't got one, is read the way that file's facility was
|
||
* running when it was written. Until the list existed the kit went to any group with
|
||
* "operational" in its name, so exactly those groups — in the file's settings and on its staff
|
||
* records — go on it, which is what the migration that added the list did to every facility
|
||
* already running. Left empty instead, a restore would put every starting-kit wearer on manager
|
||
* approval, at the one moment nobody is checking who is on what.
|
||
*
|
||
* A group on both lists — only possible in a file edited by hand — comes off the kit list. The
|
||
* FTE table wins wherever the two meet (allowanceRoute in lib/sets), and a stored overlap would
|
||
* have the next settings save refused over a group nobody touched. */
|
||
// A file written before the starting-kit list existed has no kitGroups key, and in those files an
|
||
// empty or missing FTE list meant the nursing groups the product used to assume. It is read that
|
||
// way here, as the migration read every facility already running: read literally, restoring an old
|
||
// file — onto a fresh signup especially — would put every nurse on manager approval. A file written
|
||
// since means exactly what it says.
|
||
const beforeTheList = !Array.isArray(f.kitGroups);
|
||
const fileNursing: string[] | null = Array.isArray(f.nursingGroups) ? f.nursingGroups.map((s: unknown) => str(s, 80)) : null;
|
||
const nursingRestored: string[] = beforeTheList && !(fileNursing && fileNursing.length)
|
||
? [...NURSING_BEFORE_THE_LIST]
|
||
: fileNursing ?? (await tx.facility.findUniqueOrThrow({ where: { id: fid }, select: { nursingGroups: true } })).nursingGroups;
|
||
const kitFrom: unknown[] = Array.isArray(f.kitGroups)
|
||
? f.kitGroups
|
||
: [...(Array.isArray(f.staffGroups) ? f.staffGroups : []), ...(Array.isArray(b.staff) ? b.staff.map((s: { group?: unknown }) => s?.group) : [])]
|
||
.filter((g) => KIT_BEFORE_THE_LIST.test(str(g, 80)));
|
||
const kitRestored: string[] = [];
|
||
for (const raw of kitFrom) {
|
||
const g = str(raw, 80).trim();
|
||
if (groupKey(g) && !isNursingGroup(nursingRestored, g) && !isKitGroup(kitRestored, g)) kitRestored.push(g);
|
||
}
|
||
// capSets travels because the whole facility row is exported: leave it out of this half and a
|
||
// restore quietly hands everybody back the standing six, which is a different answer from the
|
||
// one the facility agreed with its managers. An older file carries no ceiling at all, and for
|
||
// that one the standing six is the honest reading.
|
||
await tx.facility.update({ where: { id: fid }, data: { barcodeSeq: Math.max(int(f.barcodeSeq, 0), restoredSeq), name: str(f.name, 120) || undefined, location: str(f.location, 120), coordinator: str(f.coordinator, 120), coordinatorEmail: str(f.coordinatorEmail, 160), coordinatorPhone: str(f.coordinatorPhone, 40), defaultEntitlement: int(f.defaultEntitlement, 5), initialSets: setsOnStart(int(f.initialSets, 0)), capSets: setsCap(int(f.capSets, 0)), nursingGroups: nursingRestored, kitGroups: kitRestored, defaultReorder: int(f.defaultReorder, 3), exceptionHigh: int(f.exceptionHigh, 10), varianceReason: Math.max(1, int(f.varianceReason, 5)), glAccount: str(f.glAccount, 40), journalDesc: str(f.journalDesc, 120) || "Uniform issues", logoData: typeof f.logoData === "string" && f.logoData.length <= LOGO_MAX * 1.4 && /^data:image\/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(f.logoData) ? f.logoData : "", staffGroups: Array.isArray(f.staffGroups) ? f.staffGroups.map((s: unknown) => str(s, 80)) : [], orderSeq: int(f.orderSeq, 0), catalogSeq: int(f.catalogSeq, 0), requestSeq: int(f.requestSeq, 0), timezone: zoneOf(f.timezone), slipCollectionFooter: str(f.slipCollectionFooter, 400), slipDeliveryFooter: str(f.slipDeliveryFooter, 400), slipOrg: str(f.slipOrg, 120), barcodeLookup: !!f.barcodeLookup } });
|
||
// Suppliers: v2 rows, or v1 facility.suppliers[] names.
|
||
const supNames: string[] = Array.isArray(b.suppliers) ? b.suppliers.map((s: { name: string }) => str(s.name, 80)) : Array.isArray(f.suppliers) ? f.suppliers.map((s: unknown) => str(s, 80)) : [];
|
||
// `sort` is carried across rather than re-derived from the array position. It decides the
|
||
// facility's default supplier — the one a new catalogue item and an auto-created replenishment
|
||
// order are addressed to — and the export reads the rows in whatever order Postgres hands them
|
||
// back, which changes the moment somebody edits a supplier's phone number. Re-numbering on the
|
||
// way in was quietly promoting whichever supplier had been edited most recently, so a restored
|
||
// facility started sending its replenishment orders to the wrong company.
|
||
for (const [i, name] of supNames.entries()) if (name) { const src = Array.isArray(b.suppliers) ? b.suppliers[i] : {}; await tx.supplier.create({ data: { facilityId: fid, name, contact: str(src.contact, 120), phone: str(src.phone, 120), account: str(src.account, 120), lead: src.lead === null || src.lead === undefined ? null : int(src.lead), sort: src && src.sort !== undefined && src.sort !== null ? int(src.sort, i) : i } }); }
|
||
const photoMap: Record<string, string> = {};
|
||
// A restored backup carries its images as base64; they land on disk like any new capture, so a
|
||
// restore doesn't quietly reintroduce the database bloat this move removed.
|
||
for (const ph of photoRows) {
|
||
const d = str(ph.data, PHOTO_MAX + 200);
|
||
const parsed = parseDataUrl(d);
|
||
if (!parsed) continue;
|
||
const c = await tx.photo.create({ data: { facilityId: fid, kind: str(ph.kind, 20) || "photo", mime: parsed.mime, bytes: parsed.bytes.length, createdAt: stamp(ph.createdAt) ?? undefined } });
|
||
const rel = await writePhoto(fid, c.id, parsed);
|
||
written.push(rel);
|
||
await tx.photo.update({ where: { id: c.id }, data: { path: rel } });
|
||
photoMap[String(ph.id)] = c.id;
|
||
}
|
||
const pm = (id: unknown): string | null => (id ? photoMap[String(id)] || null : null);
|
||
const itemMap: Record<string, string> = {}; const staffMap: Record<string, string> = {}; const orderMap: Record<string, string> = {};
|
||
const issueMap: Record<string, string> = {}; const requestMap: Record<string, string> = {};
|
||
// A garment's groups: the list a file written since carries, or else its `group` — one name in an
|
||
// older file, names joined by | as the CSV writes them — with "All" or blank being every group,
|
||
// which garmentGroups() makes the empty list.
|
||
for (const it of b.items) { const c = await tx.catalogItem.create({ data: { facilityId: fid, sort: int(it.sort), item: str(it.item, 160), gender: str(it.gender, 20), type: str(it.type, 40), sku: str(it.sku, 60), supplier: str(it.supplier, 80), cost: num(it.cost), groups: garmentGroups(Array.isArray(it.groups) ? it.groups.map((s: unknown) => str(s, 80)) : str(it.group, 4000).split("|")), notes: str(it.notes, 400), sizes: (it.sizes || []).map((s: unknown) => str(s, 20)), archived: !!it.archived } }); itemMap[it.id] = c.id; }
|
||
for (const d of b.depts || []) await tx.department.create({ data: { facilityId: fid, name: str(d.name, 120), cc: str(d.cc, 40), sort: int(d.sort) } });
|
||
// Activation codes are globally unique because a code has to identify a person before we know
|
||
// which facility they are in. This facility's own codes went with the staff rows above; a code
|
||
// still held by some other facility is dropped rather than failing the whole restore.
|
||
const wantedCodes = (b.staff || []).map((s: { activateCode?: unknown }) => str(s.activateCode, 40)).filter(Boolean);
|
||
const takenCodes = new Set(wantedCodes.length
|
||
? (await tx.staff.findMany({ where: { activateCode: { in: wantedCodes } }, select: { activateCode: true } })).map((x) => x.activateCode as string)
|
||
: []);
|
||
for (const s of b.staff || []) {
|
||
const code = str(s.activateCode, 40);
|
||
// FTE comes back exactly as the file has it, not re-judged on the way in. It was written by
|
||
// this app in the first place, and a restore that quietly blanks a figure it no longer likes
|
||
// would move that nurse onto no proposed kit at all with nothing on the screen to say so —
|
||
// the one failure a backup exists to prevent. Approval.fte has always been carried across
|
||
// this way for the same reason: it is a record of what was signed, not a fresh entry.
|
||
// Uniform style is read through the same normaliser the register and the importer use rather
|
||
// than taken verbatim, because only the words the rule knows decide anything. A file written
|
||
// before the field existed carries none, and those records come back blank — offered every
|
||
// style, which is exactly what they were being offered on the day the backup was written.
|
||
const c = await tx.staff.create({ data: { facilityId: fid, num: str(s.num, 40), first: str(s.first, 80), last: str(s.last, 80), phone: str(s.phone, 40), group: str(s.group, 80), dept: str(s.dept, 120), top: str(s.top, 20), pants: str(s.pants, 20), ccOverride: str(s.ccOverride, 40), inactive: !!s.inactive, ent: s.ent === null || s.ent === undefined ? null : int(s.ent), fte: str(s.fte, 20), uniformStyle: normalUniformStyle(s.uniformStyle) ?? "", start: isoDate(s.start), notes: str(s.notes, 2000), wardDesk: !!s.wardDesk, activateCode: code && !takenCodes.has(code) ? code : null, activateCodeAt: stamp(s.activateCodeAt) } });
|
||
staffMap[s.id] = c.id;
|
||
}
|
||
// Manager links in a second pass: a manager can appear later in the file than the people who
|
||
// report to them, and until every row exists there is nothing to point at. Without this a
|
||
// restored facility has nobody who can approve anything, so no request can be raised at all.
|
||
// Somebody who is their own manager comes back as their own manager — staffMap maps their id
|
||
// to their own new row on both sides.
|
||
for (const s of b.staff || []) if (s.managerId && staffMap[s.id] && staffMap[s.managerId]) await tx.staff.update({ where: { id: staffMap[s.id] }, data: { managerId: staffMap[s.managerId] } });
|
||
// Their own logins, re-attached to the freshly minted staff rows by staff number.
|
||
const staffByNum = new Map((await tx.staff.findMany({ where: { facilityId: fid }, select: { id: true, num: true } })).map((x) => [x.num, x.id] as const));
|
||
for (const a of keptAccounts) {
|
||
const sid = staffByNum.get(a.num);
|
||
if (sid) await tx.staffAccount.create({ data: { facilityId: fid, staffId: sid, email: a.email, passwordHash: a.passwordHash, createdAt: a.createdAt, lastSeenAt: a.lastSeenAt } });
|
||
}
|
||
for (const bc of b.barcodes || []) if (itemMap[bc.itemId]) await tx.barcode.create({ data: { facilityId: fid, code: str(bc.code, 64), itemId: itemMap[bc.itemId], sizeIndex: int(bc.sizeIndex), source: str(bc.source, 20) } });
|
||
// Locations first, then their parents wired up, so a bay restored before its shelf still lands in the tree.
|
||
const locMapB: Record<string, string> = {};
|
||
for (const l of b.locations || []) { const c = await tx.location.create({ data: { facilityId: fid, name: str(l.name, 60), kind: LOCATION_KINDS.includes(str(l.kind)) ? str(l.kind) : "Shelf", sort: int(l.sort), archived: !!l.archived } }); locMapB[l.id] = c.id; }
|
||
for (const l of b.locations || []) if (l.parentId && locMapB[l.id] && locMapB[l.parentId]) await tx.location.update({ where: { id: locMapB[l.id] }, data: { parentId: locMapB[l.parentId] } });
|
||
for (const s of b.stock || []) if (itemMap[s.itemId]) await tx.stockLevel.create({ data: { facilityId: fid, itemId: itemMap[s.itemId], sizeIndex: Math.max(0, int(s.sizeIndex)), opening: int(s.opening), adj: int(s.adj), reorder: s.reorder === null || s.reorder === undefined ? null : int(s.reorder), preloved: Math.max(0, int(s.preloved)), locationId: s.locationId ? locMapB[s.locationId] ?? null : null } });
|
||
for (const m of b.moves || []) if (itemMap[m.itemId]) await tx.stockMove.create({ data: { facilityId: fid, date: isoDate(m.date), type: str(m.type, 20), itemId: itemMap[m.itemId], sizeIndex: int(m.sizeIndex), qty: int(m.qty), reason: str(m.reason, 120), byName: str(m.byName, 120) } });
|
||
// Cost history follows its garment; a row whose item didn't survive the restore is dropped
|
||
// rather than orphaned, exactly as movements are.
|
||
for (const c of b.costs || []) if (itemMap[c.itemId]) await tx.costChange.create({ data: { facilityId: fid, itemId: itemMap[c.itemId], cost: num(c.cost), previous: c.previous === null || c.previous === undefined ? null : num(c.previous), at: c.at ? new Date(c.at) : new Date(), byName: str(c.byName, 120) } });
|
||
for (const o of [...(b.orders || [])].sort((a, c) => String(a.createdAt).localeCompare(String(c.createdAt)))) {
|
||
const c = await tx.order.create({ data: { facilityId: fid, code: str(o.code, 20), date: isoDate(o.date), source: str(o.source, 40), orderFor: str(o.orderFor, 20), staffId: o.staffId ? staffMap[o.staffId] ?? null : null, supplier: str(o.supplier, 80), status: str(o.status, 20), ref: str(o.ref, 120), invoice: str(o.invoice, 120), tracking: str(o.tracking, 120), expected: isoDate(o.expected), received: isoDate(o.received), cc: str(o.cc, 120), notes: str(o.notes, 400), replenish: !!o.replenish, createdAt: o.createdAt ? new Date(o.createdAt) : undefined,
|
||
lines: { create: (o.lines || []).filter((l: { itemId: string }) => itemMap[l.itemId]).map((l: { itemId: string; size: string; qty: number; sort?: number }, i: number) => ({ itemId: itemMap[l.itemId], size: str(l.size, 20), qty: int(l.qty), sort: int(l.sort, i) })) } } });
|
||
orderMap[o.id] = c.id;
|
||
if (o.parentId && orderMap[o.parentId]) await tx.order.update({ where: { id: c.id }, data: { parentId: orderMap[o.parentId] } });
|
||
for (const r of o.receipts || []) await tx.receipt.create({ data: { orderId: c.id, date: isoDate(r.date), invoice: str(r.invoice, 120), note: str(r.note, 400), photoId: pm(r.photoId), lines: { create: (r.lines || []).filter((l: { itemId: string }) => itemMap[l.itemId]).map((l: { itemId: string; size: string; qty: number; dest: string; cost: number }) => ({ itemId: itemMap[l.itemId], size: str(l.size, 20), qty: int(l.qty), dest: str(l.dest, 10), cost: num(l.cost) })) } } });
|
||
}
|
||
// A zero cost means two different things on this row. On a row written before Issue.cost
|
||
// existed it means "unknown", and the garment's catalogue price is the best guess we have.
|
||
// On a pre-loved row it means free, deliberately, and guessing a price for it puts money on
|
||
// the ward's cost centre for a garment nobody bought. `preloved` survives the round trip, so
|
||
// the two are told apart here rather than being averaged into one wrong answer.
|
||
for (const i of [...(b.issues || [])].sort((a, c) => String(a.createdAt).localeCompare(String(c.createdAt)))) if (itemMap[i.itemId] && staffMap[i.staffId]) issueMap[i.id] = (await tx.issue.create({ data: { facilityId: fid, date: isoDate(i.date), staffId: staffMap[i.staffId], itemId: itemMap[i.itemId], sizeIndex: int(i.sizeIndex), qty: int(i.qty), cond: str(i.cond, 40), preloved: !!i.preloved, handedIn: isoDate(i.handedIn) || null, returnPhotoId: pm(i.returnPhotoId), cost: i.preloved ? 0 : (num(i.cost) > 0 ? num(i.cost) : num((b.items || []).find((x: { id: string }) => x.id === i.itemId)?.cost)), orderCode: str(i.orderCode, 20), receipt: !!i.receipt, returnedDate: isoDate(i.returnedDate) || null, returnedCond: i.returnedCond ? str(i.returnedCond, 40) : null, override: !!i.override, offGroup: !!i.offGroup, offStyle: !!i.offStyle, direct: !!i.direct, createdAt: i.createdAt ? new Date(i.createdAt) : undefined } })).id;
|
||
for (const pu of b.pickups || []) if (orderMap[pu.orderId] && staffMap[pu.staffId]) await tx.pickup.create({ data: { facilityId: fid, orderId: orderMap[pu.orderId], staffId: staffMap[pu.staffId], received: isoDate(pu.received), contacted: !!pu.contacted, pickedUp: isoDate(pu.pickedUp) || null, deliveredTo: str(pu.deliveredTo, 120), sigId: pm(pu.sigId), proofId: pm(pu.proofId), deliveredRound: !!pu.deliveredRound, lines: { create: (pu.lines || []).filter((l: { itemId: string }) => itemMap[l.itemId]).map((l: { itemId: string; size: string; qty: number }) => ({ itemId: itemMap[l.itemId], size: str(l.size, 20), qty: int(l.qty) })) } } });
|
||
for (const t of b.stocktakes || []) await tx.stocktake.create({ data: { facilityId: fid, date: isoDate(t.date), byName: str(t.byName, 120), counted: int(t.counted), variances: int(t.variances), mode: t.mode === "preloved" ? "preloved" : "shelf", locationId: t.locationId ? locMapB[t.locationId] ?? null : null, createdAt: t.createdAt ? new Date(t.createdAt) : undefined, lines: { create: (t.lines || []).filter((l: { itemId: string }) => itemMap[l.itemId]).map((l: { itemId: string; sizeIndex: number; sys: number; counted: number; reason?: string }) => ({ itemId: itemMap[l.itemId], sizeIndex: int(l.sizeIndex), sys: int(l.sys), counted: int(l.counted), reason: str(l.reason, 40) })) } } });
|
||
// The approver's link is remapped through the same staff map as the subject's, because the ids
|
||
// in the file belong to the facility it was exported from. A manager who was not in that export,
|
||
// or has since gone off the register, comes back as a signature with no link — which is exactly
|
||
// what an approval typed before the register search existed already is.
|
||
for (const a of b.approvals || []) if (staffMap[a.staffId]) await tx.approval.create({ data: { facilityId: fid, staffId: staffMap[a.staffId], date: isoDate(a.date), byName: str(a.byName, 120), byStaffId: (a.byStaffId && staffMap[a.byStaffId]) || null, sets: int(a.sets), fte: str(a.fte, 10), notes: str(a.notes, 400), used: int(a.used), photoId: pm(a.photoId), createdAt: a.createdAt ? new Date(a.createdAt) : undefined } });
|
||
for (const h of b.handins || []) if (staffMap[h.staffId]) await tx.handIn.create({ data: { facilityId: fid, staffId: staffMap[h.staffId], date: isoDate(h.date), byName: str(h.byName, 120), credit: !!h.credit, createdAt: h.createdAt ? new Date(h.createdAt) : undefined, lines: { create: (h.lines || []).filter((l: { itemId: string }) => itemMap[l.itemId]).map((l: { itemId: string; sizeIndex: number; qty: number; cond: string; laundered: boolean; credited?: number }) => ({ itemId: itemMap[l.itemId], sizeIndex: int(l.sizeIndex), qty: int(l.qty), cond: l.cond === "Rag" ? "Rag" : "Good", laundered: l.laundered !== false, credited: Math.max(0, int(l.credited)) })) } } });
|
||
for (const a of b.alterations || []) if (staffMap[a.staffId]) await tx.alteration.create({ data: { facilityId: fid, staffId: staffMap[a.staffId], date: isoDate(a.date), garment: str(a.garment, 120), desc: str(a.desc, 400), status: str(a.status, 40), createdAt: a.createdAt ? new Date(a.createdAt) : undefined } });
|
||
|
||
/* ---------- the staff app's half
|
||
*
|
||
* Same id-remap as everything above: every row is created fresh and pointed at the new staff,
|
||
* catalogue, issue and request ids. A backup written before these keys existed simply has none
|
||
* of them, and `|| []` restores it exactly as it always did. */
|
||
for (const rq of [...(b.requests || [])].sort((x: { createdAt?: string }, y: { createdAt?: string }) => String(x.createdAt).localeCompare(String(y.createdAt)))) {
|
||
if (!staffMap[rq.subjectId]) continue; // the wearer didn't survive the restore
|
||
/* The garments. A file written before requests carried lines has the single garment on the
|
||
* request itself, so one line is built from those fields — otherwise restoring last week's
|
||
* backup would bring back a queue of requests with nothing on them. Its line inherits the
|
||
* request's own answer, which is the only one it can have had: a request that was accepted
|
||
* had that garment approved, and one that was declined had it refused. */
|
||
const legacy = rq.itemId ? [{ itemId: rq.itemId, sizeIndex: rq.sizeIndex, qty: rq.qty, status: rq.status === "awaiting" || rq.status === "declined" ? rq.status : "approved", declineReason: rq.declineReason, sort: 0 }] : [];
|
||
const rawLines: { itemId?: unknown; sizeIndex?: unknown; qty?: unknown; status?: unknown; declineReason?: unknown; sort?: unknown }[] =
|
||
(Array.isArray(rq.lines) && rq.lines.length ? rq.lines : legacy).slice(0, REQUEST_MAX_LINES);
|
||
const lineRows = rawLines
|
||
.filter((l) => itemMap[str(l.itemId)])
|
||
.map((l, i) => ({
|
||
itemId: itemMap[str(l.itemId)], sizeIndex: Math.max(0, int(l.sizeIndex)), qty: Math.max(1, int(l.qty, 1)),
|
||
status: LINE_STATUSES.includes(str(l.status, 20) as never) ? str(l.status, 20) : "awaiting",
|
||
declineReason: l.declineReason ? str(l.declineReason, 120) : null,
|
||
sort: int(l.sort, i),
|
||
}));
|
||
if (!lineRows.length) continue; // none of its garments survived the restore
|
||
const c = await tx.request.create({ data: {
|
||
facilityId: fid, code: str(rq.code, 20),
|
||
subjectId: staffMap[rq.subjectId],
|
||
raisedByStaffId: rq.raisedByStaffId ? staffMap[rq.raisedByStaffId] ?? null : null,
|
||
// Coordinator logins are not in the backup and are not touched by a restore, so the id is
|
||
// kept only when it is still one of this facility's own users. The name on the request
|
||
// survives either way, which is what the order actually shows.
|
||
raisedByUserId: rq.raisedByUserId && ourUsers.has(String(rq.raisedByUserId)) ? String(rq.raisedByUserId) : null,
|
||
raisedByName: str(rq.raisedByName, 120),
|
||
lines: { create: lineRows },
|
||
reason: str(rq.reason, 40), note: str(rq.note, 400),
|
||
status: REQ_STATUSES.has(str(rq.status, 20)) ? str(rq.status, 20) : "awaiting",
|
||
managerId: rq.managerId ? staffMap[rq.managerId] ?? null : null, managerName: str(rq.managerName, 120),
|
||
decidedAt: stamp(rq.decidedAt), declineReason: rq.declineReason ? str(rq.declineReason, 60) : null,
|
||
route: rq.route ? str(rq.route, 20) : null, collectCode: rq.collectCode ? str(rq.collectCode, 10) : null, holdUntil: str(rq.holdUntil, 40),
|
||
signerName: rq.signerName ? str(rq.signerName, 120) : null, signerRole: rq.signerRole ? str(rq.signerRole, 120) : null,
|
||
signedAt: stamp(rq.signedAt), claimedAt: stamp(rq.claimedAt),
|
||
createdAt: stamp(rq.createdAt) ?? undefined,
|
||
events: { create: (rq.events || []).map((e: { label?: unknown; meta?: unknown; actorName?: unknown; at?: unknown }) => ({ label: str(e.label, 120), meta: str(e.meta, 200), actorName: str(e.actorName, 120), at: stamp(e.at) ?? undefined })) },
|
||
messages: { create: (rq.messages || []).map((m: { fromStaff?: unknown; authorName?: unknown; body?: unknown; readAt?: unknown; createdAt?: unknown }) => ({ fromStaff: !!m.fromStaff, authorName: str(m.authorName, 120), body: str(m.body, 2000), readAt: stamp(m.readAt), createdAt: stamp(m.createdAt) ?? undefined })) },
|
||
} });
|
||
requestMap[rq.id] = c.id;
|
||
}
|
||
for (const w of b.waitlist || []) if (staffMap[w.staffId] && itemMap[w.itemId]) await tx.waitlistEntry.create({ data: { facilityId: fid, staffId: staffMap[w.staffId], itemId: itemMap[w.itemId], sizeIndex: int(w.sizeIndex), offeredAt: stamp(w.offeredAt), acceptedAt: stamp(w.acceptedAt), leftAt: stamp(w.leftAt), createdAt: stamp(w.createdAt) ?? undefined } });
|
||
for (const k of b.kitChecks || []) await tx.kitCheck.create({ data: {
|
||
facilityId: fid, dueBy: isoDate(k.dueBy), openedAt: stamp(k.openedAt) ?? undefined, closedAt: stamp(k.closedAt), openedBy: str(k.openedBy, 120),
|
||
answers: { create: (k.answers || []).filter((a: { staffId: string; itemId: string }) => staffMap[a.staffId] && itemMap[a.itemId]).map((a: { staffId: string; itemId: string; sizeIndex: number; onRecord: number; confirmed: number; answeredAt?: unknown }) => ({ staffId: staffMap[a.staffId], itemId: itemMap[a.itemId], sizeIndex: int(a.sizeIndex), onRecord: int(a.onRecord), confirmed: int(a.confirmed), answeredAt: stamp(a.answeredAt) ?? undefined })) },
|
||
} });
|
||
for (const d of b.damage || []) if (staffMap[d.staffId]) await tx.damageReport.create({ data: { facilityId: fid, staffId: staffMap[d.staffId], issueId: d.issueId ? issueMap[d.issueId] ?? null : null, kind: str(d.kind, 40), note: str(d.note, 400), photoId: pm(d.photoId), requestId: d.requestId ? requestMap[d.requestId] ?? null : null, handedInAt: stamp(d.handedInAt), createdAt: stamp(d.createdAt) ?? undefined } });
|
||
for (const d of b.disputes || []) if (staffMap[d.staffId]) await tx.recordDispute.create({ data: { facilityId: fid, staffId: staffMap[d.staffId], itemId: d.itemId ? itemMap[d.itemId] ?? null : null, sizeIndex: d.sizeIndex === null || d.sizeIndex === undefined ? null : int(d.sizeIndex), body: str(d.body, 2000), resolvedAt: stamp(d.resolvedAt), resolvedBy: str(d.resolvedBy, 120), createdAt: stamp(d.createdAt) ?? undefined } });
|
||
for (const n of b.notices || []) await tx.linenNotice.create({ data: { facilityId: fid, body: str(n.body, 400), startsAt: isoDate(n.startsAt), endsAt: isoDate(n.endsAt), createdAt: stamp(n.createdAt) ?? undefined } });
|
||
return orphans;
|
||
}, { timeout: 120000 }).catch(async (e) => {
|
||
// The files are written outside the database, so a rollback cannot reach them. A big restore
|
||
// that hits the 120-second transaction timeout leaves the facility untouched — and, without
|
||
// this, several hundred signatures and photographs of damaged uniforms sitting in the photo
|
||
// directory with no Photo row naming them: nothing in the product could ever find or delete
|
||
// them, and every retry of the restore added another set.
|
||
await unlinkAll(written);
|
||
throw e;
|
||
});
|
||
// After the commit, never before: a rolled-back restore that had already unlinked the files
|
||
// would leave every surviving row pointing at an image that is no longer there.
|
||
await unlinkAll(orphanedFiles);
|
||
return { ok: true, photosSkipped };
|
||
}
|