feat(03-04): JobOrder schema, migration, and tenant scoping

- Added JobOrderStatus enum (PENDING, IN_PROGRESS, COMPLETED, CANCELLED)
- Added JobOrder model with ticket FK, assignedTo/createdBy user FKs
- Added orderNumber (JO-NNNN), scheduledDate, timestamps, outcomeNotes
- Added jobOrders reverse relation on Ticket model
- Added assignedJobOrders/createdJobOrders reverse relations on User model
- Applied migration: 20260304235859_add_job_orders
- Added jobOrder to TENANT_SCOPED_MODELS with full 12-operation extension block
This commit is contained in:
kevin-asprec
2026-03-05 07:59:56 +08:00
parent 0373810d9e
commit d59f1d55ea
3 changed files with 204 additions and 1 deletions

View File

@@ -0,0 +1,49 @@
-- CreateEnum
CREATE TYPE "JobOrderStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED');
-- CreateTable
CREATE TABLE "JobOrder" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"orderNumber" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"jobType" TEXT NOT NULL,
"description" TEXT,
"assignedToId" TEXT NOT NULL,
"status" "JobOrderStatus" NOT NULL DEFAULT 'PENDING',
"scheduledDate" TIMESTAMP(3),
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"outcomeNotes" TEXT,
"cancelledAt" TIMESTAMP(3),
"cancelReason" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "JobOrder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "JobOrder_tenantId_idx" ON "JobOrder"("tenantId");
-- CreateIndex
CREATE INDEX "JobOrder_tenantId_ticketId_idx" ON "JobOrder"("tenantId", "ticketId");
-- CreateIndex
CREATE INDEX "JobOrder_tenantId_assignedToId_idx" ON "JobOrder"("tenantId", "assignedToId");
-- CreateIndex
CREATE INDEX "JobOrder_tenantId_status_idx" ON "JobOrder"("tenantId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "JobOrder_tenantId_orderNumber_key" ON "JobOrder"("tenantId", "orderNumber");
-- AddForeignKey
ALTER TABLE "JobOrder" ADD CONSTRAINT "JobOrder_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JobOrder" ADD CONSTRAINT "JobOrder_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JobOrder" ADD CONSTRAINT "JobOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -129,6 +129,13 @@ enum RemittanceStatus {
VERIFIED VERIFIED
} }
enum JobOrderStatus {
PENDING
IN_PROGRESS
COMPLETED
CANCELLED
}
// ============================================================================= // =============================================================================
// MODELS // MODELS
// ============================================================================= // =============================================================================
@@ -381,6 +388,10 @@ model User {
remittances Remittance[] @relation("RemittanceByCollector") remittances Remittance[] @relation("RemittanceByCollector")
/// Remittances verified by this user (office staff) /// Remittances verified by this user (office staff)
verifiedRemittances Remittance[] @relation("RemittanceVerifiedBy") verifiedRemittances Remittance[] @relation("RemittanceVerifiedBy")
/// Job orders assigned to this technician
assignedJobOrders JobOrder[] @relation("JobOrderAssignedTo")
/// Job orders created by this user (staff)
createdJobOrders JobOrder[] @relation("JobOrderCreatedBy")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -626,6 +637,8 @@ model Ticket {
@@index([tenantId, status]) @@index([tenantId, status])
@@index([tenantId, categoryId]) @@index([tenantId, categoryId])
@@index([subscriberId]) @@index([subscriberId])
jobOrders JobOrder[]
} }
/// A Collection records cash received from a subscriber by a field collector. /// A Collection records cash received from a subscriber by a field collector.
@@ -722,6 +735,51 @@ model Remittance {
@@index([tenantId, status]) @@index([tenantId, status])
} }
/// A JobOrder is the execution unit created from a support ticket.
/// One ticket can have many job orders (1:many).
/// A technician is assigned to each job order and updates its status.
/// When ALL non-cancelled job orders on a ticket are COMPLETED, the ticket auto-resolves.
/// When ALL job orders on a ticket are CANCELLED, the ticket reverts to OPEN.
model JobOrder {
id String @id @default(uuid())
tenantId String
/// Auto-generated sequential identifier per tenant (e.g., "JO-0001")
orderNumber String
/// The ticket this job order is for
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
/// Type of work (e.g., "Installation", "Repair", "Maintenance")
jobType String
description String?
/// The technician assigned to this job order
assignedToId String
assignedTo User @relation("JobOrderAssignedTo", fields: [assignedToId], references: [id])
status JobOrderStatus @default(PENDING)
scheduledDate DateTime?
/// Timestamp when the technician started working
startedAt DateTime?
/// Timestamp when the job was completed
completedAt DateTime?
/// Notes on outcome, required when marking COMPLETED
outcomeNotes String?
/// Timestamp when the job was cancelled
cancelledAt DateTime?
cancelReason String?
/// The staff member who created this job order
createdById String
createdBy User @relation("JobOrderCreatedBy", fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Order numbers must be unique within a tenant
@@unique([tenantId, orderNumber])
/// RLS-ready index — always present on tenant-scoped models
@@index([tenantId])
@@index([tenantId, ticketId])
@@index([tenantId, assignedToId])
@@index([tenantId, status])
}
/// A single line item on an Invoice (e.g., "50 Mbps Monthly Service — $49.99"). /// A single line item on an Invoice (e.g., "50 Mbps Monthly Service — $49.99").
model InvoiceLine { model InvoiceLine {
id String @id @default(uuid()) id String @id @default(uuid())

View File

@@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma";
* Extend this list as new models are added in later phases: * Extend this list as new models are added in later phases:
* e.g., "subscriber", "invoice", "servicePlan", "payment" * e.g., "subscriber", "invoice", "servicePlan", "payment"
*/ */
export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation", "zone", "zoneAssignment", "ticket", "ticketCategory", "collection", "collectionAllocation", "remittance"] as const; export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation", "zone", "zoneAssignment", "ticket", "ticketCategory", "collection", "collectionAllocation", "remittance", "jobOrder"] as const;
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number]; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
@@ -1608,6 +1608,102 @@ export function withTenantContext(tenantId: string) {
return query(args); return query(args);
}, },
}, },
jobOrder: {
async findMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findFirstOrThrow({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findUnique({ args, query }) {
if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) {
return prisma.jobOrder.findFirst({
...args,
where: { ...args.where, tenantId },
});
}
return query(args);
},
async findUniqueOrThrow({ args, query }) {
if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) {
const result = await prisma.jobOrder.findFirst({
...args,
where: { ...args.where, tenantId },
});
if (!result) {
throw new Error("Record not found");
}
return result;
}
return query(args);
},
async create({ args, query }) {
args.data = { ...args.data, tenantId } as typeof args.data;
return query(args);
},
async createMany({ args, query }) {
if (Array.isArray(args.data)) {
args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data;
} else {
args.data = { ...args.data, tenantId } as typeof args.data;
}
return query(args);
},
async update({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async updateMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async delete({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async deleteMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async upsert({ args, query }) {
args.where = { ...args.where, tenantId } as typeof args.where;
args.create = { ...args.create, tenantId } as typeof args.create;
return query(args);
},
async count({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async aggregate({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async groupBy({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
},
}, },
}); });
} }