[MAI-009] feat: Complete quality module with schema fixes and ChecklistService

- Fix 8 entities schema from 'quality' to 'construction' to match DDL
- Create ChecklistService with full CRUD, duplication, item management
- Fix InspectionService and TicketService to use local ServiceContext
- Fix InspectionController and TicketController pagination structure
- Update services/index.ts to export ChecklistService

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 08:54:18 -06:00
parent 63013bb2f4
commit 60782a3cac
14 changed files with 446 additions and 36 deletions

View File

@ -19,7 +19,11 @@ import { AuthService } from '../../auth/services/auth.service';
import { User } from '../../core/entities/user.entity'; import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity'; import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity'; import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createInspectionController(dataSource: DataSource): Router { export function createInspectionController(dataSource: DataSource): Router {
const router = Router(); const router = Router();
@ -73,7 +77,7 @@ export function createInspectionController(dataSource: DataSource): Router {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const result = await service.findWithFilters(getContext(req), filters, page, limit); const result = await service.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({ success: true, data: result.data, pagination: result.meta }); res.status(200).json({ success: true, data: result.data, pagination: { total: result.total, page: result.page, limit: result.limit } });
} catch (error) { } catch (error) {
next(error); next(error);
} }

View File

@ -17,7 +17,11 @@ import { AuthService } from '../../auth/services/auth.service';
import { User } from '../../core/entities/user.entity'; import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity'; import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity'; import { RefreshToken } from '../../auth/entities/refresh-token.entity';
import { ServiceContext } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
export function createTicketController(dataSource: DataSource): Router { export function createTicketController(dataSource: DataSource): Router {
const router = Router(); const router = Router();
@ -72,7 +76,7 @@ export function createTicketController(dataSource: DataSource): Router {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const result = await service.findWithFilters(getContext(req), filters, page, limit); const result = await service.findWithFilters(getContext(req), filters, page, limit);
res.status(200).json({ success: true, data: result.data, pagination: result.meta }); res.status(200).json({ success: true, data: result.data, pagination: { total: result.total, page: result.page, limit: result.limit } });
} catch (error) { } catch (error) {
next(error); next(error);
} }

View File

@ -19,7 +19,7 @@ import {
import { Tenant } from '../../core/entities/tenant.entity'; import { Tenant } from '../../core/entities/tenant.entity';
import { Checklist } from './checklist.entity'; import { Checklist } from './checklist.entity';
@Entity({ schema: 'quality', name: 'checklist_items' }) @Entity({ schema: 'construction', name: 'checklist_items' })
@Index(['tenantId', 'checklistId', 'sequenceNumber']) @Index(['tenantId', 'checklistId', 'sequenceNumber'])
export class ChecklistItem { export class ChecklistItem {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')

View File

@ -24,7 +24,7 @@ import { Inspection } from './inspection.entity';
export type ChecklistStage = 'foundation' | 'structure' | 'installations' | 'finishes' | 'delivery' | 'custom'; export type ChecklistStage = 'foundation' | 'structure' | 'installations' | 'finishes' | 'delivery' | 'custom';
@Entity({ schema: 'quality', name: 'checklists' }) @Entity({ schema: 'construction', name: 'checklists' })
@Index(['tenantId', 'code'], { unique: true }) @Index(['tenantId', 'code'], { unique: true })
export class Checklist { export class Checklist {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')

View File

@ -23,7 +23,7 @@ import { NonConformity } from './non-conformity.entity';
export type ActionType = 'corrective' | 'preventive' | 'improvement'; export type ActionType = 'corrective' | 'preventive' | 'improvement';
export type ActionStatus = 'pending' | 'in_progress' | 'completed' | 'verified'; export type ActionStatus = 'pending' | 'in_progress' | 'completed' | 'verified';
@Entity({ schema: 'quality', name: 'corrective_actions' }) @Entity({ schema: 'construction', name: 'acciones_correctivas' })
@Index(['tenantId', 'nonConformityId']) @Index(['tenantId', 'nonConformityId'])
export class CorrectiveAction { export class CorrectiveAction {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@ -32,7 +32,7 @@ export class CorrectiveAction {
@Column({ name: 'tenant_id', type: 'uuid' }) @Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string; tenantId: string;
@Column({ name: 'non_conformity_id', type: 'uuid' }) @Column({ name: 'no_conformidad_id', type: 'uuid' })
nonConformityId: string; nonConformityId: string;
@Column({ name: 'action_type', type: 'varchar', length: 20 }) @Column({ name: 'action_type', type: 'varchar', length: 20 })
@ -83,7 +83,7 @@ export class CorrectiveAction {
tenant: Tenant; tenant: Tenant;
@ManyToOne(() => NonConformity, (nc) => nc.correctiveActions, { onDelete: 'CASCADE' }) @ManyToOne(() => NonConformity, (nc) => nc.correctiveActions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'non_conformity_id' }) @JoinColumn({ name: 'no_conformidad_id' })
nonConformity: NonConformity; nonConformity: NonConformity;
@ManyToOne(() => User) @ManyToOne(() => User)

View File

@ -22,7 +22,7 @@ import { ChecklistItem } from './checklist-item.entity';
export type InspectionResultStatus = 'pending' | 'passed' | 'failed' | 'not_applicable'; export type InspectionResultStatus = 'pending' | 'passed' | 'failed' | 'not_applicable';
@Entity({ schema: 'quality', name: 'inspection_results' }) @Entity({ schema: 'construction', name: 'inspeccion_resultados' })
@Index(['tenantId', 'inspectionId', 'checklistItemId'], { unique: true }) @Index(['tenantId', 'inspectionId', 'checklistItemId'], { unique: true })
export class InspectionResult { export class InspectionResult {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')

View File

@ -25,7 +25,7 @@ import { NonConformity } from './non-conformity.entity';
export type InspectionStatus = 'pending' | 'in_progress' | 'completed' | 'approved' | 'rejected'; export type InspectionStatus = 'pending' | 'in_progress' | 'completed' | 'approved' | 'rejected';
@Entity({ schema: 'quality', name: 'inspections' }) @Entity({ schema: 'construction', name: 'inspecciones' })
@Index(['tenantId', 'inspectionNumber'], { unique: true }) @Index(['tenantId', 'inspectionNumber'], { unique: true })
export class Inspection { export class Inspection {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')

View File

@ -25,7 +25,7 @@ import { CorrectiveAction } from './corrective-action.entity';
export type NCSeverity = 'minor' | 'major' | 'critical'; export type NCSeverity = 'minor' | 'major' | 'critical';
export type NCStatus = 'open' | 'in_progress' | 'closed' | 'verified'; export type NCStatus = 'open' | 'in_progress' | 'closed' | 'verified';
@Entity({ schema: 'quality', name: 'non_conformities' }) @Entity({ schema: 'construction', name: 'no_conformidades' })
@Index(['tenantId', 'ncNumber'], { unique: true }) @Index(['tenantId', 'ncNumber'], { unique: true })
export class NonConformity { export class NonConformity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')

View File

@ -25,7 +25,7 @@ export type TicketPriority = 'urgent' | 'high' | 'medium' | 'low';
export type TicketStatus = 'created' | 'assigned' | 'in_progress' | 'resolved' | 'closed' | 'cancelled'; export type TicketStatus = 'created' | 'assigned' | 'in_progress' | 'resolved' | 'closed' | 'cancelled';
export type TicketCategory = 'plumbing' | 'electrical' | 'finishes' | 'carpentry' | 'structural' | 'other'; export type TicketCategory = 'plumbing' | 'electrical' | 'finishes' | 'carpentry' | 'structural' | 'other';
@Entity({ schema: 'quality', name: 'post_sale_tickets' }) @Entity({ schema: 'construction', name: 'tickets_postventa' })
@Index(['tenantId', 'ticketNumber'], { unique: true }) @Index(['tenantId', 'ticketNumber'], { unique: true })
export class PostSaleTicket { export class PostSaleTicket {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')

View File

@ -22,7 +22,7 @@ import { PostSaleTicket } from './post-sale-ticket.entity';
export type AssignmentStatus = 'assigned' | 'accepted' | 'in_progress' | 'completed' | 'reassigned'; export type AssignmentStatus = 'assigned' | 'accepted' | 'in_progress' | 'completed' | 'reassigned';
@Entity({ schema: 'quality', name: 'ticket_assignments' }) @Entity({ schema: 'construction', name: 'ticket_asignaciones' })
@Index(['tenantId', 'ticketId', 'technicianId']) @Index(['tenantId', 'ticketId', 'technicianId'])
export class TicketAssignment { export class TicketAssignment {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')

View File

@ -0,0 +1,409 @@
/**
* ChecklistService - Servicio de plantillas de inspección
*
* Gestión de checklists y sus items para inspecciones de calidad.
*
* @module Quality (MAI-009)
*/
import { Repository } from 'typeorm';
import { Checklist, ChecklistStage } from '../entities/checklist.entity';
import { ChecklistItem } from '../entities/checklist-item.entity';
interface ServiceContext {
tenantId: string;
userId?: string;
}
export interface CreateChecklistDto {
code: string;
name: string;
description?: string;
stage: ChecklistStage;
prototypeId?: string;
}
export interface UpdateChecklistDto {
name?: string;
description?: string;
stage?: ChecklistStage;
prototypeId?: string;
isActive?: boolean;
}
export interface CreateChecklistItemDto {
sequenceNumber: number;
category: string;
description: string;
isCritical?: boolean;
requiresPhoto?: boolean;
acceptanceCriteria?: string;
}
export interface UpdateChecklistItemDto {
sequenceNumber?: number;
category?: string;
description?: string;
isCritical?: boolean;
requiresPhoto?: boolean;
acceptanceCriteria?: string;
isActive?: boolean;
}
export interface ChecklistFilters {
stage?: ChecklistStage;
prototypeId?: string;
isActive?: boolean;
search?: string;
page?: number;
limit?: number;
}
export class ChecklistService {
constructor(
private readonly checklistRepository: Repository<Checklist>,
private readonly itemRepository: Repository<ChecklistItem>,
) {}
async findAll(
ctx: ServiceContext,
filters: ChecklistFilters = {},
): Promise<{ data: Checklist[]; total: number; page: number; limit: number }> {
const page = filters.page || 1;
const limit = filters.limit || 20;
const skip = (page - 1) * limit;
const queryBuilder = this.checklistRepository
.createQueryBuilder('cl')
.leftJoinAndSelect('cl.items', 'items', 'items.is_active = true')
.where('cl.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('cl.deleted_at IS NULL');
if (filters.stage) {
queryBuilder.andWhere('cl.stage = :stage', { stage: filters.stage });
}
if (filters.prototypeId) {
queryBuilder.andWhere('cl.prototype_id = :prototypeId', {
prototypeId: filters.prototypeId,
});
}
if (filters.isActive !== undefined) {
queryBuilder.andWhere('cl.is_active = :isActive', { isActive: filters.isActive });
}
if (filters.search) {
queryBuilder.andWhere(
'(cl.name ILIKE :search OR cl.code ILIKE :search OR cl.description ILIKE :search)',
{ search: `%${filters.search}%` },
);
}
const [data, total] = await queryBuilder
.orderBy('cl.stage', 'ASC')
.addOrderBy('cl.name', 'ASC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { data, total, page, limit };
}
async findById(ctx: ServiceContext, id: string): Promise<Checklist | null> {
return this.checklistRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
},
});
}
async findWithItems(ctx: ServiceContext, id: string): Promise<Checklist | null> {
return this.checklistRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
},
relations: ['items', 'createdBy'],
order: { items: { sequenceNumber: 'ASC' } },
});
}
async findByCode(ctx: ServiceContext, code: string): Promise<Checklist | null> {
return this.checklistRepository.findOne({
where: {
code,
tenantId: ctx.tenantId,
},
});
}
async findByStage(ctx: ServiceContext, stage: ChecklistStage): Promise<Checklist[]> {
return this.checklistRepository
.createQueryBuilder('cl')
.leftJoinAndSelect('cl.items', 'items', 'items.is_active = true')
.where('cl.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('cl.deleted_at IS NULL')
.andWhere('cl.is_active = true')
.andWhere('cl.stage = :stage', { stage })
.orderBy('cl.name', 'ASC')
.addOrderBy('items.sequence_number', 'ASC')
.getMany();
}
async create(ctx: ServiceContext, dto: CreateChecklistDto): Promise<Checklist> {
// Check code uniqueness
const existing = await this.findByCode(ctx, dto.code);
if (existing) {
throw new Error(`Checklist with code ${dto.code} already exists`);
}
const checklist = this.checklistRepository.create({
tenantId: ctx.tenantId,
createdById: ctx.userId,
code: dto.code,
name: dto.name,
description: dto.description,
stage: dto.stage,
prototypeId: dto.prototypeId,
isActive: true,
version: 1,
});
return this.checklistRepository.save(checklist);
}
async update(
ctx: ServiceContext,
id: string,
dto: UpdateChecklistDto,
): Promise<Checklist | null> {
const checklist = await this.findById(ctx, id);
if (!checklist) {
return null;
}
Object.assign(checklist, {
...dto,
updatedById: ctx.userId,
});
return this.checklistRepository.save(checklist);
}
async incrementVersion(ctx: ServiceContext, id: string): Promise<Checklist | null> {
const checklist = await this.findById(ctx, id);
if (!checklist) {
return null;
}
checklist.version += 1;
if (ctx.userId) {
checklist.updatedById = ctx.userId;
}
return this.checklistRepository.save(checklist);
}
async activate(ctx: ServiceContext, id: string): Promise<Checklist | null> {
return this.update(ctx, id, { isActive: true });
}
async deactivate(ctx: ServiceContext, id: string): Promise<Checklist | null> {
return this.update(ctx, id, { isActive: false });
}
async duplicate(ctx: ServiceContext, id: string, newCode: string, newName: string): Promise<Checklist | null> {
const original = await this.findWithItems(ctx, id);
if (!original) {
return null;
}
// Check new code uniqueness
const existing = await this.findByCode(ctx, newCode);
if (existing) {
throw new Error(`Checklist with code ${newCode} already exists`);
}
// Create new checklist
const newChecklist = this.checklistRepository.create({
tenantId: ctx.tenantId,
createdById: ctx.userId,
code: newCode,
name: newName,
description: original.description,
stage: original.stage,
prototypeId: original.prototypeId,
isActive: true,
version: 1,
});
const savedChecklist = await this.checklistRepository.save(newChecklist);
// Duplicate items
if (original.items && original.items.length > 0) {
const newItems = original.items.map((item) =>
this.itemRepository.create({
tenantId: ctx.tenantId,
checklistId: savedChecklist.id,
sequenceNumber: item.sequenceNumber,
category: item.category,
description: item.description,
isCritical: item.isCritical,
requiresPhoto: item.requiresPhoto,
acceptanceCriteria: item.acceptanceCriteria,
isActive: true,
}),
);
await this.itemRepository.save(newItems);
}
return this.findWithItems(ctx, savedChecklist.id);
}
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const checklist = await this.findById(ctx, id);
if (!checklist) {
return false;
}
const updateData: { deletedAt: Date; deletedById?: string } = { deletedAt: new Date() };
if (ctx.userId) {
updateData.deletedById = ctx.userId;
}
await this.checklistRepository.update(
{ id, tenantId: ctx.tenantId },
updateData,
);
return true;
}
// Item management
async addItem(
ctx: ServiceContext,
checklistId: string,
dto: CreateChecklistItemDto,
): Promise<ChecklistItem> {
const checklist = await this.findById(ctx, checklistId);
if (!checklist) {
throw new Error('Checklist not found');
}
const item = this.itemRepository.create({
tenantId: ctx.tenantId,
checklistId,
sequenceNumber: dto.sequenceNumber,
category: dto.category,
description: dto.description,
isCritical: dto.isCritical ?? false,
requiresPhoto: dto.requiresPhoto ?? false,
acceptanceCriteria: dto.acceptanceCriteria,
isActive: true,
});
const savedItem = await this.itemRepository.save(item);
// Increment checklist version
await this.incrementVersion(ctx, checklistId);
return savedItem;
}
async updateItem(
ctx: ServiceContext,
itemId: string,
dto: UpdateChecklistItemDto,
): Promise<ChecklistItem | null> {
const item = await this.itemRepository.findOne({
where: { id: itemId, tenantId: ctx.tenantId },
});
if (!item) {
return null;
}
Object.assign(item, dto);
const savedItem = await this.itemRepository.save(item);
// Increment checklist version
await this.incrementVersion(ctx, item.checklistId);
return savedItem;
}
async removeItem(ctx: ServiceContext, itemId: string): Promise<boolean> {
const item = await this.itemRepository.findOne({
where: { id: itemId, tenantId: ctx.tenantId },
});
if (!item) {
return false;
}
await this.itemRepository.delete({ id: itemId, tenantId: ctx.tenantId });
// Increment checklist version
await this.incrementVersion(ctx, item.checklistId);
return true;
}
async reorderItems(
ctx: ServiceContext,
checklistId: string,
itemOrders: { itemId: string; sequenceNumber: number }[],
): Promise<boolean> {
const checklist = await this.findById(ctx, checklistId);
if (!checklist) {
return false;
}
for (const order of itemOrders) {
await this.itemRepository.update(
{ id: order.itemId, tenantId: ctx.tenantId, checklistId },
{ sequenceNumber: order.sequenceNumber },
);
}
await this.incrementVersion(ctx, checklistId);
return true;
}
async getStatistics(ctx: ServiceContext): Promise<{
totalChecklists: number;
activeChecklists: number;
byStage: Record<string, number>;
totalItems: number;
criticalItems: number;
}> {
const checklists = await this.checklistRepository.find({
where: { tenantId: ctx.tenantId, deletedAt: undefined },
relations: ['items'],
});
const byStage: Record<string, number> = {};
let totalItems = 0;
let criticalItems = 0;
for (const cl of checklists) {
byStage[cl.stage] = (byStage[cl.stage] || 0) + 1;
if (cl.items) {
totalItems += cl.items.length;
criticalItems += cl.items.filter((i) => i.isCritical).length;
}
}
return {
totalChecklists: checklists.length,
activeChecklists: checklists.filter((c) => c.isActive).length,
byStage,
totalItems,
criticalItems,
};
}
}

View File

@ -3,5 +3,6 @@
* @module Quality * @module Quality
*/ */
export * from './checklist.service';
export * from './inspection.service'; export * from './inspection.service';
export * from './ticket.service'; export * from './ticket.service';

View File

@ -11,7 +11,11 @@ import { Inspection, InspectionStatus } from '../entities/inspection.entity';
import { InspectionResult } from '../entities/inspection-result.entity'; import { InspectionResult } from '../entities/inspection-result.entity';
import { NonConformity } from '../entities/non-conformity.entity'; import { NonConformity } from '../entities/non-conformity.entity';
import { Checklist } from '../entities/checklist.entity'; import { Checklist } from '../entities/checklist.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
export interface CreateInspectionDto { export interface CreateInspectionDto {
checklistId: string; checklistId: string;
@ -66,7 +70,7 @@ export class InspectionService {
filters: InspectionFilters = {}, filters: InspectionFilters = {},
page: number = 1, page: number = 1,
limit: number = 20 limit: number = 20
): Promise<PaginatedResult<Inspection>> { ): Promise<{ data: Inspection[]; total: number; page: number; limit: number }> {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const queryBuilder = this.inspectionRepository const queryBuilder = this.inspectionRepository
@ -106,15 +110,7 @@ export class InspectionService {
const [data, total] = await queryBuilder.getManyAndCount(); const [data, total] = await queryBuilder.getManyAndCount();
return { return { data, total, page, limit };
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
} }
async findById(ctx: ServiceContext, id: string): Promise<Inspection | null> { async findById(ctx: ServiceContext, id: string): Promise<Inspection | null> {

View File

@ -9,7 +9,11 @@
import { Repository, FindOptionsWhere, LessThan } from 'typeorm'; import { Repository, FindOptionsWhere, LessThan } from 'typeorm';
import { PostSaleTicket, TicketStatus, TicketPriority, TicketCategory } from '../entities/post-sale-ticket.entity'; import { PostSaleTicket, TicketStatus, TicketPriority, TicketCategory } from '../entities/post-sale-ticket.entity';
import { TicketAssignment } from '../entities/ticket-assignment.entity'; import { TicketAssignment } from '../entities/ticket-assignment.entity';
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
interface ServiceContext {
tenantId: string;
userId?: string;
}
export interface CreateTicketDto { export interface CreateTicketDto {
loteId: string; loteId: string;
@ -82,7 +86,7 @@ export class TicketService {
filters: TicketFilters = {}, filters: TicketFilters = {},
page: number = 1, page: number = 1,
limit: number = 20 limit: number = 20
): Promise<PaginatedResult<PostSaleTicket>> { ): Promise<{ data: PostSaleTicket[]; total: number; page: number; limit: number }> {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const queryBuilder = this.ticketRepository const queryBuilder = this.ticketRepository
@ -137,15 +141,7 @@ export class TicketService {
const [data, total] = await queryBuilder.getManyAndCount(); const [data, total] = await queryBuilder.getManyAndCount();
return { return { data, total, page, limit };
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
} }
async findById(ctx: ServiceContext, id: string): Promise<PostSaleTicket | null> { async findById(ctx: ServiceContext, id: string): Promise<PostSaleTicket | null> {