Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/abdulrcrtw.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe('abdulrcrtw Modules (BE-92, BE-91, BE-90, BE-89)', () => {
assetId: 'ast-1',
title: 'Screen repair',
cost: 15000,
scheduledDate: '2026-01-01',
});
expect(rec.cost).toBe(15000);
});
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { InventoryModule } from './inventory/inventory.module';
import { VendorsModule } from './vendors/vendors.module';
import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module';
import { LicensesModule } from './licenses/licenses.module';
import { AuditsModule } from './audits/audits.module';
import { NotificationsModule } from './notifications/notifications.module';
import { GatewayModule } from './gateway/gateway.module';
import { AuditLogsModule } from './audit-logs/audit-logs.module';
Expand All @@ -45,6 +46,7 @@ import { AuditLogsModule } from './audit-logs/audit-logs.module';
VendorsModule,
PurchaseOrdersModule,
LicensesModule,
AuditsModule,
NotificationsModule,
GatewayModule,
AuditLogsModule,
Expand Down
54 changes: 54 additions & 0 deletions backend/src/audits/audits.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AuditsService } from './audits.service';
import { CreateAuditSessionDto } from './dto/create-audit-session.dto';
import { RecordAuditItemDto } from './dto/record-audit-item.dto';

@ApiTags('audits')
@Controller('audits')
export class AuditsController {
constructor(private readonly auditsService: AuditsService) {}

@Get()
@ApiOperation({
summary: 'List audit sessions with scope, status and progress',
})
findAll() {
return this.auditsService.findAll();
}

@Post()
@ApiOperation({
summary: 'Start an audit session (generates the expected-assets checklist)',
})
create(@Body() dto: CreateAuditSessionDto) {
return this.auditsService.create(dto);
}

@Get(':id')
@ApiOperation({
summary: 'Get an audit session with its checklist and discrepancy summary',
})
findOne(@Param('id') id: string) {
return this.auditsService.findById(id);
}

@Patch(':id/items/:itemId')
@ApiOperation({
summary:
'Record a checklist item result (Found / Missing / Wrong location / Damaged)',
})
recordItem(
@Param('id') id: string,
@Param('itemId') itemId: string,
@Body() dto: RecordAuditItemDto,
) {
return this.auditsService.recordItem(id, itemId, dto);
}

@Post(':id/complete')
@ApiOperation({ summary: 'Complete the audit session (becomes immutable)' })
complete(@Param('id') id: string) {
return this.auditsService.complete(id);
}
}
15 changes: 15 additions & 0 deletions backend/src/audits/audits.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditSession } from './entities/audit-session.entity';
import { AuditItem } from './entities/audit-item.entity';
import { Asset } from '../assets/entities/asset.entity';
import { AuditsService } from './audits.service';
import { AuditsController } from './audits.controller';

@Module({
imports: [TypeOrmModule.forFeature([AuditSession, AuditItem, Asset])],
providers: [AuditsService],
controllers: [AuditsController],
exports: [AuditsService],
})
export class AuditsModule {}
153 changes: 153 additions & 0 deletions backend/src/audits/audits.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
AuditSession,
AuditSessionStatus,
} from './entities/audit-session.entity';
import { AuditItem, AuditItemResult } from './entities/audit-item.entity';
import { Asset } from '../assets/entities/asset.entity';
import { CreateAuditSessionDto } from './dto/create-audit-session.dto';
import { RecordAuditItemDto } from './dto/record-audit-item.dto';

@Injectable()
export class AuditsService {
constructor(
@InjectRepository(AuditSession)
private readonly sessionRepo: Repository<AuditSession>,
@InjectRepository(AuditItem)
private readonly itemRepo: Repository<AuditItem>,
@InjectRepository(Asset)
private readonly assetRepo: Repository<Asset>,
) {}

private summarize(session: AuditSession, items: AuditItem[]) {
const total = items.length;
const checked = items.filter(
(i) => i.result !== AuditItemResult.PENDING,
).length;
const discrepancies = items.filter(
(i) =>
i.result !== AuditItemResult.PENDING &&
i.result !== AuditItemResult.FOUND,
);
return {
...session,
totalItems: total,
checkedItems: checked,
progressPercent: total === 0 ? 0 : Math.round((checked / total) * 100),
discrepancyCount: discrepancies.length,
};
}

async findAll() {
const sessions = await this.sessionRepo.find({
order: { createdAt: 'DESC' },
});
const results = [];
for (const session of sessions) {
const items = await this.itemRepo.find({
where: { auditSessionId: session.id },
});
results.push(this.summarize(session, items));
}
return results;
}

async findById(id: string) {
const session = await this.sessionRepo.findOne({ where: { id } });
if (!session) throw new NotFoundException(`Audit session ${id} not found`);
const items = await this.itemRepo.find({
where: { auditSessionId: id },
order: { createdAt: 'ASC' },
});
return { ...this.summarize(session, items), items };
}

async create(dto: CreateAuditSessionDto, actorId?: string) {
if (!dto.departmentId && !dto.locationId) {
throw new BadRequestException(
'An audit session needs a department or location scope',
);
}

const qb = this.assetRepo.createQueryBuilder('asset');
if (dto.departmentId)
qb.andWhere('asset.departmentId = :d', { d: dto.departmentId });
if (dto.locationId)
qb.andWhere('asset.locationId = :l', { l: dto.locationId });
const assets = await qb.getMany();

if (assets.length === 0) {
throw new BadRequestException('No assets match the selected scope');
}

const session = this.sessionRepo.create({
name: dto.name,
scopeDepartmentId: dto.departmentId,
scopeLocationId: dto.locationId,
createdByUserId: actorId,
status: AuditSessionStatus.DRAFT,
});
const savedSession = await this.sessionRepo.save(session);

const items = assets.map((asset) =>
this.itemRepo.create({
auditSessionId: savedSession.id,
assetId: asset.id,
expectedLocationId: asset.locationId,
result: AuditItemResult.PENDING,
}),
);
await this.itemRepo.save(items);

return this.findById(savedSession.id);
}

private async getMutableSession(id: string): Promise<AuditSession> {
const session = await this.sessionRepo.findOne({ where: { id } });
if (!session) throw new NotFoundException(`Audit session ${id} not found`);
if (session.status === AuditSessionStatus.COMPLETED) {
throw new BadRequestException(
'This audit session is completed and can no longer be edited',
);
}
return session;
}

async recordItem(sessionId: string, itemId: string, dto: RecordAuditItemDto) {
const session = await this.getMutableSession(sessionId);

const item = await this.itemRepo.findOne({
where: { id: itemId, auditSessionId: sessionId },
});
if (!item)
throw new NotFoundException(
`Audit item ${itemId} not found in this session`,
);

item.result = dto.result;
item.note = dto.note;
item.checkedAt = new Date();
await this.itemRepo.save(item);

if (session.status === AuditSessionStatus.DRAFT) {
session.status = AuditSessionStatus.IN_PROGRESS;
await this.sessionRepo.save(session);
}

return this.findById(sessionId);
}

async complete(sessionId: string) {
const session = await this.getMutableSession(sessionId);
session.status = AuditSessionStatus.COMPLETED;
session.completedAt = new Date();
await this.sessionRepo.save(session);
return this.findById(sessionId);
}
}
14 changes: 14 additions & 0 deletions backend/src/audits/dto/create-audit-session.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IsOptional, IsString } from 'class-validator';

export class CreateAuditSessionDto {
@IsString()
name: string;

@IsOptional()
@IsString()
departmentId?: string;

@IsOptional()
@IsString()
locationId?: string;
}
11 changes: 11 additions & 0 deletions backend/src/audits/dto/record-audit-item.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { AuditItemResult } from '../entities/audit-item.entity';

export class RecordAuditItemDto {
@IsEnum(AuditItemResult)
result: AuditItemResult;

@IsOptional()
@IsString()
note?: string;
}
48 changes: 48 additions & 0 deletions backend/src/audits/entities/audit-item.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
} from 'typeorm';

export enum AuditItemResult {
PENDING = 'PENDING',
FOUND = 'FOUND',
MISSING = 'MISSING',
WRONG_LOCATION = 'WRONG_LOCATION',
DAMAGED = 'DAMAGED',
}

@Entity('audit_items')
export class AuditItem {
@PrimaryGeneratedColumn('uuid')
id: string;

@Index()
@Column()
auditSessionId: string;

@Column()
assetId: string;

/** Snapshot of the asset's expected location at the time the checklist was generated. */
@Column({ nullable: true })
expectedLocationId?: string;

@Column({
type: 'enum',
enum: AuditItemResult,
default: AuditItemResult.PENDING,
})
result: AuditItemResult;

@Column({ nullable: true })
note?: string;

@Column({ nullable: true })
checkedAt?: Date;

@CreateDateColumn()
createdAt: Date;
}
47 changes: 47 additions & 0 deletions backend/src/audits/entities/audit-session.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';

export enum AuditSessionStatus {
DRAFT = 'DRAFT',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
}

@Entity('audit_sessions')
export class AuditSession {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
name: string;

@Column({
type: 'enum',
enum: AuditSessionStatus,
default: AuditSessionStatus.DRAFT,
})
status: AuditSessionStatus;

@Column({ nullable: true })
scopeDepartmentId?: string;

@Column({ nullable: true })
scopeLocationId?: string;

@Column({ nullable: true })
createdByUserId?: string;

@Column({ nullable: true })
completedAt?: Date;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
5 changes: 4 additions & 1 deletion backend/src/femaleotaku.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ describe('femaleotaku Modules (BE-88, BE-87, BE-85, BE-84)', () => {
.fn()
.mockImplementation((dto) => Promise.resolve({ id: 'loc-1', ...dto })),
};
const service = new LocationsService(mockRepo as any);
const mockAssetRepo = {
count: jest.fn().mockResolvedValue(0),
};
const service = new LocationsService(mockRepo as any, mockAssetRepo as any);
const loc = await service.create({ name: 'Building A', code: 'BLD-A' });
expect(loc.name).toBe('Building A');
});
Expand Down
7 changes: 5 additions & 2 deletions backend/src/ibinola.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,12 @@ describe('ibinola Modules (BE-96, BE-95, BE-94, BE-93)', () => {
findOne: jest.fn().mockResolvedValue(lic),
save: jest.fn().mockImplementation((dto) => Promise.resolve(dto)),
};
const service = new LicensesService(mockRepo as any);
const mockSeatRepo = {
findOne: jest.fn().mockResolvedValue(null),
};
const service = new LicensesService(mockRepo as any, mockSeatRepo as any);

await expect(service.assign('lic-1', 'u-1')).rejects.toThrow(
await expect(service.assign('lic-1', { userId: 'u-1' })).rejects.toThrow(
'No available seats remaining for this license',
);
});
Expand Down
Loading
Loading