Mirrors the Place-of-Delivery (#19) pattern: an admin clause library that feeds the PO T&C fields as dropdowns. (No "work order" type — POs only, per steer.) - schema + migration: TermsCondition (category enum + text + isActive); the migration seeds the prior TC_DEFAULTS as the starting clauses. - permission manage_terms (Manager + SuperUser + Admin). - admin screen /admin/terms: table + Add/Edit dialogs + activate/deactivate + delete (mirrors /admin/delivery-locations); sidebar link under Administration. - PO forms (new / edit / manager-edit): the five named T&C slots (Delivery / Dispatch / Inspection / Transit Insurance / Payment Terms) become a shared <TermsField> select sourced from active clauses of that category; "Others" stays free text; the fixed boilerplate lines are untouched. - tc* columns stay free-text SNAPSHOTS (export/import unchanged); a current value not among active clauses is preserved as a "(current)" option. - tests: terms CRUD + permission guard + grouping helper (6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1154 lines
38 KiB
Text
1154 lines
38 KiB
Text
generator client {
|
||
provider = "prisma-client-js"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
url = env("DATABASE_URL")
|
||
}
|
||
|
||
enum Role {
|
||
TECHNICAL
|
||
MANNING
|
||
ACCOUNTS
|
||
MANAGER
|
||
SUPERUSER
|
||
AUDITOR
|
||
ADMIN
|
||
SITE_STAFF
|
||
}
|
||
|
||
enum POStatus {
|
||
DRAFT
|
||
SUBMITTED
|
||
MGR_REVIEW
|
||
VENDOR_ID_PENDING
|
||
EDITS_REQUESTED
|
||
REJECTED
|
||
MGR_APPROVED
|
||
SENT_FOR_PAYMENT
|
||
PARTIALLY_PAID
|
||
PAID_DELIVERED
|
||
PARTIALLY_CLOSED
|
||
CLOSED
|
||
CANCELLED
|
||
}
|
||
|
||
enum ActionType {
|
||
CREATED
|
||
SUBMITTED
|
||
APPROVED
|
||
APPROVED_WITH_NOTE
|
||
REJECTED
|
||
EDITS_REQUESTED
|
||
VENDOR_ID_REQUESTED
|
||
VENDOR_ID_PROVIDED
|
||
PAYMENT_SENT
|
||
PARTIAL_PAYMENT_CONFIRMED
|
||
RECEIPT_CONFIRMED
|
||
PARTIAL_RECEIPT_CONFIRMED
|
||
CLOSED
|
||
REASSIGNED
|
||
PRODUCT_PRICE_UPDATED
|
||
MANAGER_LINE_EDIT
|
||
CANCELLED
|
||
SUPERSEDED
|
||
}
|
||
|
||
enum RequestStatus {
|
||
PENDING
|
||
APPROVED
|
||
DENIED
|
||
}
|
||
|
||
// ─── Crewing (feature-flagged: NEXT_PUBLIC_CREWING_ENABLED) ──────────────────
|
||
// Phase 1 (Foundations) lands only the reference-data layer. The lifecycle
|
||
// models/enums (Requisition, Application, Assignment, …) arrive in later phases.
|
||
// See wiki Crewing-Implementation-Spec §12.
|
||
|
||
// Org-chart grouping for a Rank. Drives reporting/segmentation, not login.
|
||
enum RankCategory {
|
||
OPERATIONAL
|
||
SUPPORT
|
||
}
|
||
|
||
// The seafarer/crew document set a rank may be required to hold. Drives
|
||
// candidate vetting and crew uploads via RankDocRequirement.
|
||
enum SeafarerDocType {
|
||
STCW
|
||
AADHAAR
|
||
PAN
|
||
PASSPORT
|
||
CDC
|
||
COC
|
||
PHOTOGRAPH
|
||
DRIVING_LICENSE
|
||
MEDICAL_FITNESS
|
||
CONTRACT_LETTER
|
||
}
|
||
|
||
// ─── Crewing lifecycle (Phase 2: Requisitions + relief) ─────────────────────
|
||
// Requisition lifecycle — Crewing-Implementation-Spec §5.2. The intermediate
|
||
// stages (SHORTLISTING…SELECTED) are advanced by the recruitment pipeline that
|
||
// lands in Phase 3; Phase 2 wires OPEN, CANCELLED and the FILLED terminal.
|
||
enum RequisitionStatus {
|
||
OPEN
|
||
SHORTLISTING
|
||
PROPOSING
|
||
INTERVIEWING
|
||
SELECTED
|
||
FILLED
|
||
CANCELLED
|
||
}
|
||
|
||
// Why a vacancy exists. LEAVE / SIGN_OFF / END_OF_CONTRACT are the system
|
||
// auto-raise reasons (§5.2/§5.3); the rest are raised manually by MPO/Manager.
|
||
enum RequisitionReason {
|
||
NEW_VACANCY
|
||
REPLACEMENT
|
||
LEAVE
|
||
SIGN_OFF
|
||
END_OF_CONTRACT
|
||
OTHER
|
||
}
|
||
|
||
// A foreseen-gap flag raised by site staff (§8.2 "Relief requests from sites").
|
||
// The office converts an OPEN relief request into a real requisition.
|
||
enum ReliefRequestStatus {
|
||
OPEN
|
||
CONVERTED
|
||
CANCELLED
|
||
}
|
||
|
||
// Crewing audit-trail action types — the CrewAction mirror of ActionType for
|
||
// POAction (§4.5/§11). Extended per phase; Phase 2 covers requisition + relief,
|
||
// Phase 3a adds candidate intake.
|
||
enum CrewActionType {
|
||
REQUISITION_RAISED
|
||
REQUISITION_ADVANCED
|
||
REQUISITION_FILLED
|
||
REQUISITION_CANCELLED
|
||
RELIEF_REQUESTED
|
||
RELIEF_CONVERTED
|
||
RELIEF_CANCELLED
|
||
CANDIDATE_ADDED
|
||
CANDIDATE_UPDATED
|
||
APPLICATION_CREATED
|
||
GATE_PASSED
|
||
REFERENCE_RECORDED
|
||
SALARY_AGREED
|
||
SALARY_APPROVED
|
||
SALARY_RETURNED
|
||
CANDIDATE_PROPOSED
|
||
INTERVIEW_RECORDED
|
||
WAIVER_REQUESTED
|
||
WAIVER_APPROVED
|
||
WAIVER_DECLINED
|
||
CANDIDATE_SELECTED
|
||
SELECTION_RETURNED
|
||
APPLICATION_REJECTED
|
||
CREW_ONBOARDED
|
||
DOCUMENT_UPLOADED
|
||
RECORD_UPDATED
|
||
RECORD_DELETED
|
||
PPE_ISSUED
|
||
PPE_RETURNED
|
||
EXPERIENCE_ADDED
|
||
LEAVE_APPLIED
|
||
LEAVE_DECIDED
|
||
ATTENDANCE_RECORDED
|
||
CREW_SIGNED_OFF
|
||
RECORD_VERIFIED
|
||
RECORD_REJECTED
|
||
APPRAISAL_SUBMITTED
|
||
APPRAISAL_VERIFIED
|
||
APPRAISAL_APPROVED
|
||
APPRAISAL_REJECTED
|
||
}
|
||
|
||
// ─── Crewing appraisal (Phase 5b, Epic H) ───────────────────────────────────
|
||
// Appraisal lifecycle (Crewing-Implementation-Spec §5.4/§8.14): a PM raises
|
||
// (→ SUBMITTED), the MPO verifies (→ MPO_VERIFIED), the Manager approves
|
||
// (→ MANAGER_APPROVED); → REJECTED with remarks from either review.
|
||
enum AppraisalStatus {
|
||
DRAFT
|
||
SUBMITTED
|
||
MPO_VERIFIED
|
||
MANAGER_APPROVED
|
||
REJECTED
|
||
}
|
||
|
||
// ─── Crewing leave & attendance (Phase 4b, Epic G) ──────────────────────────
|
||
// Leave is applied by the Site In-charge on a crew member and decided by the
|
||
// Manager (the MPO has no leave role — R1). See Crewing-Data-Model §1/§4.
|
||
enum LeaveType {
|
||
ANNUAL
|
||
MEDICAL
|
||
EMERGENCY
|
||
UNPAID
|
||
OTHER
|
||
}
|
||
|
||
enum LeaveStatus {
|
||
APPLIED
|
||
APPROVED
|
||
REJECTED
|
||
CANCELLED
|
||
}
|
||
|
||
// Daily attendance (§8.10). v1 is the daily model; hours/overtime is deferred (A7).
|
||
enum AttendanceStatus {
|
||
PRESENT
|
||
ABSENT
|
||
HALF_DAY
|
||
ON_LEAVE
|
||
SIGN_OFF
|
||
}
|
||
|
||
// PPE kit items issued to crew (Phase 4a, Epic F). See Crewing-Data-Model §1.
|
||
enum PpeItem {
|
||
BOILER_SUIT
|
||
SAFETY_SHOES
|
||
HELMET
|
||
VEST
|
||
GLOVES
|
||
MASK
|
||
GOGGLES
|
||
TIFFIN
|
||
TORCH
|
||
WALKIE_TALKIE
|
||
}
|
||
|
||
// ─── Crewing recruitment pipeline (Phase 3b: Epic C) ────────────────────────
|
||
// The gated 7-stage application pipeline (Crewing-Implementation-Spec §5.1).
|
||
// ONBOARDED is the terminal system state set at onboarding (Phase 3c);
|
||
// REJECTED is the branch reachable from any active stage.
|
||
enum ApplicationStage {
|
||
SHORTLISTED
|
||
COMPETENCY_AND_REFERENCES
|
||
DOC_VERIFICATION
|
||
SALARY_AGREEMENT
|
||
PROPOSED
|
||
INTERVIEW
|
||
SELECTED
|
||
REJECTED
|
||
ONBOARDED
|
||
}
|
||
|
||
// A vetting gate on an application. SALARY / SELECTION / WAIVER are the
|
||
// Manager-decided gates that surface in the central Approvals queue (§8.13).
|
||
enum ApplicationGateType {
|
||
COMPETENCY_REFERENCE
|
||
DOCUMENT
|
||
SALARY
|
||
INTERVIEW
|
||
WAIVER
|
||
SELECTION
|
||
}
|
||
|
||
enum GateResult {
|
||
PENDING
|
||
VERIFIED
|
||
REJECTED
|
||
}
|
||
|
||
// MPO's recorded interview outcome (Manager then approves selection).
|
||
enum InterviewOutcome {
|
||
PENDING
|
||
ACCEPTED
|
||
REJECTED
|
||
}
|
||
|
||
// Salary capture basis — the other is derived (R10/A4). Effective-dated.
|
||
enum SalaryRateBasis {
|
||
MONTHLY
|
||
DAILY
|
||
}
|
||
|
||
// A crew member's tour of duty (Phase 3c, Epic D). Created at onboarding; the
|
||
// leave/sign-off transitions land in Phase 4. See Crewing-Data-Model §4.
|
||
enum AssignmentStatus {
|
||
ACTIVE
|
||
ON_LEAVE
|
||
SIGNED_OFF
|
||
}
|
||
|
||
// ─── Crewing candidates (Phase 3a: Epic B) ──────────────────────────────────
|
||
// A CrewMember is the talent-pool spine: a row exists from first contact and
|
||
// persists through CANDIDATE → EMPLOYEE → EX_HAND. `employeeId` is assigned only
|
||
// at onboarding (Phase 3c). See Crewing-Data-Model §4 + Implementation-Spec §8.6.
|
||
enum CrewStatus {
|
||
PROSPECT
|
||
CANDIDATE
|
||
EMPLOYEE
|
||
EX_HAND
|
||
BLACKLISTED
|
||
}
|
||
|
||
// NEW applicants vs returning EX_HAND crew (drives the ex-hand affordances).
|
||
enum CandidateType {
|
||
NEW
|
||
EX_HAND
|
||
}
|
||
|
||
// Where the candidate came from (the §8.6 "Source" column; ex-hand renders purple).
|
||
enum CandidateSource {
|
||
CAREERS
|
||
EX_HAND
|
||
WALK_IN
|
||
REFERRAL
|
||
OTHER
|
||
}
|
||
|
||
model User {
|
||
id String @id @default(cuid())
|
||
employeeId String @unique
|
||
email String @unique
|
||
name String
|
||
passwordHash String?
|
||
role Role
|
||
isActive Boolean @default(true)
|
||
signatureKey String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
submittedPOs PurchaseOrder[] @relation("Submitter")
|
||
actions POAction[]
|
||
notifications Notification[]
|
||
consumption ItemConsumption[]
|
||
superUserRequests SuperUserRequest[] @relation("Requester")
|
||
resolvedRequests SuperUserRequest[] @relation("RequestResolver")
|
||
requisitionsRaised Requisition[] @relation("RequisitionRaiser")
|
||
reliefRequested ReliefRequest[] @relation("ReliefRequester")
|
||
crewActions CrewAction[]
|
||
|
||
// Site-staff home site (Crewing §8.7 own-site scoping). Null = unscoped.
|
||
siteId String?
|
||
site Site? @relation(fields: [siteId], references: [id])
|
||
}
|
||
|
||
model SuperUserRequest {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation("Requester", fields: [userId], references: [id])
|
||
reason String?
|
||
status RequestStatus @default(PENDING)
|
||
createdAt DateTime @default(now())
|
||
resolvedAt DateTime?
|
||
resolvedById String?
|
||
resolvedBy User? @relation("RequestResolver", fields: [resolvedById], references: [id])
|
||
}
|
||
|
||
model Site {
|
||
id String @id @default(cuid())
|
||
name String
|
||
code String @unique
|
||
address String?
|
||
latitude Float?
|
||
longitude Float?
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
purchaseOrders PurchaseOrder[]
|
||
inventory ItemInventory[]
|
||
consumption ItemConsumption[]
|
||
requisitions Requisition[]
|
||
reliefRequests ReliefRequest[]
|
||
assignments CrewAssignment[]
|
||
staff User[]
|
||
}
|
||
|
||
model Vessel {
|
||
id String @id @default(cuid())
|
||
name String
|
||
code String @unique
|
||
isActive Boolean @default(true)
|
||
|
||
purchaseOrders PurchaseOrder[]
|
||
requisitions Requisition[]
|
||
reliefRequests ReliefRequest[]
|
||
assignments CrewAssignment[]
|
||
rankRequirements VesselRankRequirement[]
|
||
}
|
||
|
||
model Company {
|
||
id String @id @default(cuid())
|
||
name String
|
||
code String? @unique
|
||
gstNumber String?
|
||
address String?
|
||
telephone String?
|
||
mobile String?
|
||
email String?
|
||
invoiceEmail String?
|
||
invoiceAddress String?
|
||
logoKey String? // storage key for uploaded logo image (top of exported POs)
|
||
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
purchaseOrders PurchaseOrder[]
|
||
deliveryLocations DeliveryLocation[]
|
||
}
|
||
|
||
// Admin-managed delivery destinations (issue #19). Each is a Company + a
|
||
// free-text address; the PO "Place of Delivery" field becomes a dropdown sourced
|
||
// from these. The PO stores the resolved text snapshot in
|
||
// PurchaseOrder.placeOfDelivery (point-in-time document), so deleting/editing a
|
||
// location never rewrites historical POs. Managed by manage_delivery_locations.
|
||
model DeliveryLocation {
|
||
id String @id @default(cuid())
|
||
companyId String
|
||
company Company @relation(fields: [companyId], references: [id])
|
||
address String
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([companyId])
|
||
}
|
||
|
||
// Admin-managed Terms & Conditions clauses (issue #11). Each clause belongs to a
|
||
// category matching one of the PO's named T&C slots; the PO form turns those slots
|
||
// into dropdowns sourced from the active clauses of that category. The PO keeps
|
||
// the chosen clause as a text snapshot in its tc* columns (point-in-time
|
||
// document), so editing/removing a clause never rewrites historical POs. Managed
|
||
// by manage_terms. ("Others" stays free text; the fixed boilerplate lines —
|
||
// TC_FIXED_LINE / TC_FIXED_LINE_2 — are not catalogued.)
|
||
enum TermsCategory {
|
||
DELIVERY
|
||
DISPATCH
|
||
INSPECTION
|
||
TRANSIT_INSURANCE
|
||
PAYMENT_TERMS
|
||
}
|
||
|
||
model TermsCondition {
|
||
id String @id @default(cuid())
|
||
category TermsCategory
|
||
text String
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([category])
|
||
}
|
||
|
||
model Account {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
name String
|
||
description String?
|
||
isActive Boolean @default(true)
|
||
|
||
parentId String?
|
||
parent Account? @relation("AccountHierarchy", fields: [parentId], references: [id])
|
||
children Account[] @relation("AccountHierarchy")
|
||
|
||
purchaseOrders PurchaseOrder[]
|
||
lineItems POLineItem[]
|
||
}
|
||
|
||
model VendorContact {
|
||
id String @id @default(cuid())
|
||
name String
|
||
role String?
|
||
mobile String?
|
||
email String?
|
||
isPrimary Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
vendorId String
|
||
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Cascade)
|
||
}
|
||
|
||
model Vendor {
|
||
id String @id @default(cuid())
|
||
name String
|
||
vendorId String? @unique
|
||
address String?
|
||
pincode String?
|
||
gstin String?
|
||
latitude Float?
|
||
longitude Float?
|
||
isVerified Boolean @default(false)
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
|
||
contacts VendorContact[]
|
||
purchaseOrders PurchaseOrder[]
|
||
products Product[] @relation("ProductLastVendor")
|
||
vendorPrices ProductVendorPrice[]
|
||
}
|
||
|
||
model Product {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
name String
|
||
description String?
|
||
lastPrice Decimal? @db.Decimal(12, 2)
|
||
lastVendorId String?
|
||
lastVendor Vendor? @relation("ProductLastVendor", fields: [lastVendorId], references: [id])
|
||
isActive Boolean @default(true)
|
||
updatedAt DateTime @updatedAt
|
||
createdAt DateTime @default(now())
|
||
|
||
lineItems POLineItem[]
|
||
vendorPrices ProductVendorPrice[]
|
||
inventory ItemInventory[]
|
||
consumption ItemConsumption[]
|
||
}
|
||
|
||
model ProductVendorPrice {
|
||
id String @id @default(cuid())
|
||
price Decimal @db.Decimal(12, 2)
|
||
updatedAt DateTime @updatedAt
|
||
|
||
productId String
|
||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||
vendorId String
|
||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||
|
||
@@unique([productId, vendorId])
|
||
}
|
||
|
||
model ItemInventory {
|
||
id String @id @default(cuid())
|
||
quantity Decimal @db.Decimal(10, 3)
|
||
updatedAt DateTime @updatedAt
|
||
|
||
productId String
|
||
product Product @relation(fields: [productId], references: [id])
|
||
siteId String
|
||
site Site @relation(fields: [siteId], references: [id])
|
||
|
||
@@unique([productId, siteId])
|
||
}
|
||
|
||
model ItemConsumption {
|
||
id String @id @default(cuid())
|
||
date DateTime @db.Date
|
||
quantity Decimal @db.Decimal(10, 3)
|
||
note String?
|
||
|
||
productId String
|
||
product Product @relation(fields: [productId], references: [id])
|
||
siteId String
|
||
site Site @relation(fields: [siteId], references: [id])
|
||
recordedById String
|
||
recordedBy User @relation(fields: [recordedById], references: [id])
|
||
|
||
@@unique([productId, siteId, date])
|
||
}
|
||
|
||
model PurchaseOrder {
|
||
id String @id @default(cuid())
|
||
poNumber String @unique
|
||
title String
|
||
status POStatus @default(DRAFT)
|
||
totalAmount Decimal @db.Decimal(12, 2)
|
||
currency String @default("INR")
|
||
dateRequired DateTime?
|
||
projectCode String?
|
||
managerNote String?
|
||
paymentRef String?
|
||
paymentDate DateTime?
|
||
paidAmount Decimal? @db.Decimal(12, 2)
|
||
// Advance the approving Manager wants paid first (absolute amount, not %).
|
||
// The approval slider (0–100% of totalAmount) is convenience only — the
|
||
// resolved amount is stored here. Null on legacy/pre-feature POs ⇒ no explicit
|
||
// advance, so Accounts defaults to the full remaining balance. Set once at
|
||
// approval and not edited afterwards (issue #92).
|
||
//
|
||
// NOTE (issue #91): this IS the "exact sum due for payment" for an ADVANCE/PART
|
||
// request. When the structured payment-request lane (payment-term enum +
|
||
// separate approval) is built, reuse this column for the requested amount
|
||
// rather than adding a parallel "exact sum" field.
|
||
suggestedAdvancePayment Decimal? @db.Decimal(12, 2)
|
||
piQuotationNo String?
|
||
piQuotationDate DateTime?
|
||
requisitionNo String?
|
||
requisitionDate DateTime?
|
||
placeOfDelivery String?
|
||
tcDelivery String?
|
||
tcDispatch String?
|
||
tcInspection String?
|
||
tcTransitInsurance String?
|
||
tcPaymentTerms String?
|
||
tcOthers String?
|
||
poDate DateTime?
|
||
submittedAt DateTime?
|
||
approvedAt DateTime?
|
||
paidAt DateTime?
|
||
closedAt DateTime?
|
||
cancelledAt DateTime?
|
||
cancellationReason String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
submitterId String
|
||
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
||
vesselId String
|
||
vessel Vessel @relation(fields: [vesselId], references: [id])
|
||
accountId String
|
||
account Account @relation(fields: [accountId], references: [id])
|
||
companyId String?
|
||
company Company? @relation(fields: [companyId], references: [id])
|
||
vendorId String?
|
||
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
||
siteId String?
|
||
site Site? @relation(fields: [siteId], references: [id])
|
||
|
||
// Supersede: a cancelled PO may be linked to the existing PO that replaces it.
|
||
// `supersededBy` is that replacement; `supersedes` is the reciprocal list.
|
||
supersededById String?
|
||
supersededBy PurchaseOrder? @relation("Supersede", fields: [supersededById], references: [id])
|
||
supersedes PurchaseOrder[] @relation("Supersede")
|
||
|
||
lineItems POLineItem[]
|
||
documents PODocument[]
|
||
actions POAction[]
|
||
receipt Receipt?
|
||
notifications Notification[]
|
||
}
|
||
|
||
model POLineItem {
|
||
id String @id @default(cuid())
|
||
name String
|
||
description String?
|
||
quantity Decimal @db.Decimal(10, 3)
|
||
unit String
|
||
unitPrice Decimal @db.Decimal(12, 2)
|
||
totalPrice Decimal @db.Decimal(12, 2)
|
||
gstRate Decimal @default(0.18) @db.Decimal(5, 4)
|
||
sortOrder Int @default(0)
|
||
size String?
|
||
deliveredQuantity Decimal? @db.Decimal(10, 3)
|
||
productId String?
|
||
product Product? @relation(fields: [productId], references: [id])
|
||
accountId String?
|
||
account Account? @relation(fields: [accountId], references: [id])
|
||
|
||
poId String
|
||
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
||
}
|
||
|
||
model PODocument {
|
||
id String @id @default(cuid())
|
||
fileName String
|
||
fileSize Int
|
||
mimeType String
|
||
storageKey String
|
||
uploadedAt DateTime @default(now())
|
||
|
||
poId String
|
||
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
||
}
|
||
|
||
model POAction {
|
||
id String @id @default(cuid())
|
||
actionType ActionType
|
||
note String?
|
||
metadata Json?
|
||
createdAt DateTime @default(now())
|
||
|
||
poId String
|
||
po PurchaseOrder @relation(fields: [poId], references: [id])
|
||
actorId String
|
||
actor User @relation(fields: [actorId], references: [id])
|
||
}
|
||
|
||
model Receipt {
|
||
id String @id @default(cuid())
|
||
storageKey String
|
||
fileName String
|
||
notes String?
|
||
confirmedAt DateTime @default(now())
|
||
|
||
poId String @unique
|
||
po PurchaseOrder @relation(fields: [poId], references: [id])
|
||
}
|
||
|
||
model Notification {
|
||
id String @id @default(cuid())
|
||
subject String
|
||
body String
|
||
link String?
|
||
isRead Boolean @default(false)
|
||
sentAt DateTime @default(now())
|
||
status String @default("sent")
|
||
|
||
poId String?
|
||
po PurchaseOrder? @relation(fields: [poId], references: [id])
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id])
|
||
}
|
||
|
||
// ─── Crewing reference data ──────────────────────────────────────────────────
|
||
|
||
// The crew org hierarchy. A self-referential tree (parent/children), exactly
|
||
// like the Account accounting-code hierarchy. Reference data managed at
|
||
// /admin/ranks. `grantsLogin` is true only for the three management ranks
|
||
// (PM, Assistant PM, Site In-charge) — every other rank is a crew member /
|
||
// data subject with no portal account. See Crewing-Data-Model §2.
|
||
model Rank {
|
||
id String @id @default(cuid())
|
||
code String @unique
|
||
name String
|
||
description String?
|
||
category RankCategory @default(OPERATIONAL)
|
||
isSeafarer Boolean @default(false)
|
||
grantsLogin Boolean @default(false)
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
parentId String?
|
||
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
|
||
children Rank[] @relation("RankHierarchy")
|
||
|
||
docRequirements RankDocRequirement[]
|
||
requisitions Requisition[]
|
||
reliefRequests ReliefRequest[]
|
||
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
|
||
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
|
||
assignments CrewAssignment[]
|
||
experienceRecords ExperienceRecord[]
|
||
vesselRequirements VesselRankRequirement[]
|
||
}
|
||
|
||
// Which documents a rank is required (or conditionally required) to hold.
|
||
// `isMandatory = false` is the "conditional" tag in the UI.
|
||
model RankDocRequirement {
|
||
id String @id @default(cuid())
|
||
rankId String
|
||
rank Rank @relation(fields: [rankId], references: [id], onDelete: Cascade)
|
||
docType SeafarerDocType
|
||
isMandatory Boolean @default(true)
|
||
note String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([rankId, docType])
|
||
}
|
||
|
||
// ─── Crewing lifecycle models (Phase 2) ──────────────────────────────────────
|
||
|
||
// A vacancy to be filled for a rank on a vessel/site. Raised manually by
|
||
// MPO/Manager, or auto-raised by the system on a leave clash / sign-off / EOC
|
||
// (autoRaised = true). The recruitment pipeline (Phase 3) attaches candidates
|
||
// and drives the intermediate stages. See Crewing-Implementation-Spec §5.2/§8.
|
||
model Requisition {
|
||
id String @id @default(cuid())
|
||
code String @unique // mono id, e.g. REQ-9000
|
||
status RequisitionStatus @default(OPEN)
|
||
reason RequisitionReason @default(NEW_VACANCY)
|
||
autoRaised Boolean @default(false)
|
||
neededBy DateTime?
|
||
notes String?
|
||
cancelledAt DateTime?
|
||
cancellationReason String?
|
||
filledAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
rankId String
|
||
rank Rank @relation(fields: [rankId], references: [id])
|
||
vesselId String?
|
||
vessel Vessel? @relation(fields: [vesselId], references: [id])
|
||
siteId String?
|
||
site Site? @relation(fields: [siteId], references: [id])
|
||
|
||
// Null when the system auto-raised it.
|
||
raisedById String?
|
||
raisedBy User? @relation("RequisitionRaiser", fields: [raisedById], references: [id])
|
||
|
||
// The site relief request this requisition was converted from, if any.
|
||
sourceReliefRequest ReliefRequest? @relation("ReliefConversion")
|
||
|
||
actions CrewAction[]
|
||
applications Application[]
|
||
assignment CrewAssignment?
|
||
}
|
||
|
||
// A foreseen-gap flag from a site (site staff), pending office conversion into a
|
||
// Requisition. Complementary, proactive channel to the auto-raised LEAVE
|
||
// requisition. See Crewing-Implementation-Spec §8.2 (R3/R6).
|
||
model ReliefRequest {
|
||
id String @id @default(cuid())
|
||
status ReliefRequestStatus @default(OPEN)
|
||
note String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
rankId String
|
||
rank Rank @relation(fields: [rankId], references: [id])
|
||
vesselId String?
|
||
vessel Vessel? @relation(fields: [vesselId], references: [id])
|
||
siteId String?
|
||
site Site? @relation(fields: [siteId], references: [id])
|
||
|
||
requestedById String
|
||
requestedBy User @relation("ReliefRequester", fields: [requestedById], references: [id])
|
||
|
||
// Set when an MPO/Manager converts it; one relief request → one requisition.
|
||
convertedRequisitionId String? @unique
|
||
convertedRequisition Requisition? @relation("ReliefConversion", fields: [convertedRequisitionId], references: [id])
|
||
}
|
||
|
||
// Crewing audit trail — one row per transition / verification (mirror of
|
||
// POAction). Entity relations are added per phase; Phase 2 links requisitions,
|
||
// Phase 3a adds candidates. A row references at most one entity (the rest null).
|
||
model CrewAction {
|
||
id String @id @default(cuid())
|
||
actionType CrewActionType
|
||
note String?
|
||
metadata Json?
|
||
createdAt DateTime @default(now())
|
||
|
||
// Null for system-performed actions (auto-raise).
|
||
actorId String?
|
||
actor User? @relation(fields: [actorId], references: [id])
|
||
|
||
requisitionId String?
|
||
requisition Requisition? @relation(fields: [requisitionId], references: [id])
|
||
crewMemberId String?
|
||
crewMember CrewMember? @relation(fields: [crewMemberId], references: [id])
|
||
applicationId String?
|
||
application Application? @relation(fields: [applicationId], references: [id])
|
||
}
|
||
|
||
// The talent-pool spine (Phase 3a, Epic B). One row per person, created the
|
||
// moment they enter the pool and kept through CANDIDATE → EMPLOYEE → EX_HAND, so
|
||
// an ex-hand's history/documents are already on file. `employeeId` is assigned
|
||
// at onboarding (Phase 3c). The recruitment pipeline (Applications, Phase 3b)
|
||
// and crew records (Phase 4) hang off this model. See Crewing-Data-Model §4.
|
||
model CrewMember {
|
||
id String @id @default(cuid())
|
||
employeeId String? @unique // assigned at onboarding (Phase 3c)
|
||
name String
|
||
status CrewStatus @default(CANDIDATE)
|
||
type CandidateType @default(NEW)
|
||
source CandidateSource @default(CAREERS)
|
||
email String?
|
||
phone String?
|
||
dob DateTime?
|
||
experienceMonths Int @default(0)
|
||
vesselTypeExperience String? // free-text "vessel type" from the Add-candidate modal
|
||
cvKey String? // storage key for an uploaded CV (no parsing yet — A2 deferred)
|
||
notes String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Rank held / last held (ex-hands) and the rank being applied for.
|
||
currentRankId String?
|
||
currentRank Rank? @relation("CrewCurrentRank", fields: [currentRankId], references: [id])
|
||
appliedRankId String?
|
||
appliedRank Rank? @relation("CrewAppliedRank", fields: [appliedRankId], references: [id])
|
||
|
||
actions CrewAction[]
|
||
applications Application[]
|
||
bankDetail BankDetail?
|
||
epfDetail EpfDetail?
|
||
assignments CrewAssignment[]
|
||
documents SeafarerDocument[]
|
||
nextOfKin NextOfKin[]
|
||
experienceRecords ExperienceRecord[]
|
||
ppeIssues PpeIssue[]
|
||
}
|
||
|
||
// ─── Crewing recruitment pipeline models (Phase 3b) ─────────────────────────
|
||
|
||
// A candidate's application against one requisition — the gated pipeline spine
|
||
// (spec §5.1/§8.4–8.5). One application per (requisition, candidate).
|
||
model Application {
|
||
id String @id @default(cuid())
|
||
stage ApplicationStage @default(SHORTLISTED)
|
||
type CandidateType @default(NEW)
|
||
interviewResult InterviewOutcome @default(PENDING)
|
||
interviewWaived Boolean @default(false) // set true only on Manager-approved waiver (R2)
|
||
rejectedReason String?
|
||
rejectedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
requisitionId String
|
||
requisition Requisition @relation(fields: [requisitionId], references: [id])
|
||
crewMemberId String
|
||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
|
||
|
||
gates ApplicationGate[]
|
||
referenceChecks ReferenceCheck[]
|
||
salaryStructures SalaryStructure[]
|
||
actions CrewAction[]
|
||
|
||
@@unique([requisitionId, crewMemberId])
|
||
}
|
||
|
||
// One row per vetting gate. SALARY / SELECTION / WAIVER gates with result PENDING
|
||
// are the Manager's central Approvals-queue items (§8.13). `decidedById` is a
|
||
// denormalised actor id — the audited actor lives on the CrewAction.
|
||
model ApplicationGate {
|
||
id String @id @default(cuid())
|
||
applicationId String
|
||
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||
gate ApplicationGateType
|
||
result GateResult @default(PENDING)
|
||
note String?
|
||
decidedById String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([applicationId, gate])
|
||
}
|
||
|
||
// Competency & reference checks recorded by the MPO at the COMPETENCY_AND_REFERENCES gate.
|
||
model ReferenceCheck {
|
||
id String @id @default(cuid())
|
||
applicationId String
|
||
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||
refereeName String
|
||
refereeContact String?
|
||
outcome String? // free-text / "positive" | "negative"
|
||
note String?
|
||
recordedById String?
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
// The salary agreed at SALARY_AGREEMENT, sent for Manager approval. Effective-dated
|
||
// (R10/A4) and attached to the Application in 3b; onboarding (3c) binds it to the
|
||
// CrewAssignment. `approvedById` is set when the Manager approves the SALARY gate.
|
||
model SalaryStructure {
|
||
id String @id @default(cuid())
|
||
applicationId String
|
||
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||
rateBasis SalaryRateBasis @default(MONTHLY)
|
||
basic Decimal @db.Decimal(12, 2)
|
||
victualingPerDay Decimal @default(0) @db.Decimal(12, 2)
|
||
allowances Json?
|
||
currency String @default("INR")
|
||
effectiveFrom DateTime?
|
||
effectiveTo DateTime?
|
||
approvedById String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Bound to the assignment at onboarding (Phase 3c); null while still a proposal.
|
||
assignmentId String?
|
||
assignment CrewAssignment? @relation(fields: [assignmentId], references: [id])
|
||
}
|
||
|
||
// Bank details captured at DOC_VERIFICATION (needed downstream for payroll).
|
||
// NOTE: PII — field-level encryption/masking is a Phase-4 task (§11); stored
|
||
// plainly for now behind the crewing flag.
|
||
model BankDetail {
|
||
id String @id @default(cuid())
|
||
crewMemberId String @unique
|
||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||
accountName String?
|
||
accountNumber String?
|
||
ifsc String?
|
||
bankName String?
|
||
verificationStatus GateResult @default(PENDING) // verified by Accounts in a later phase
|
||
verifiedById String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// EPF / identity details captured at DOC_VERIFICATION. PII note as BankDetail.
|
||
model EpfDetail {
|
||
id String @id @default(cuid())
|
||
crewMemberId String @unique
|
||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||
uan String?
|
||
aadhaarLast4 String?
|
||
pfNumber String?
|
||
verificationStatus GateResult @default(PENDING)
|
||
verifiedById String?
|
||
// EPFO assisted-lookup result (recorded from the EpfoService check, A3).
|
||
epfoMemberName String?
|
||
epfoCheckedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// ─── Crewing onboarding (Phase 3c: Epic D) ──────────────────────────────────
|
||
|
||
// A single tour of duty, created at onboarding. Flips the requisition to FILLED
|
||
// and the crew member to EMPLOYEE. Leave/sign-off transitions arrive in Phase 4.
|
||
model CrewAssignment {
|
||
id String @id @default(cuid())
|
||
status AssignmentStatus @default(ACTIVE)
|
||
signOnDate DateTime
|
||
signOffDate DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
crewMemberId String
|
||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
|
||
rankId String
|
||
rank Rank @relation(fields: [rankId], references: [id])
|
||
vesselId String?
|
||
vessel Vessel? @relation(fields: [vesselId], references: [id])
|
||
siteId String?
|
||
site Site? @relation(fields: [siteId], references: [id])
|
||
// The requisition this assignment fills (one assignment per requisition).
|
||
requisitionId String? @unique
|
||
requisition Requisition? @relation(fields: [requisitionId], references: [id])
|
||
|
||
salaryStructures SalaryStructure[]
|
||
contractLetter ContractLetter?
|
||
leaveRequests LeaveRequest[]
|
||
attendance Attendance[]
|
||
appraisals Appraisal[]
|
||
}
|
||
|
||
// A periodic appraisal on a tour of duty (Phase 5b). Actor ids are denormalised
|
||
// strings — the audited actor lives on the CrewAction.
|
||
model Appraisal {
|
||
id String @id @default(cuid())
|
||
assignmentId String
|
||
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||
period String // e.g. "2026" or "2026-Q2"
|
||
ratings Json?
|
||
comments String?
|
||
status AppraisalStatus @default(SUBMITTED)
|
||
rejectedReason String?
|
||
addedById String
|
||
verifiedById String?
|
||
approvedById String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// Leave applied by the Site In-charge on a crew member's assignment, decided by
|
||
// the Manager (§8.9, R1). Actor ids are denormalised strings — the audited actor
|
||
// lives on the CrewAction.
|
||
model LeaveRequest {
|
||
id String @id @default(cuid())
|
||
assignmentId String
|
||
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||
type LeaveType @default(ANNUAL)
|
||
fromDate DateTime
|
||
toDate DateTime
|
||
reason String?
|
||
status LeaveStatus @default(APPLIED)
|
||
appliedById String
|
||
decidedById String?
|
||
decidedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// One attendance mark per assignment per day (§8.10). Site staff + Manager only.
|
||
model Attendance {
|
||
id String @id @default(cuid())
|
||
assignmentId String
|
||
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||
date DateTime @db.Date
|
||
status AttendanceStatus
|
||
note String?
|
||
recordedById String
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([assignmentId, date])
|
||
}
|
||
|
||
// Required crew strength per rank, per vessel (Phase 4b, Option A). Drives
|
||
// leave-clash detection (§5.3, R6): approving a leave is a clash when the active
|
||
// same-rank cover over the window would fall below this. Managed by the office
|
||
// (manage_crew). Absent a row, the clash check falls back to a strength of 1.
|
||
model VesselRankRequirement {
|
||
id String @id @default(cuid())
|
||
vesselId String
|
||
vessel Vessel @relation(fields: [vesselId], references: [id], onDelete: Cascade)
|
||
rankId String
|
||
rank Rank @relation(fields: [rankId], references: [id], onDelete: Cascade)
|
||
minStrength Int @default(1)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([vesselId, rankId])
|
||
}
|
||
|
||
// The signed contract for an assignment. `salaryRestricted` hides salary from
|
||
// site staff on the crew profile (Phase 4 display gating).
|
||
model ContractLetter {
|
||
id String @id @default(cuid())
|
||
assignmentId String @unique
|
||
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||
fileKey String
|
||
salaryRestricted Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
// ─── Crewing crew records (Phase 4a, Epics E + F) ───────────────────────────
|
||
|
||
// A held document on the crew profile (medical, passport, CDC, STCW, …). The
|
||
// verify queue (MPO/Accounts) lands in Phase 5; here we capture + display, with
|
||
// `verificationStatus` carried and "expired" derived from expiryDate in the UI.
|
||
model SeafarerDocument {
|
||
id String @id @default(cuid())
|
||
crewMemberId String
|
||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||
docType SeafarerDocType
|
||
number String? // PII — masked in the UI for non-privileged roles
|
||
fileKey String?
|
||
issueDate DateTime?
|
||
expiryDate DateTime?
|
||
verificationStatus GateResult @default(PENDING)
|
||
verifiedById String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// Next of kin / emergency contacts (§8.8). `isEmergency` marks the emergency row.
|
||
model NextOfKin {
|
||
id String @id @default(cuid())
|
||
crewMemberId String
|
||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||
name String
|
||
relationship String?
|
||
phone String?
|
||
address String?
|
||
isEmergency Boolean @default(false)
|
||
verificationStatus GateResult @default(PENDING) // MPO verifies (Phase 5 follow-up)
|
||
verifiedById String?
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
// A tour-of-duty experience row — added manually or auto-appended at sign-off
|
||
// (Phase 4c). `source` is "internal" (a PPMS assignment) or "declared".
|
||
model ExperienceRecord {
|
||
id String @id @default(cuid())
|
||
crewMemberId String
|
||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||
vesselType String?
|
||
rankId String?
|
||
rank Rank? @relation(fields: [rankId], references: [id])
|
||
fromDate DateTime?
|
||
toDate DateTime?
|
||
durationMonths Int?
|
||
source String @default("declared")
|
||
createdAt DateTime @default(now())
|
||
}
|
||
|
||
// PPE issued to a crew member (§8.8). A reissue is a new row; `returnedDate`
|
||
// marks a returned item. Optional ItemInventory draw-down is a later refinement.
|
||
model PpeIssue {
|
||
id String @id @default(cuid())
|
||
crewMemberId String
|
||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||
item PpeItem
|
||
size String?
|
||
quantity Int @default(1)
|
||
issuedDate DateTime @default(now())
|
||
returnedDate DateTime?
|
||
issuedById String?
|
||
comment String?
|
||
verificationStatus GateResult @default(PENDING) // MPO verifies (Phase 5 follow-up)
|
||
verifiedById String?
|
||
createdAt DateTime @default(now())
|
||
}
|