MY QUALITY - First iteration

This commit is contained in:
2026-06-11 15:43:35 +01:00
parent 4f8996712e
commit 1fdb9536fa
31 changed files with 1965 additions and 98 deletions
@@ -0,0 +1,78 @@
-- CreateEnum
CREATE TYPE "QualityDefectStatus" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'CORRECTED');
-- AlterEnum
ALTER TYPE "UserRole" ADD VALUE 'QUALITY';
-- CreateTable
CREATE TABLE "OperatorSession" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"workstationId" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endedAt" TIMESTAMP(3),
CONSTRAINT "OperatorSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QualityDefect" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"workstationId" TEXT NOT NULL,
"createdByUserId" TEXT NOT NULL,
"defectType" TEXT NOT NULL,
"location" TEXT,
"description" TEXT NOT NULL,
"rfsCode" TEXT,
"photoKey" TEXT,
"status" "QualityDefectStatus" NOT NULL DEFAULT 'OPEN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"acknowledgedByUserId" TEXT,
"acknowledgedAt" TIMESTAMP(3),
"correctedByUserId" TEXT,
"correctedAt" TIMESTAMP(3),
"correctionNote" TEXT,
CONSTRAINT "QualityDefect_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "OperatorSession_tenantId_idx" ON "OperatorSession"("tenantId");
-- CreateIndex
CREATE INDEX "OperatorSession_tenantId_userId_endedAt_idx" ON "OperatorSession"("tenantId", "userId", "endedAt");
-- CreateIndex
CREATE INDEX "OperatorSession_tenantId_workstationId_endedAt_idx" ON "OperatorSession"("tenantId", "workstationId", "endedAt");
-- CreateIndex
CREATE INDEX "QualityDefect_tenantId_status_createdAt_idx" ON "QualityDefect"("tenantId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "QualityDefect_tenantId_workstationId_status_idx" ON "QualityDefect"("tenantId", "workstationId", "status");
-- AddForeignKey
ALTER TABLE "OperatorSession" ADD CONSTRAINT "OperatorSession_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OperatorSession" ADD CONSTRAINT "OperatorSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OperatorSession" ADD CONSTRAINT "OperatorSession_workstationId_fkey" FOREIGN KEY ("workstationId") REFERENCES "Workstation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_workstationId_fkey" FOREIGN KEY ("workstationId") REFERENCES "Workstation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_acknowledgedByUserId_fkey" FOREIGN KEY ("acknowledgedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_correctedByUserId_fkey" FOREIGN KEY ("correctedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+71
View File
@@ -16,6 +16,7 @@ datasource db {
enum UserRole {
ADMIN
SUPERVISOR
QUALITY
OPERATOR
}
@@ -25,6 +26,12 @@ enum MaintenanceRequestStatus {
RESOLVED
}
enum QualityDefectStatus {
OPEN
ACKNOWLEDGED
CORRECTED
}
model Tenant {
id String @id @default(cuid())
name String
@@ -34,6 +41,8 @@ model Tenant {
workstations Workstation[]
events DomainEvent[]
maintenanceRequests MaintenanceRequest[]
operatorSessions OperatorSession[]
qualityDefects QualityDefect[]
}
model User {
@@ -52,6 +61,11 @@ model User {
claimedRequests MaintenanceRequest[] @relation("claimed")
resolvedRequests MaintenanceRequest[] @relation("resolved")
sessions OperatorSession[]
createdDefects QualityDefect[] @relation("defectCreated")
acknowledgedDefects QualityDefect[] @relation("defectAcknowledged")
correctedDefects QualityDefect[] @relation("defectCorrected")
@@unique([tenantId, email])
@@index([tenantId])
}
@@ -65,6 +79,8 @@ model Workstation {
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
maintenanceRequests MaintenanceRequest[]
operatorSessions OperatorSession[]
qualityDefects QualityDefect[]
@@unique([tenantId, code])
@@index([tenantId])
@@ -115,3 +131,58 @@ model MaintenanceRequest {
@@index([tenantId, status, createdAt])
@@index([tenantId, reportedByUserId])
}
/// MY QUALITY — an operator's active binding to a workstation ("badge-in").
/// At most one active session (endedAt == null) per user; starting a new one
/// ends the previous. Quality defects route to whoever has the active session
/// at the targeted workstation.
model OperatorSession {
id String @id @default(cuid())
tenantId String
userId String
workstationId String
startedAt DateTime @default(now())
endedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
workstation Workstation @relation(fields: [workstationId], references: [id])
@@index([tenantId])
@@index([tenantId, userId, endedAt])
@@index([tenantId, workstationId, endedAt])
}
/// MY QUALITY — a quality defect raised by QCP against a workstation, routed to
/// the operator currently bound there. Mirrors MaintenanceRequest but in the
/// opposite direction (quality -> operator). State: OPEN -> ACKNOWLEDGED ->
/// CORRECTED.
model QualityDefect {
id String @id @default(cuid())
tenantId String
workstationId String
createdByUserId String
defectType String
location String?
description String
rfsCode String?
photoKey String?
status QualityDefectStatus @default(OPEN)
createdAt DateTime @default(now())
acknowledgedByUserId String?
acknowledgedAt DateTime?
correctedByUserId String?
correctedAt DateTime?
correctionNote String?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
workstation Workstation @relation(fields: [workstationId], references: [id])
createdBy User @relation("defectCreated", fields: [createdByUserId], references: [id])
acknowledgedBy User? @relation("defectAcknowledged", fields: [acknowledgedByUserId], references: [id])
correctedBy User? @relation("defectCorrected", fields: [correctedByUserId], references: [id])
@@index([tenantId, status, createdAt])
@@index([tenantId, workstationId, status])
}
+85
View File
@@ -15,6 +15,8 @@ const prisma = new PrismaClient();
const DEMO_TENANT_NAME = 'Demo Factory';
const DEMO_ADMIN_EMAIL = 'admin@demo.local';
const DEMO_ADMIN_PASSWORD = 'admin1234';
const DEMO_QCP_EMAIL = 'qcp@demo.local';
const DEMO_QCP_PASSWORD = 'qcp1234';
const OPERATORS = [
{ email: 'op1@demo.local', pin: '1111' },
@@ -49,6 +51,15 @@ async function main() {
},
});
await prisma.user.create({
data: {
tenantId: tenant.id,
email: DEMO_QCP_EMAIL,
role: UserRole.QUALITY,
passwordHash: await hashSecret(DEMO_QCP_PASSWORD),
},
});
for (const op of OPERATORS) {
await prisma.user.create({
data: {
@@ -161,6 +172,77 @@ async function main() {
}
console.warn(` pedidos de exemplo: ${samples.length} criados`);
// MY QUALITY — op1 is badged-in at the first workstation, and QCP has
// raised a few defects there so the operator's alerts and the QCP queue
// are non-empty on first boot.
const station = wsList[0]!;
const qcpUser = await prisma.user.findFirst({
where: { tenantId: tenant.id, email: DEMO_QCP_EMAIL },
});
await prisma.operatorSession.create({
data: {
tenantId: tenant.id,
userId: op1User.id,
workstationId: station.id,
startedAt: ago(120),
},
});
if (qcpUser) {
const defects = [
// OPEN — operator hasn't seen it yet
{
tenantId: tenant.id,
workstationId: station.id,
createdByUserId: qcpUser.id,
defectType: 'Aperto não conforme',
location: 'Banco dianteiro esquerdo',
description: 'Binário fora de especificação no parafuso da calha.',
rfsCode: 'RFS-1042',
status: 'OPEN' as const,
createdAt: ago(8),
},
// ACKNOWLEDGED — operator saw it, correcting
{
tenantId: tenant.id,
workstationId: station.id,
createdByUserId: qcpUser.id,
defectType: 'Clip em falta',
location: 'Painel de porta traseira direita',
description: 'Clip de fixação do painel ausente.',
rfsCode: 'RFS-1043',
status: 'ACKNOWLEDGED' as const,
createdAt: ago(35),
acknowledgedByUserId: op1User.id,
acknowledgedAt: ago(30),
},
// CORRECTED — closed loop
{
tenantId: tenant.id,
workstationId: station.id,
createdByUserId: qcpUser.id,
defectType: 'Risco na pintura',
location: 'Capot',
description: 'Risco superficial detetado no controlo visual.',
rfsCode: 'RFS-1041',
status: 'CORRECTED' as const,
createdAt: ago(90),
acknowledgedByUserId: op1User.id,
acknowledgedAt: ago(85),
correctedByUserId: op1User.id,
correctedAt: ago(70),
correctionNote: 'Polimento efetuado, defeito eliminado.',
},
];
for (const d of defects) {
await prisma.qualityDefect.create({ data: d });
}
console.warn(` defeitos de exemplo: ${defects.length} criados (op1 em ${station.code})`);
}
}
console.warn(
@@ -169,6 +251,9 @@ async function main() {
console.warn(
` admin: ${DEMO_ADMIN_EMAIL} / ${DEMO_ADMIN_PASSWORD}`,
);
console.warn(
` qcp: ${DEMO_QCP_EMAIL} / ${DEMO_QCP_PASSWORD}`,
);
console.warn(
` operadores: ${OPERATORS.map((o) => `${o.email}=${o.pin}`).join(' | ')}`,
);
+10 -2
View File
@@ -1,5 +1,13 @@
export { prisma, type DbClient } from './client';
export { tenantScoped, type TenantScopedClient } from './tenant-extension';
export { Prisma, UserRole, MaintenanceRequestStatus } from '@prisma/client';
export type { User, Tenant, Workstation, DomainEvent, MaintenanceRequest } from '@prisma/client';
export { Prisma, UserRole, MaintenanceRequestStatus, QualityDefectStatus } from '@prisma/client';
export type {
User,
Tenant,
Workstation,
DomainEvent,
MaintenanceRequest,
OperatorSession,
QualityDefect,
} from '@prisma/client';
export { hashSecret, verifySecret } from './crypto';
+8 -1
View File
@@ -83,7 +83,14 @@ import type { PrismaClient } from '@prisma/client';
* ============================================================================
*/
const TENANT_SCOPED_MODELS = ['User', 'Workstation', 'DomainEvent', 'MaintenanceRequest'] as const;
const TENANT_SCOPED_MODELS = [
'User',
'Workstation',
'DomainEvent',
'MaintenanceRequest',
'OperatorSession',
'QualityDefect',
] as const;
type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
function isTenantScoped(model: string | undefined): model is TenantScopedModel {