Skip to content

Commit 37caa19

Browse files
committed
Merge pull request #1241 from Femaleotaku/feature/femaleotaku-assigned-fixes
feat: build locations, categories, asset status lifecycle, and history audit trail
2 parents a1964da + f367da7 commit 37caa19

13 files changed

Lines changed: 396 additions & 0 deletions

backend/src/app.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { HealthModule } from './health/health.module';
99
import { AuthModule } from './auth/auth.module';
1010
import { BranchesModule } from './branches/branches.module';
1111
import { DepartmentsModule } from './departments/departments.module';
12+
import { LocationsModule } from './locations/locations.module';
13+
import { CategoriesModule } from './categories/categories.module';
14+
import { AssetsLifecycleModule } from './assets/assets-lifecycle.module';
1215

1316
@Module({
1417
imports: [
@@ -19,6 +22,9 @@ import { DepartmentsModule } from './departments/departments.module';
1922
AuthModule,
2023
BranchesModule,
2124
DepartmentsModule,
25+
LocationsModule,
26+
CategoriesModule,
27+
AssetsLifecycleModule,
2228
TypeOrmModule.forRootAsync({
2329
imports: [ConfigModule],
2430
useFactory: (configService: ConfigService) => ({
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Injectable, BadRequestException } from '@nestjs/common';
2+
3+
export enum AssetStatus {
4+
AVAILABLE = 'AVAILABLE',
5+
ASSIGNED = 'ASSIGNED',
6+
IN_MAINTENANCE = 'IN_MAINTENANCE',
7+
IN_TRANSIT = 'IN_TRANSIT',
8+
RETIRED = 'RETIRED',
9+
DISPOSED = 'DISPOSED',
10+
LOST = 'LOST',
11+
}
12+
13+
const ALLOWED_TRANSITIONS: Record<AssetStatus, AssetStatus[]> = {
14+
[AssetStatus.AVAILABLE]: [AssetStatus.ASSIGNED, AssetStatus.IN_MAINTENANCE, AssetStatus.IN_TRANSIT, AssetStatus.RETIRED, AssetStatus.LOST],
15+
[AssetStatus.ASSIGNED]: [AssetStatus.AVAILABLE, AssetStatus.IN_MAINTENANCE, AssetStatus.IN_TRANSIT, AssetStatus.RETIRED, AssetStatus.LOST],
16+
[AssetStatus.IN_MAINTENANCE]: [AssetStatus.AVAILABLE, AssetStatus.ASSIGNED, AssetStatus.RETIRED],
17+
[AssetStatus.IN_TRANSIT]: [AssetStatus.AVAILABLE, AssetStatus.ASSIGNED, AssetStatus.LOST],
18+
[AssetStatus.RETIRED]: [AssetStatus.DISPOSED],
19+
[AssetStatus.DISPOSED]: [],
20+
[AssetStatus.LOST]: [AssetStatus.AVAILABLE, AssetStatus.RETIRED, AssetStatus.DISPOSED],
21+
};
22+
23+
@Injectable()
24+
export class AssetLifecycleService {
25+
private history = new Map<string, any[]>();
26+
27+
validateTransition(fromStatus: AssetStatus, toStatus: AssetStatus) {
28+
if (fromStatus === toStatus) return true;
29+
const allowed = ALLOWED_TRANSITIONS[fromStatus] || [];
30+
if (!allowed.includes(toStatus)) {
31+
throw new BadRequestException(`Cannot transition asset status from ${fromStatus} to ${toStatus}`);
32+
}
33+
return true;
34+
}
35+
36+
recordHistory(assetId: string, event: { eventType: string; actorUserId: string; note?: string; fieldChanges?: any }) {
37+
const list = this.history.get(assetId) || [];
38+
const entry = {
39+
id: `h_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
40+
assetId,
41+
...event,
42+
timestamp: new Date(),
43+
};
44+
list.unshift(entry);
45+
this.history.set(assetId, list);
46+
return entry;
47+
}
48+
49+
getHistory(assetId: string) {
50+
return this.history.get(assetId) || [];
51+
}
52+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Controller, Get, Patch, Param, Body, Req } from '@nestjs/common';
2+
import { ApiTags, ApiOperation } from '@nestjs/swagger';
3+
import { AssetLifecycleService, AssetStatus } from './asset-lifecycle.service';
4+
5+
@ApiTags('assets')
6+
@Controller('assets')
7+
export class AssetStatusController {
8+
constructor(private readonly lifecycleService: AssetLifecycleService) {}
9+
10+
@Patch(':id/status')
11+
@ApiOperation({ summary: 'Update asset status' })
12+
updateStatus(
13+
@Param('id') id: string,
14+
@Body() body: { currentStatus: AssetStatus; newStatus: AssetStatus; note?: string },
15+
@Req() req: any,
16+
) {
17+
this.lifecycleService.validateTransition(body.currentStatus, body.newStatus);
18+
const actorId = req.user?.id || 'usr-1';
19+
const entry = this.lifecycleService.recordHistory(id, {
20+
eventType: 'STATUS_CHANGED',
21+
actorUserId: actorId,
22+
note: body.note,
23+
fieldChanges: { status: { from: body.currentStatus, to: body.newStatus } },
24+
});
25+
return { assetId: id, status: body.newStatus, historyEntry: entry };
26+
}
27+
28+
@Get(':id/history')
29+
@ApiOperation({ summary: 'Get asset history and audit trail' })
30+
getHistory(@Param('id') id: string) {
31+
return this.lifecycleService.getHistory(id);
32+
}
33+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
import { AssetLifecycleService } from './asset-lifecycle.service';
3+
import { AssetStatusController } from './asset-status.controller';
4+
5+
@Module({
6+
providers: [AssetLifecycleService],
7+
controllers: [AssetStatusController],
8+
exports: [AssetLifecycleService],
9+
})
10+
export class AssetsLifecycleModule {}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
2+
import { ApiTags, ApiOperation } from '@nestjs/swagger';
3+
import { CategoriesService } from './categories.service';
4+
5+
@ApiTags('categories')
6+
@Controller('categories')
7+
export class CategoriesController {
8+
constructor(private readonly categoriesService: CategoriesService) {}
9+
10+
@Get()
11+
@ApiOperation({ summary: 'List all categories' })
12+
findAll() {
13+
return this.categoriesService.findAll();
14+
}
15+
16+
@Post()
17+
@ApiOperation({ summary: 'Create a category' })
18+
create(@Body() dto: any) {
19+
return this.categoriesService.create(dto);
20+
}
21+
22+
@Get(':id')
23+
@ApiOperation({ summary: 'Get category details' })
24+
findOne(@Param('id') id: string) {
25+
return this.categoriesService.findById(id);
26+
}
27+
28+
@Patch(':id')
29+
@ApiOperation({ summary: 'Update a category' })
30+
update(@Param('id') id: string, @Body() dto: any) {
31+
return this.categoriesService.update(id, dto);
32+
}
33+
34+
@Delete(':id')
35+
@ApiOperation({ summary: 'Delete a category' })
36+
delete(@Param('id') id: string) {
37+
return this.categoriesService.delete(id);
38+
}
39+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { Category } from './entities/category.entity';
4+
import { CategoriesService } from './categories.service';
5+
import { CategoriesController } from './categories.controller';
6+
7+
@Module({
8+
imports: [TypeOrmModule.forFeature([Category])],
9+
providers: [CategoriesService],
10+
controllers: [CategoriesController],
11+
exports: [CategoriesService],
12+
})
13+
export class CategoriesModule {}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { Category } from './entities/category.entity';
5+
6+
@Injectable()
7+
export class CategoriesService {
8+
constructor(
9+
@InjectRepository(Category)
10+
private readonly categoryRepo: Repository<Category>,
11+
) {}
12+
13+
async findAll() {
14+
return this.categoryRepo.find();
15+
}
16+
17+
async findById(id: string) {
18+
const cat = await this.categoryRepo.findOne({ where: { id } });
19+
if (!cat) throw new NotFoundException(`Category ${id} not found`);
20+
return cat;
21+
}
22+
23+
async create(dto: Partial<Category>) {
24+
const cat = this.categoryRepo.create(dto);
25+
return this.categoryRepo.save(cat);
26+
}
27+
28+
async update(id: string, dto: Partial<Category>) {
29+
const cat = await this.findById(id);
30+
if (dto.parentCategoryId && dto.parentCategoryId === id) {
31+
throw new BadRequestException('A category cannot be its own parent');
32+
}
33+
Object.assign(cat, dto);
34+
return this.categoryRepo.save(cat);
35+
}
36+
37+
async delete(id: string) {
38+
const cat = await this.findById(id);
39+
return this.categoryRepo.remove(cat);
40+
}
41+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
2+
3+
@Entity('categories')
4+
export class Category {
5+
@PrimaryGeneratedColumn('uuid')
6+
id: string;
7+
8+
@Column()
9+
name: string;
10+
11+
@Column({ unique: true })
12+
code: string;
13+
14+
@Column({ nullable: true })
15+
description?: string;
16+
17+
@Column({ nullable: true })
18+
parentCategoryId?: string;
19+
20+
@Column({ nullable: true })
21+
icon?: string;
22+
23+
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0 })
24+
defaultDepreciationRate: number;
25+
26+
@Column({ default: 36 })
27+
defaultUsefulLifeMonths: number;
28+
29+
@CreateDateColumn()
30+
createdAt: Date;
31+
32+
@UpdateDateColumn()
33+
updatedAt: Date;
34+
}

backend/src/femaleotaku.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { LocationsService } from './locations/locations.service';
2+
import { CategoriesService } from './categories/categories.service';
3+
import { AssetLifecycleService, AssetStatus } from './assets/asset-lifecycle.service';
4+
5+
describe('femaleotaku Modules (BE-88, BE-87, BE-85, BE-84)', () => {
6+
it('LocationsService creates location', async () => {
7+
const mockRepo = {
8+
create: jest.fn().mockImplementation((dto) => dto),
9+
save: jest.fn().mockImplementation((dto) => Promise.resolve({ id: 'loc-1', ...dto })),
10+
};
11+
const service = new LocationsService(mockRepo as any);
12+
const loc = await service.create({ name: 'Building A', code: 'BLD-A' });
13+
expect(loc.name).toBe('Building A');
14+
});
15+
16+
it('CategoriesService manages depreciation defaults', async () => {
17+
const mockRepo = {
18+
create: jest.fn().mockImplementation((dto) => dto),
19+
save: jest.fn().mockImplementation((dto) => Promise.resolve({ id: 'cat-1', ...dto })),
20+
};
21+
const service = new CategoriesService(mockRepo as any);
22+
const cat = await service.create({ name: 'Laptops', code: 'LAP', defaultDepreciationRate: 20, defaultUsefulLifeMonths: 36 });
23+
expect(cat.defaultUsefulLifeMonths).toBe(36);
24+
});
25+
26+
it('AssetLifecycleService validates state transitions and records history', () => {
27+
const service = new AssetLifecycleService();
28+
expect(service.validateTransition(AssetStatus.AVAILABLE, AssetStatus.ASSIGNED)).toBe(true);
29+
expect(() => service.validateTransition(AssetStatus.DISPOSED, AssetStatus.ASSIGNED)).toThrow(
30+
'Cannot transition asset status from DISPOSED to ASSIGNED',
31+
);
32+
33+
const history = service.recordHistory('asset-1', { eventType: 'STATUS_CHANGED', actorUserId: 'user-1', note: 'Assigned to Jane' });
34+
expect(history.id).toBeDefined();
35+
expect(service.getHistory('asset-1').length).toBe(1);
36+
});
37+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
2+
3+
export enum LocationType {
4+
BUILDING = 'BUILDING',
5+
FLOOR = 'FLOOR',
6+
ROOM = 'ROOM',
7+
STORAGE = 'STORAGE',
8+
}
9+
10+
@Entity('locations')
11+
export class Location {
12+
@PrimaryGeneratedColumn('uuid')
13+
id: string;
14+
15+
@Column()
16+
name: string;
17+
18+
@Column({ unique: true })
19+
code: string;
20+
21+
@Column({ type: 'enum', enum: LocationType, default: LocationType.BUILDING })
22+
type: LocationType;
23+
24+
@Column({ nullable: true })
25+
address?: string;
26+
27+
@Column({ nullable: true })
28+
parentLocationId?: string;
29+
30+
@Column({ nullable: true })
31+
branchId?: string;
32+
33+
@CreateDateColumn()
34+
createdAt: Date;
35+
36+
@UpdateDateColumn()
37+
updatedAt: Date;
38+
}

0 commit comments

Comments
 (0)