822c0b7c0b
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
1297 lines
55 KiB
Plaintext
1297 lines
55 KiB
Plaintext
generator client {
|
||
provider = "prisma-client-js"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
}
|
||
|
||
enum Role {
|
||
ADMIN
|
||
ISSUER
|
||
}
|
||
|
||
model Facility {
|
||
id String @id @default(cuid())
|
||
name String
|
||
location String @default("Linen Room")
|
||
coordinator String @default("")
|
||
/// How the linen room is reached, printed in the footer of the order form a ward manager signs. Held
|
||
/// here and nowhere else: the form carries a real health service's contacts, and the only place
|
||
/// a customer's own address and number may live is that customer's own settings.
|
||
coordinatorEmail String @default("")
|
||
coordinatorPhone String @default("")
|
||
/// The IANA zone this linen room actually stands in. Every date-only column (Issue.date,
|
||
/// Stocktake.date, Order.date, HandIn.date…) is "today" as this zone sees it, which is what
|
||
/// decides the month an issue is journalled in and the date printed on a credit slip. Brisbane
|
||
/// only because that is where the first facility was; a Perth or Hobart room sets its own.
|
||
timezone String @default("Australia/Brisbane")
|
||
defaultEntitlement Int @default(5)
|
||
/// The starting kit, in sets — a set being a top and a bottom — for the staff groups on the
|
||
/// starting-kit route (kitGroups): handed over on the first day, with more issued as needed up to
|
||
/// capSets. The FTE-table groups (nursingGroups) start on a number proposed by their hours instead,
|
||
/// and groups on manager approval start on none. A starting kit and not a yearly allowance, so it
|
||
/// is nothing to do with defaultEntitlement, which still counts garments per financial year for
|
||
/// everything that reads it.
|
||
initialSets Int @default(3)
|
||
/// The ceiling, in sets HELD at any one time, for every staff group in the building, whichever of
|
||
/// the three routes it is on. Six sets is twelve garments, and there is no financial year in it:
|
||
/// the only ways past it are a hand-in, which swaps rather than adds, and a coordinator's override,
|
||
/// recorded as one. The routes differ only in how somebody gets up to it — hours proposing a
|
||
/// number, a starting kit, or a manager approving a set at a time.
|
||
capSets Int @default(6)
|
||
defaultReorder Int @default(3)
|
||
exceptionHigh Int @default(10)
|
||
varianceReason Int @default(5) // a count gap this big or bigger has to carry a reason
|
||
glAccount String @default("")
|
||
journalDesc String @default("Uniform issues")
|
||
lastBackup String @default("")
|
||
logoData String @default("")
|
||
barcodeLookup Boolean @default(false) // ask a public barcode database for a product name when an unknown code is scanned
|
||
/// Single sign-on through the facility's own identity provider (SAML/OIDC via the Jackson
|
||
/// broker; the IdP metadata lives in Jackson, keyed by this facility's id). Domains decide whose
|
||
/// email reaches SSO; required means the password door is refused for everyone except admins
|
||
/// flagged break-glass; staff says wearers with an account may use it too (web only).
|
||
ssoEnabled Boolean @default(false)
|
||
ssoRequired Boolean @default(false)
|
||
ssoStaff Boolean @default(false)
|
||
ssoDomains String[] @default([])
|
||
staffGroups String[] @default([])
|
||
/// The staff groups on the FTE-table route: the hours they work propose their starting kit, off the
|
||
/// table on the signed order form, and a manager may sign above it up to capSets. A list of names
|
||
/// this facility chose rather than a test for letters in a name, because every employer names its
|
||
/// groups differently. Empty means none of its groups are on that route — there is no fallback.
|
||
/// Renaming a group renames it here too, which is the point: nobody's allowance may change because
|
||
/// a label was tidied up.
|
||
nursingGroups String[] @default([])
|
||
/// The staff groups on the starting-kit route: initialSets on the first day, then more as needed up
|
||
/// to capSets, with nothing to hand back first. Read and renamed exactly as nursingGroups is, and a
|
||
/// group may not be on both. A group on neither list is on manager approval — no starting kit, and
|
||
/// a manager approves a set at a time.
|
||
kitGroups String[] @default([])
|
||
orderSeq Int @default(0)
|
||
catalogSeq Int @default(0)
|
||
/// Numbers the staff-app requests (R-0001…). Incremented inside the same transaction that
|
||
/// creates the request, like orderSeq, so two people submitting at once can't collide on a code.
|
||
requestSeq Int @default(0)
|
||
/// Bumped once per successful mutation, by the three routes that can change anything here.
|
||
///
|
||
/// The screens already know how to reload themselves — every mutation ends in router.refresh().
|
||
/// What they had no way to know was that somebody ELSE had changed something, so a phone on a
|
||
/// ward showed whatever the catalogue looked like when it was opened. Polling the whole snapshot
|
||
/// to find out would mean re-reading the facility's history every few seconds on every device;
|
||
/// polling this integer costs a primary-key lookup and a couple of hundred bytes, and the
|
||
/// expensive fetch happens only when the number has actually moved.
|
||
rev Int @default(0)
|
||
/// Numbers the barcodes this room prints for garments that arrived without one. Incremented in
|
||
/// the same transaction that binds the code, like orderSeq, so two people labelling at once
|
||
/// cannot mint the same number.
|
||
barcodeSeq Int @default(0)
|
||
slipCollectionFooter String @default("Collect from the Linen Room during opening hours. Enquiries: see coordinator.")
|
||
slipDeliveryFooter String @default("After hours deliveries are left with the manager or team leader on duty.")
|
||
slipOrg String @default("")
|
||
/// The plan, and where the facility stands on it. Written only by ThreadCount and
|
||
/// by sign-up; read by lib/plan.ts, which turns these six columns into the entitlements every
|
||
/// write door checks. `plan` is a code (hosted_small · hosted_facility · health_service ·
|
||
/// private; empty means "whatever a new sign-up gets"), `planStatus` is what was last
|
||
/// set (free · trial · active · read_only) and the dates decide what that means today — a trial
|
||
/// past trialEndsAt, or a paid year past paidUntil, runs a fortnight of grace and then goes
|
||
/// read-only. Nothing is ever deleted for money: read-only keeps every report, export and the
|
||
/// backup working.
|
||
///
|
||
/// `grandfathered` is the promise the pricing page made while the product was free: every
|
||
/// facility created before plans went live keeps everything, hosted, for as long as it exists.
|
||
/// It survives a change of plan, so a grandfathered room that buys and later lapses falls back to
|
||
/// free, never to read-only. Only an explicit read_only overrides it.
|
||
plan String @default("")
|
||
planNote String @default("")
|
||
planStatus String @default("free")
|
||
trialEndsAt DateTime?
|
||
paidUntil DateTime?
|
||
/// Where invoices go. A contact, set by the coordinator from Settings › Plan and shown to
|
||
/// nobody else.
|
||
billingEmail String @default("")
|
||
grandfathered Boolean @default(false)
|
||
isDemo Boolean @default(false)
|
||
demoResetAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
/// The health service this facility belongs to, if any. Set by ThreadCount when a
|
||
/// Health Service plan is sold. A member facility's entitlements come from the organisation's
|
||
/// plan row, not its own (lib/plan.ts), and its catalogue and suppliers may be published to it
|
||
/// from the organisation's. Nothing about a wearer crosses this line: the organisation sees
|
||
/// roll-ups, and reaching a facility's records still takes a coordinator account there.
|
||
orgId String?
|
||
org Organisation? @relation(fields: [orgId], references: [id], onDelete: SetNull)
|
||
/// Card payments (Stripe), dormant until STRIPE_* is set. Ids only; no card data is ever here.
|
||
stripeCustomerId String @default("")
|
||
stripeSubscriptionId String @default("")
|
||
|
||
users User[]
|
||
items CatalogItem[]
|
||
barcodes Barcode[]
|
||
stock StockLevel[]
|
||
moves StockMove[]
|
||
depts Department[]
|
||
staff Staff[]
|
||
issues Issue[]
|
||
orders Order[]
|
||
pickups Pickup[]
|
||
stocktakes Stocktake[]
|
||
suppliers Supplier[]
|
||
approvals Approval[]
|
||
alterations Alteration[]
|
||
handins HandIn[]
|
||
photos Photo[]
|
||
locations Location[]
|
||
auditEvents AuditEvent[]
|
||
costChanges CostChange[]
|
||
staffAccounts StaffAccount[]
|
||
requests Request[]
|
||
waitlist WaitlistEntry[]
|
||
kitChecks KitCheck[]
|
||
notices LinenNotice[]
|
||
damageReports DamageReport[]
|
||
disputes RecordDispute[]
|
||
|
||
@@index([orgId])
|
||
}
|
||
|
||
/// A health service: several facilities under one owner, one plan, one sign-in for the people who
|
||
/// run it, and a catalogue and supplier list the facilities can inherit. It is not a facility and
|
||
/// holds no wearer, staff or issue records of its own — what it can see is what lib/orgdata.ts
|
||
/// computes as roll-ups. Created only by ThreadCount.
|
||
model Organisation {
|
||
id String @id @default(cuid())
|
||
name String
|
||
createdAt DateTime @default(now())
|
||
/// The plan the member facilities inherit — the same columns Facility carries, read by
|
||
/// lib/plan.ts in the facility's place whenever Facility.orgId is set.
|
||
plan String @default("health_service")
|
||
planStatus String @default("trial")
|
||
trialEndsAt DateTime?
|
||
paidUntil DateTime?
|
||
billingEmail String @default("")
|
||
planNote String @default("")
|
||
/// Organisation-level single sign-on: one IdP connection (Jackson tenant "org:<id>") that every
|
||
/// member facility's coordinators may sign in through. Domains route an email to it.
|
||
ssoEnabled Boolean @default(false)
|
||
ssoDomains String[] @default([])
|
||
|
||
facilities Facility[]
|
||
users OrgUser[]
|
||
items OrgCatalogItem[]
|
||
suppliers OrgSupplier[]
|
||
events OrgEvent[]
|
||
}
|
||
|
||
/// Somebody who runs a health service's uniform programme across its facilities. A fourth kind
|
||
/// of identity (see lib/org/session.ts), never a coordinator: holding this account opens the
|
||
/// organisation's roll-ups and its shared lists, and nothing inside any facility.
|
||
model OrgUser {
|
||
id String @id @default(cuid())
|
||
orgId String
|
||
org Organisation @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||
email String @unique
|
||
name String
|
||
passwordHash String
|
||
totpSecret String @default("")
|
||
totpEnabledAt DateTime?
|
||
inactive Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
lastSeenAt DateTime?
|
||
|
||
events OrgEvent[]
|
||
|
||
@@index([orgId])
|
||
}
|
||
|
||
/// A garment in the organisation's shared catalogue. Publishing writes it into each member
|
||
/// facility's own catalogue (CatalogItem.orgItemId links back); the facility keeps its stock,
|
||
/// barcodes, locations and reorder levels, and may change cost and supplier locally until the next
|
||
/// publish. Sizes are append-only in the facility, as everywhere else.
|
||
model OrgCatalogItem {
|
||
id String @id @default(cuid())
|
||
orgId String
|
||
org Organisation @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||
item String
|
||
gender String @default("Unisex")
|
||
type String @default("")
|
||
sku String @default("")
|
||
supplier String @default("")
|
||
cost Float @default(0)
|
||
groups String[] @default([])
|
||
sizes String[] @default([])
|
||
notes String @default("")
|
||
archived Boolean @default(false)
|
||
updatedAt DateTime @updatedAt
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([orgId, item, gender])
|
||
}
|
||
|
||
model OrgSupplier {
|
||
id String @id @default(cuid())
|
||
orgId String
|
||
org Organisation @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||
name String
|
||
contact String @default("")
|
||
phone String @default("")
|
||
account String @default("")
|
||
lead Int?
|
||
|
||
@@unique([orgId, name])
|
||
}
|
||
|
||
/// What the organisation's people did: sign-ins, publishes, membership changes. Append-only.
|
||
model OrgEvent {
|
||
id String @id @default(cuid())
|
||
orgId String
|
||
org Organisation @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||
userId String?
|
||
user OrgUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||
action String
|
||
detail String @default("")
|
||
ip String @default("")
|
||
at DateTime @default(now())
|
||
|
||
@@index([orgId, at])
|
||
}
|
||
|
||
model Supplier {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
name String
|
||
contact String @default("")
|
||
phone String @default("")
|
||
account String @default("")
|
||
lead Int?
|
||
sort Int @default(0)
|
||
|
||
@@unique([facilityId, name])
|
||
}
|
||
|
||
model Approval {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
staffId String
|
||
staff Staff @relation("ApprovalSubject", fields: [staffId], references: [id], onDelete: Cascade)
|
||
date String
|
||
/// The approver's name exactly as it was signed on the form. It is a copy, not a lookup, and it
|
||
/// stays one: a form that has been signed does not start reading differently because the person
|
||
/// who signed it was later married, promoted or taken off the register. Every other place this
|
||
/// schema writes a name down beside an id — Order, HandIn, Stocktake — does it for the same
|
||
/// reason.
|
||
byName String @default("")
|
||
/// Which person on the register that signature belongs to, when the coordinator picked the
|
||
/// manager out of the register rather than typing them. It is what lets a manager's own history
|
||
/// of approvals be found, and lets it survive that manager being renamed, which matching on
|
||
/// byName never could.
|
||
///
|
||
/// Nullable because it has to be: every approval recorded before the register search existed
|
||
/// carries a typed name and nothing else, and a manager who is not on the register — an agency
|
||
/// manager, one covering from another ward — still signs forms. A blank here means "we only have the signature",
|
||
/// never "nobody approved this".
|
||
///
|
||
/// It is also the only reliable way to see that a manager approved their own kit: staffId ==
|
||
/// byStaffId says so outright, where two spellings of the same typed name never could. That case
|
||
/// is now allowed, so anything showing an approval has to be able to say when it happened.
|
||
byStaffId String?
|
||
/// SetNull rather than Cascade or Restrict, and byName is what makes that safe. Cascade would
|
||
/// delete signed approvals along with the manager who signed them, taking the sets somebody was
|
||
/// actually issued off the record; Restrict would refuse to let the linen room remove a manager
|
||
/// who has ever approved anything, which is most of them. So the approval stays, the link goes,
|
||
/// and the name as signed is still there to read.
|
||
byStaff Staff? @relation("ApprovalApprover", fields: [byStaffId], references: [id], onDelete: SetNull)
|
||
sets Int
|
||
fte String @default("")
|
||
notes String @default("")
|
||
used Int @default(0)
|
||
photoId String? // photo of the signed order form
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([staffId])
|
||
// The history screen asks "what has this manager approved?", which without this is a scan of
|
||
// every approval the facility has ever recorded.
|
||
@@index([byStaffId])
|
||
}
|
||
|
||
model Alteration {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
staffId String
|
||
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
||
date String
|
||
garment String
|
||
desc String @default("")
|
||
status String @default("Requested")
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([staffId])
|
||
}
|
||
|
||
model User {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
email String @unique
|
||
passwordHash String
|
||
first String
|
||
last String
|
||
title String @default("")
|
||
role Role @default(ISSUER)
|
||
inactive Boolean @default(false)
|
||
/// Keeps a working password when the facility requires single sign-on: the fire escape.
|
||
ssoBreakGlass Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
/// Encrypted with a key derived from SESSION_SECRET — a one-time-password secret sitting in
|
||
/// plaintext is a second factor that one leaked dump removes for every account at once.
|
||
/// Set while enrolling; `totpEnabledAt` is what actually turns the requirement on.
|
||
totpSecret String @default("")
|
||
totpEnabledAt DateTime?
|
||
|
||
resets PasswordReset[]
|
||
recovery RecoveryCode[]
|
||
raisedRequests Request[]
|
||
|
||
@@index([facilityId])
|
||
}
|
||
|
||
/// ThreadCount's own administrative identity, separate from every facility.
|
||
///
|
||
/// Not a User. A User belongs to one facility and every query it can reach is scoped to that
|
||
/// facility; an Operator belongs to none and is the one identity that sees across them. Keeping it
|
||
/// in its own table means a missed role check on User can never become platform-wide access, and
|
||
/// its session is a third cookie with a third key and a third claim name — see lib/ops/session.ts —
|
||
/// so no arrangement of the other two tokens can be presented as this one.
|
||
///
|
||
/// SUPPORT is read-only with no reveal. OWNER may reveal masked contacts, flip switches and open the
|
||
/// danger zone. Seeded with one OWNER row; adding a second person is a row plus a Cloudflare Access
|
||
/// policy entry, not a refactor.
|
||
enum OperatorRole {
|
||
OWNER
|
||
SUPPORT
|
||
}
|
||
|
||
model Operator {
|
||
id String @id @default(cuid())
|
||
email String @unique
|
||
name String
|
||
role OperatorRole @default(SUPPORT)
|
||
passwordHash String
|
||
/// Encrypted like User.totpSecret, but with a key derived from its own secret rather than
|
||
/// SESSION_SECRET: rotating this secret must never touch a customer's second factor,
|
||
/// and the reverse.
|
||
totpSecret String @default("")
|
||
totpEnabledAt DateTime?
|
||
inactive Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
lastSeenAt DateTime?
|
||
|
||
events OperatorEvent[]
|
||
grants RevealGrant[]
|
||
recovery OperatorRecoveryCode[]
|
||
}
|
||
|
||
/// Single-use codes for the day an operator's phone is lost. Hashed, shown once at enrolment.
|
||
/// Without these a lost phone closes the password door until somebody clears the second factor
|
||
/// over SSH — and until single sign-on exists, the password door is the only door.
|
||
model OperatorRecoveryCode {
|
||
id String @id @default(cuid())
|
||
operatorId String
|
||
operator Operator @relation(fields: [operatorId], references: [id], onDelete: Cascade)
|
||
codeHash String
|
||
usedAt DateTime?
|
||
|
||
@@index([operatorId])
|
||
}
|
||
|
||
/// The operations trail. Append-only: nothing in the product updates or deletes a row, and an
|
||
/// Operator with rows cannot be deleted (Restrict) — deactivate them instead, the same rule the
|
||
/// register applies to a staff member with history.
|
||
///
|
||
/// `facilityId` is a plain column and deliberately NOT a relation. AuditEvent's facilityId cascades
|
||
/// on delete, which would let a facility that closes its account erase every record of what an
|
||
/// operator revealed about it. This table remembers regardless. It also lets an event be about
|
||
/// nothing in particular — a switch flipped, a sign-in — where AuditEvent has nowhere to file it.
|
||
model OperatorEvent {
|
||
id String @id @default(cuid())
|
||
operatorId String
|
||
operator Operator @relation(fields: [operatorId], references: [id], onDelete: Restrict)
|
||
at DateTime @default(now())
|
||
action String
|
||
facilityId String @default("")
|
||
subject String @default("")
|
||
detail String @default("")
|
||
ip String @default("")
|
||
|
||
@@index([at])
|
||
@@index([operatorId, at])
|
||
@@index([facilityId, at])
|
||
}
|
||
|
||
/// One row. Platform switches ThreadCount flips without a deploy. The environment
|
||
/// still wins when set (SIGNUPS_DISABLED=1, DEMO_DISABLED=1): a box can be locked down by hand and
|
||
/// nothing can open it again until the variable goes.
|
||
model PlatformSwitch {
|
||
id String @id @default("platform")
|
||
signupsDisabled Boolean @default(false)
|
||
demoDisabled Boolean @default(false)
|
||
/// Plans are live: new sign-ups start on Hosted Small instead of being grandfathered, and the
|
||
/// Plan tab appears in Settings. Off until the sixty days' notice to existing rooms has run.
|
||
plansLive Boolean @default(false)
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
/// A thirty-minute window in which one masked contact field of one facility is shown unmasked.
|
||
///
|
||
/// A server-side row rather than a claim in the session cookie, so a grant can be revoked before it
|
||
/// expires. The reason is recorded here and mirrored into OperatorEvent; the grant is the mechanism,
|
||
/// the event is the record, and both are kept forever.
|
||
model RevealGrant {
|
||
id String @id @default(cuid())
|
||
operatorId String
|
||
operator Operator @relation(fields: [operatorId], references: [id], onDelete: Restrict)
|
||
facilityId String
|
||
field String
|
||
reason String
|
||
at DateTime @default(now())
|
||
expiresAt DateTime
|
||
|
||
@@index([operatorId, facilityId, expiresAt])
|
||
}
|
||
|
||
/// Single-use codes for the day the phone is lost.
|
||
///
|
||
/// Without these a locked-out admin is a locked-out facility, because deleting the last admin
|
||
/// deletes everything. Stored hashed, like any other credential.
|
||
model RecoveryCode {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
codeHash String
|
||
usedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([userId])
|
||
}
|
||
|
||
/// A password reset in flight.
|
||
///
|
||
/// The token is stored hashed, never in the clear: this table is in every backup, and a plaintext
|
||
/// token in a leaked dump is a live key to an account until it expires. Rows are single-use and
|
||
/// short-lived, and a successful reset changes the password hash, which invalidates every existing
|
||
/// session for that user through the session token's password version.
|
||
model PasswordReset {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
tokenHash String @unique
|
||
expiresAt DateTime
|
||
usedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
requestIp String @default("")
|
||
|
||
@@index([userId])
|
||
@@index([expiresAt])
|
||
}
|
||
|
||
/// Who changed what, and when.
|
||
///
|
||
/// Issues and stock movements already record their own actor, but nothing recorded the rest: who
|
||
/// edited a price, moved a par level, archived a garment, deleted a staff record or changed the
|
||
/// variance threshold. "Who changed this?" is the first question in any review of a hospital
|
||
/// system, and until now the answer was "nobody knows".
|
||
///
|
||
/// Deliberately holds no values — only the operation and the record ids it touched. An audit trail
|
||
/// that quoted the data would become a second copy of the staff register, growing forever, outside
|
||
/// every retention rule that applies to the first one.
|
||
model AuditEvent {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
at DateTime @default(now())
|
||
userId String
|
||
userName String
|
||
op String
|
||
target String @default("")
|
||
ip String @default("")
|
||
|
||
@@index([facilityId, at])
|
||
}
|
||
|
||
/// What a garment cost, and when it changed.
|
||
///
|
||
/// CatalogItem.cost is a single field, so raising a price silently overwrote what you used to pay
|
||
/// and "what did these cost us last year" became unanswerable — which is a question finance asks
|
||
/// every year. This keeps the old figures.
|
||
///
|
||
/// Unlike the audit trail this deliberately does hold a value, because the value is the entire
|
||
/// point. It is a price, not personal information.
|
||
model CostChange {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
cost Float
|
||
previous Float?
|
||
at DateTime @default(now())
|
||
byName String @default("")
|
||
|
||
@@index([facilityId, at])
|
||
@@index([itemId, at])
|
||
}
|
||
|
||
/// A request for uniform, from the person who will wear it.
|
||
///
|
||
/// The spine of the staff app. Two rules shape every field here.
|
||
///
|
||
/// First: **no money**. There is no price, no basket and no payment anywhere in this model, and
|
||
/// there must not be. Every garment is trust stock issued against a person's record; what it cost
|
||
/// is the linen room's business and lives on Issue.
|
||
///
|
||
/// Second: **the manager sees it before the linen room does**. `status` starts at `awaiting` and
|
||
/// the fulfilment fields stay null until someone approves. A request the linen room can act on is
|
||
/// one a named person put their name to.
|
||
model Request {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
code String // human reference, e.g. R-2291
|
||
|
||
/// Who the uniform is for. Always the wearer, never the person who typed it in.
|
||
subjectId String
|
||
subject Staff @relation("RequestSubject", fields: [subjectId], references: [id], onDelete: Cascade)
|
||
|
||
/// Who raised it, when that is not the subject: a ward clerk at the desk, or a coordinator at
|
||
/// the counter. Both names sit on the order — the design is explicit that the subject is never
|
||
/// hidden behind whoever typed it.
|
||
raisedByStaffId String?
|
||
raisedByStaff Staff? @relation("RequestRaisedByStaff", fields: [raisedByStaffId], references: [id], onDelete: SetNull)
|
||
raisedByUserId String?
|
||
raisedByUser User? @relation(fields: [raisedByUserId], references: [id], onDelete: SetNull)
|
||
/// Denormalised so the order still names them after the account goes.
|
||
raisedByName String @default("")
|
||
|
||
/// Why the uniform is needed, and anything the wearer wanted to add. Both sit on the request and
|
||
/// not on the line: a nurse asking for a tunic, trousers and a fleece is making one ask for one
|
||
/// reason, and making them justify each garment separately is the paperwork this replaces.
|
||
reason String @default("") // Worn out | Damaged | Lost | Extra for shifts
|
||
note String @default("")
|
||
|
||
/// awaiting | declined | accepted | picking | ready | round | collected | delivered
|
||
///
|
||
/// The machine is unchanged, but the decision step is now a ROLLUP of the lines: the manager
|
||
/// looks at the whole ask and approves in one action, while still being able to decline
|
||
/// individual lines. When they decide, if every line ended up `declined` the request goes
|
||
/// `declined`; if at least one line is `approved` it goes `accepted`, and only the approved
|
||
/// lines are picked, bagged and collected. One code, one pick, one collection either way.
|
||
status String @default("awaiting")
|
||
|
||
/// The approver, snapshotted at submit time. A later change of manager must not silently
|
||
/// re-address a request that is already with someone.
|
||
managerId String?
|
||
managerName String @default("")
|
||
decidedAt DateTime?
|
||
/// One of exactly three: Over allowance | Not needed right now | Wrong item for the role.
|
||
/// Always shown to the staff member — a decline without a reason is the thing this replaces.
|
||
/// Set only when the WHOLE request was refused; a partly-approved request carries its refusals
|
||
/// on the individual lines instead.
|
||
declineReason String?
|
||
|
||
/// counter | ward_round — chosen by the linen room, never by the requester, who finds out after
|
||
/// the fact.
|
||
route String?
|
||
/// Four digits shown at the counter. Only ever set for the counter route.
|
||
collectCode String?
|
||
holdUntil String @default("")
|
||
|
||
/// Anyone on the ward may sign for a round bag, and whoever does is named on the requester's
|
||
/// order — so a bag that never arrived has a name against it.
|
||
signerName String?
|
||
signerRole String?
|
||
signedAt DateTime?
|
||
/// Set when the bag HAS been picked up off the ward desk — by the requester on their own order,
|
||
/// or by the desk clerk clearing the pile (round.claim, from /my/orders/[id] and /my/round). The
|
||
/// unclaimed list is the delivered rows where this is still null; unclaimed bags are the linen
|
||
/// room's biggest waste, so the desk sees them above today's work.
|
||
claimedAt DateTime?
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
lines RequestLine[]
|
||
events RequestEvent[]
|
||
messages RequestMessage[]
|
||
|
||
@@unique([facilityId, code])
|
||
@@index([facilityId, status])
|
||
@@index([subjectId])
|
||
@@index([managerId])
|
||
}
|
||
|
||
/// One garment on a request.
|
||
///
|
||
/// A request used to carry a single garment, so a nurse who needed a tunic, trousers and a fleece
|
||
/// raised three of them: three codes, three approval emails to the same manager on the same day,
|
||
/// three rows on the linen room's queue, three bags. The garments moved onto lines so that one ask
|
||
/// is one request — but each line keeps its OWN status, because a manager quite reasonably wants to
|
||
/// approve the tunic and the trousers and refuse the fleece as over allowance. Only the approved
|
||
/// lines are picked.
|
||
model RequestLine {
|
||
id String @id @default(cuid())
|
||
requestId String
|
||
request Request @relation(fields: [requestId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
sizeIndex Int
|
||
qty Int @default(1)
|
||
|
||
/// awaiting | approved | declined. Every line starts `awaiting` and is settled when the manager
|
||
/// decides the request; the request's own status is then the rollup of these.
|
||
status String @default("awaiting")
|
||
|
||
/// Why this one garment was refused, from the same three reasons as a whole-request decline.
|
||
/// The wearer is always told which line was knocked back and why — otherwise a bag turning up
|
||
/// two garments short looks like the linen room lost them.
|
||
declineReason String?
|
||
|
||
/// The order the requester entered them in, kept so the manager's screen, the pick list and the
|
||
/// bag all read the same way down the page.
|
||
sort Int @default(0)
|
||
|
||
@@index([requestId])
|
||
}
|
||
|
||
/// One line of a request's timeline. Append-only: the screen is a history, so nothing here is
|
||
/// ever updated or deleted, and the order they were written in is the order they are shown.
|
||
model RequestEvent {
|
||
id String @id @default(cuid())
|
||
requestId String
|
||
request Request @relation(fields: [requestId], references: [id], onDelete: Cascade)
|
||
label String
|
||
meta String @default("")
|
||
actorName String @default("")
|
||
at DateTime @default(now())
|
||
|
||
@@index([requestId])
|
||
}
|
||
|
||
/// A message about one request. There is no general inbox and no direct messaging: every message
|
||
/// hangs off the thing it is about, which is what stops this becoming a chat app nobody staffs.
|
||
model RequestMessage {
|
||
id String @id @default(cuid())
|
||
requestId String
|
||
request Request @relation(fields: [requestId], references: [id], onDelete: Cascade)
|
||
/// True when the wearer wrote it; false when the linen room did.
|
||
fromStaff Boolean
|
||
authorName String @default("")
|
||
body String
|
||
readAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([requestId])
|
||
}
|
||
|
||
/// Waiting for a size that is not on the shelf.
|
||
///
|
||
/// Joining needs no approval — the design is deliberate that a queue is not a request. Approval
|
||
/// happens if and when the item lands and the person accepts it.
|
||
model WaitlistEntry {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
staffId String
|
||
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
sizeIndex Int
|
||
/// Set when stock arrives; the item is held for this person for 48 hours from here.
|
||
offeredAt DateTime?
|
||
acceptedAt DateTime?
|
||
leftAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
/// One place in the queue per garment and size. Joining twice is a bug, not a strategy.
|
||
@@unique([staffId, itemId, sizeIndex])
|
||
@@index([facilityId, itemId, sizeIndex])
|
||
}
|
||
|
||
/// A round of "does this match what is in your locker?", run across the facility.
|
||
model KitCheck {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
dueBy String // ISO date
|
||
openedAt DateTime @default(now())
|
||
closedAt DateTime?
|
||
openedBy String @default("")
|
||
|
||
answers KitCheckAnswer[]
|
||
|
||
@@index([facilityId])
|
||
}
|
||
|
||
/// One person's answer about one garment. A shortfall is written off, never charged — the cycle
|
||
/// exists to correct the record and set par levels, not to chase anyone.
|
||
model KitCheckAnswer {
|
||
id String @id @default(cuid())
|
||
kitCheckId String
|
||
kitCheck KitCheck @relation(fields: [kitCheckId], references: [id], onDelete: Cascade)
|
||
staffId String
|
||
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
sizeIndex Int
|
||
onRecord Int
|
||
confirmed Int
|
||
answeredAt DateTime @default(now())
|
||
|
||
@@unique([kitCheckId, staffId, itemId, sizeIndex])
|
||
@@index([kitCheckId, staffId])
|
||
}
|
||
|
||
/// A garment reported damaged. Two jobs: take it off the record, and start the replacement — but
|
||
/// only when the person hands it in, which is why this is a report and not a write-off.
|
||
model DamageReport {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
staffId String
|
||
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
||
issueId String?
|
||
issue Issue? @relation(fields: [issueId], references: [id], onDelete: SetNull)
|
||
kind String // Torn | Stained | Worn thin
|
||
note String @default("")
|
||
photoId String?
|
||
requestId String? // the replacement, if they asked for one
|
||
handedInAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([facilityId])
|
||
@@index([staffId])
|
||
}
|
||
|
||
/// "This isn't right" — someone saying the linen room's record of what they hold is wrong.
|
||
///
|
||
/// It needs its own model because it is the one thing a staff member can raise that has no order
|
||
/// behind it, and the product rule is one message thread per order. Rather than bend that rule
|
||
/// into a general inbox, a dispute is its own small object with its own queue on the linen-room
|
||
/// side. Nothing about the record changes until a coordinator agrees it should.
|
||
model RecordDispute {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
staffId String
|
||
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
||
itemId String?
|
||
item CatalogItem? @relation(fields: [itemId], references: [id], onDelete: SetNull)
|
||
sizeIndex Int?
|
||
body String
|
||
resolvedAt DateTime?
|
||
resolvedBy String @default("")
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([facilityId, resolvedAt])
|
||
@@index([staffId])
|
||
}
|
||
|
||
/// A short broadcast from the linen room, shown on every staff member's home screen. Not a
|
||
/// message — nobody replies to it, and it is not attached to an order.
|
||
model LinenNotice {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
body String
|
||
startsAt String @default("")
|
||
endsAt String @default("")
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([facilityId])
|
||
}
|
||
|
||
/// A staff member's own read-only account.
|
||
///
|
||
/// Deliberately NOT a User. Coordinator sessions are checked by currentUser(), and a wearer who
|
||
/// could satisfy that check would be one missed role test away from the issuing screens, the
|
||
/// catalogue and the staff register. A separate model with a separate cookie means the answer is
|
||
/// structural rather than a condition someone has to remember: a staff account cannot become a
|
||
/// coordinator session, because it is not the same kind of thing.
|
||
model StaffAccount {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
staffId String @unique
|
||
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
||
email String @unique
|
||
passwordHash String
|
||
createdAt DateTime @default(now())
|
||
lastSeenAt DateTime?
|
||
|
||
@@index([facilityId])
|
||
}
|
||
|
||
model CatalogItem {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
sort Int
|
||
item String
|
||
gender String @default("Unisex")
|
||
type String @default("") // garment type: Shirt, Pants, Jacket… drives the tops/pants split for nursing entitlement
|
||
sku String @default("")
|
||
supplier String @default("")
|
||
cost Float @default(0)
|
||
/// The staff groups this garment is for, as the facility names them. EMPTY MEANS EVERY GROUP.
|
||
/// Compared ignoring case and outer spaces; ask through garmentForGroup() in lib/compute.
|
||
groups String[] @default([])
|
||
notes String @default("")
|
||
sizes String[] @default([])
|
||
archived Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
/// The organisation catalogue item this was published from, when it was. Plain string, no
|
||
/// relation: a facility that leaves its organisation keeps the garment and loses the link.
|
||
orgItemId String?
|
||
|
||
barcodes Barcode[]
|
||
stock StockLevel[]
|
||
moves StockMove[]
|
||
issues Issue[]
|
||
orderLines OrderLine[]
|
||
receiptLines ReceiptLine[]
|
||
pickupLines PickupLine[]
|
||
stocktakeLines StocktakeLine[]
|
||
handinLines HandInLine[]
|
||
costs CostChange[]
|
||
requestLines RequestLine[]
|
||
waitlist WaitlistEntry[]
|
||
kitAnswers KitCheckAnswer[]
|
||
disputes RecordDispute[]
|
||
|
||
@@unique([facilityId, sort])
|
||
@@index([facilityId])
|
||
}
|
||
|
||
model Barcode {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
code String
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
sizeIndex Int
|
||
source String @default("bound")
|
||
|
||
@@unique([facilityId, code])
|
||
@@index([itemId])
|
||
}
|
||
|
||
// Where a garment physically lives. A tree: rooms hold shelves, shelves hold bays.
|
||
// A variant has one home location (a placement, not a split of its quantity) — counting a
|
||
// location walks its descendants. Laundry and External exist so a garment that has left the
|
||
// shelf but not the building still has somewhere to be.
|
||
model Location {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
name String
|
||
kind String @default("Shelf") // Room | Shelf | Bay | Laundry | External
|
||
parentId String?
|
||
parent Location? @relation("LocationTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||
children Location[] @relation("LocationTree")
|
||
sort Int @default(0)
|
||
archived Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
levels StockLevel[]
|
||
stocktakes Stocktake[]
|
||
|
||
@@unique([facilityId, name])
|
||
@@index([facilityId])
|
||
@@index([parentId])
|
||
}
|
||
|
||
model StockLevel {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
sizeIndex Int
|
||
opening Int @default(0)
|
||
adj Int @default(0)
|
||
reorder Int?
|
||
preloved Int @default(0) // pre-loved pool for this size: handed-in / seconds, reissued free at $0
|
||
locationId String?
|
||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||
|
||
@@unique([itemId, sizeIndex])
|
||
@@index([locationId])
|
||
@@index([facilityId])
|
||
}
|
||
|
||
model StockMove {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
date String
|
||
type String
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
sizeIndex Int
|
||
qty Int
|
||
reason String @default("")
|
||
byName String @default("")
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([facilityId])
|
||
}
|
||
|
||
model Department {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
name String
|
||
cc String @default("")
|
||
sort Int @default(0)
|
||
|
||
@@unique([facilityId, name])
|
||
}
|
||
|
||
model Staff {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
num String
|
||
first String
|
||
last String
|
||
phone String @default("")
|
||
group String @default("")
|
||
dept String @default("")
|
||
top String @default("")
|
||
pants String @default("")
|
||
/// Which cut of uniform this person is offered: "Men's", "Women's", "Either" (both), or "" when
|
||
/// nobody has said yet. Named for what it decides — which garments the counter and the staff app
|
||
/// offer — and not for anybody's gender: a woman who wears the men's cut is set to Men's and
|
||
/// nobody has to argue about the label.
|
||
///
|
||
/// BLANK MEANS EVERY STYLE, exactly as "Either" does, and blank is what every record starts as, so
|
||
/// nothing changes for anybody until a coordinator sets it. The two are kept apart in storage
|
||
/// only so the register can list who nobody has decided yet. Ask through garmentForStyle() in
|
||
/// lib/compute; normalUniformStyle() is what turns a CSV cell or an old label into one of these.
|
||
uniformStyle String @default("")
|
||
ccOverride String @default("")
|
||
inactive Boolean @default(false)
|
||
ent Int?
|
||
/// Combined FTE as the order form writes it — "1.0" down to "0.1", or "Casual". A string, and the
|
||
/// same shape as Approval.fte, because Casual is not a number and rounding it to one loses the
|
||
/// discretion the form deliberately gives the manager. Blank on every existing row, and blank has to
|
||
/// go on behaving as it does today: the table then proposes nothing at all, rather than proposing
|
||
/// a kit of nought.
|
||
fte String @default("")
|
||
start String @default("")
|
||
notes String @default("")
|
||
createdAt DateTime @default(now())
|
||
|
||
/// The one-time code a staff member types to claim their own read-only account. Globally unique
|
||
/// because it is presented before we know which facility they belong to — the code alone has to
|
||
/// identify them. Cleared once used, and stale once activateCodeAt is old enough, so a printed
|
||
/// slip left on a desk stops working whether or not anyone ever claimed it.
|
||
activateCode String? @unique
|
||
/// When that code was printed. A slip is a bearer token on paper: whoever holds it can set an
|
||
/// e-mail and password on this record, so it has to go stale on its own rather than only when
|
||
/// somebody remembers to cancel it. Also what lets the register say how old an outstanding code
|
||
/// is instead of just that there is one.
|
||
activateCodeAt DateTime?
|
||
account StaffAccount?
|
||
|
||
/// The one named person who approves this staff member's requests. Not "any manager on the
|
||
/// ward" and not a rota — a request with no manager has no addressee and cannot be raised, so
|
||
/// the linen room has to set this before self-service is any use to them.
|
||
managerId String?
|
||
manager Staff? @relation("StaffManager", fields: [managerId], references: [id], onDelete: SetNull)
|
||
reports Staff[] @relation("StaffManager")
|
||
|
||
/// Ward clerks sign for the bags the round drops at their ward. Scoped to that ward — a flag,
|
||
/// not a role, because the person on the desk changes and the register is where that is already
|
||
/// tracked.
|
||
wardDesk Boolean @default(false)
|
||
|
||
requests Request[] @relation("RequestSubject")
|
||
raisedRequests Request[] @relation("RequestRaisedByStaff")
|
||
waitlist WaitlistEntry[]
|
||
kitAnswers KitCheckAnswer[]
|
||
damageReports DamageReport[]
|
||
|
||
issues Issue[]
|
||
orders Order[]
|
||
pickups Pickup[]
|
||
/// Approvals recorded ABOUT this person — their own signed order forms.
|
||
approvals Approval[] @relation("ApprovalSubject")
|
||
/// Approvals this person signed FOR somebody else. Empty for everyone who has never approved
|
||
/// anything, and empty too for an approval whose approver was typed rather than picked out of
|
||
/// the register.
|
||
approvalsGiven Approval[] @relation("ApprovalApprover")
|
||
alterations Alteration[]
|
||
handins HandIn[]
|
||
disputes RecordDispute[]
|
||
|
||
@@unique([facilityId, num])
|
||
}
|
||
|
||
model Issue {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
date String
|
||
staffId String
|
||
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
sizeIndex Int
|
||
qty Int
|
||
cond String @default("New")
|
||
cost Float @default(0) // unit cost at the time of issue — reports never re-price history
|
||
orderCode String @default("")
|
||
receipt Boolean @default(false)
|
||
returnedDate String?
|
||
returnedCond String?
|
||
override Boolean @default(false)
|
||
/// Issued outside the person's staff group on the coordinator's override. Kept apart from
|
||
/// `override`, which is the six-set ceiling's, so the record can tell the two apart.
|
||
offGroup Boolean @default(false)
|
||
/// Issued outside the person's uniform style on the coordinator's override — a men's cut to
|
||
/// somebody set to Women's, or the other way about. Its own flag beside offGroup and `override`
|
||
/// so the record says which rule was overridden, not merely that one was.
|
||
offStyle Boolean @default(false)
|
||
direct Boolean @default(false)
|
||
preloved Boolean @default(false) // issued free from the pre-loved pool: no cost, entitlement, approval or ledger effect
|
||
handedIn String? // date the garment came back via a hand-in (informational)
|
||
returnPhotoId String? // damage evidence photo attached to a return
|
||
createdAt DateTime @default(now())
|
||
|
||
damageReports DamageReport[]
|
||
|
||
@@index([facilityId, date])
|
||
@@index([staffId])
|
||
}
|
||
|
||
model Order {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
code String
|
||
date String
|
||
source String @default("Supplier Order")
|
||
orderFor String @default("Stock")
|
||
staffId String?
|
||
staff Staff? @relation(fields: [staffId], references: [id], onDelete: SetNull)
|
||
supplier String @default("")
|
||
status String @default("Draft")
|
||
ref String @default("")
|
||
invoice String @default("")
|
||
tracking String @default("")
|
||
expected String @default("")
|
||
received String @default("")
|
||
cc String @default("")
|
||
notes String @default("")
|
||
replenish Boolean @default(false)
|
||
parentId String? // back orders point at the order they were split from
|
||
parent Order? @relation("BackOrder", fields: [parentId], references: [id], onDelete: SetNull)
|
||
backOrders Order[] @relation("BackOrder")
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
lines OrderLine[]
|
||
receipts Receipt[]
|
||
pickups Pickup[]
|
||
|
||
@@unique([facilityId, code])
|
||
@@index([facilityId])
|
||
}
|
||
|
||
model OrderLine {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
size String
|
||
qty Int
|
||
sort Int @default(0)
|
||
|
||
@@index([orderId])
|
||
}
|
||
|
||
model Receipt {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
date String
|
||
invoice String @default("")
|
||
note String @default("")
|
||
photoId String? // photo of the supplier invoice
|
||
createdAt DateTime @default(now())
|
||
|
||
lines ReceiptLine[]
|
||
|
||
@@index([orderId])
|
||
}
|
||
|
||
model ReceiptLine {
|
||
id String @id @default(cuid())
|
||
receiptId String
|
||
receipt Receipt @relation(fields: [receiptId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
size String
|
||
qty Int
|
||
dest String @default("shelf")
|
||
cost Float @default(0)
|
||
|
||
@@index([receiptId])
|
||
}
|
||
|
||
model Pickup {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
orderId String
|
||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
staffId String
|
||
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
||
received String
|
||
contacted Boolean @default(false)
|
||
pickedUp String?
|
||
deliveredTo String @default("") // delivery-round proof: who signed for it
|
||
sigId String? // signature PNG (Photo)
|
||
proofId String? // handover photo (Photo)
|
||
deliveredRound Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
lines PickupLine[]
|
||
|
||
@@index([facilityId])
|
||
}
|
||
|
||
model PickupLine {
|
||
id String @id @default(cuid())
|
||
pickupId String
|
||
pickup Pickup @relation(fields: [pickupId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
size String
|
||
qty Int
|
||
|
||
@@index([pickupId])
|
||
}
|
||
|
||
model Stocktake {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
date String
|
||
byName String
|
||
counted Int
|
||
variances Int
|
||
mode String @default("shelf") // shelf | preloved
|
||
locationId String? // the location this count was scoped to; null = the whole linen room
|
||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||
createdAt DateTime @default(now())
|
||
|
||
lines StocktakeLine[]
|
||
|
||
@@index([facilityId])
|
||
}
|
||
|
||
model StocktakeLine {
|
||
id String @id @default(cuid())
|
||
stocktakeId String
|
||
stocktake Stocktake @relation(fields: [stocktakeId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
sizeIndex Int
|
||
sys Int
|
||
counted Int
|
||
reason String @default("") // why the gap: At laundry | Condemned | Missing | Other
|
||
|
||
@@index([stocktakeId])
|
||
}
|
||
|
||
// Uniform hand-ins: good garments join the pre-loved pool, rags are tallied for disposal.
|
||
model HandIn {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
date String
|
||
staffId String
|
||
staff Staff @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
||
byName String
|
||
credit Boolean @default(false) // coordinator chose to credit the staff member's allowance
|
||
createdAt DateTime @default(now())
|
||
|
||
lines HandInLine[]
|
||
|
||
@@index([facilityId])
|
||
@@index([staffId])
|
||
}
|
||
|
||
model HandInLine {
|
||
id String @id @default(cuid())
|
||
handInId String
|
||
handIn HandIn @relation(fields: [handInId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item CatalogItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
sizeIndex Int
|
||
qty Int
|
||
cond String @default("Good") // Good | Rag
|
||
laundered Boolean @default(true)
|
||
credited Int @default(0) // qty that earned allowance credit (matched a real, un-returned shelf issue)
|
||
|
||
@@index([handInId])
|
||
}
|
||
|
||
// Camera captures and signatures, stored server-side (JPEG/PNG data URLs, downscaled on the device to <= 900px).
|
||
// Only ids travel in the snapshot; the bytes are served by /api/photo/[id].
|
||
/// A signature or a photograph.
|
||
///
|
||
/// `data` used to hold the whole image as a base64 data URL, which put every picture in the
|
||
/// database and therefore in every dump. Images now live on disk and this row keeps only a
|
||
/// pointer. `data` stays for rows written before the move — they are read from it and rewritten to
|
||
/// disk by scripts/photos-to-disk.cjs — and is empty for everything new.
|
||
model Photo {
|
||
id String @id @default(cuid())
|
||
facilityId String
|
||
facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
|
||
kind String // approval | receipt | return | sig | proof
|
||
data String @default("")
|
||
path String @default("")
|
||
mime String @default("")
|
||
bytes Int @default(0)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([facilityId])
|
||
}
|
||
|
||
/// Messages from the public contact form. Not tied to a Facility — the sender usually has no account
|
||
/// yet. Read from the server; nothing in the app exposes these to a facility.
|
||
model ContactMessage {
|
||
id String @id @default(cuid())
|
||
name String
|
||
role String @default("")
|
||
facility String @default("")
|
||
email String
|
||
topic String @default("")
|
||
slot String @default("")
|
||
message String
|
||
ip String @default("")
|
||
emailed Boolean @default(false)
|
||
handled Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([createdAt])
|
||
}
|