Skip to content
3 changes: 3 additions & 0 deletions backend/inventory-items/entities/inventory-item.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export class InventoryItem {
@Column({ type: 'int', default: 10 })
reorderLevel: number; // This is the threshold for reordering

@Column({ type: 'int', nullable: true })
currentDepartmentId: number;

@CreateDateColumn()
createdAt: Date;

Expand Down
12 changes: 11 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { AssetCategoriesModule } from './asset-categories/asset-categories.modul
import { AssetCategory } from './asset-categories/asset-category.entity';
import { DepartmentsModule } from './departments/departments.module';
import { Department } from './departments/department.entity';
import { CompaniesModule } from './companies/companies.module';
import { Company } from './companies/entities/company.entity';
import { BranchesModule } from './branches/branches.module';
import { Branch } from './branches/entities/branch.entity';
import { AssetTransfersModule } from './asset-transfers/asset-transfers.module';
import { AssetTransfer } from './asset-transfers/entities/asset-transfer.entity';
import { InventoryItem as InventoryItemTop } from '../inventory-items/entities/inventory-item.entity';

@Module({
imports: [
Expand Down Expand Up @@ -38,13 +45,16 @@ import { Department } from './departments/department.entity';
username: configService.get('DB_USERNAME', 'postgres'),
password: configService.get('DB_PASSWORD', 'password'),
database: configService.get('DB_DATABASE', 'manage_assets'),
entities: [AssetCategory, Department],
entities: [AssetCategory, Department, Company, Branch, AssetTransfer, InventoryItemTop],
synchronize: configService.get('NODE_ENV') !== 'production', // Only for development
}),
inject: [ConfigService],
}),
AssetCategoriesModule,
DepartmentsModule,
CompaniesModule,
BranchesModule,
AssetTransfersModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
15 changes: 15 additions & 0 deletions backend/src/asset-transfers/asset-transfers.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Controller, Post, Body } from '@nestjs/common';
import { AssetTransfersService } from './asset-transfers.service';
import { InitiateTransferDto } from './dto/initiate-transfer.dto';

@Controller('asset-transfers')
export class AssetTransfersController {
constructor(private readonly service: AssetTransfersService) {}

@Post('initiate')
initiate(@Body() dto: InitiateTransferDto) {
return this.service.initiateTransfer(dto);
}
}


15 changes: 15 additions & 0 deletions backend/src/asset-transfers/asset-transfers.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 { AssetTransfersService } from './asset-transfers.service';
import { AssetTransfersController } from './asset-transfers.controller';
import { AssetTransfer } from './entities/asset-transfer.entity';
import { InventoryItem } from '../../inventory-items/entities/inventory-item.entity';

@Module({
imports: [TypeOrmModule.forFeature([AssetTransfer, InventoryItem])],
controllers: [AssetTransfersController],
providers: [AssetTransfersService],
})
export class AssetTransfersModule {}


123 changes: 123 additions & 0 deletions backend/src/asset-transfers/asset-transfers.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetTransfersService } from './asset-transfers.service';
import { AssetTransfer } from './entities/asset-transfer.entity';
import { InventoryItem } from '../../inventory-items/entities/inventory-item.entity';
import { InitiateTransferDto } from './dto/initiate-transfer.dto';

describe('AssetTransfersService', () => {
let service: AssetTransfersService;
let transferRepo: Repository<AssetTransfer>;
let inventoryRepo: Repository<InventoryItem>;

const mockTransferRepo = {
create: jest.fn(),
save: jest.fn(),
};

const mockInventoryRepo = {
findOne: jest.fn(),
save: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AssetTransfersService,
{ provide: getRepositoryToken(AssetTransfer), useValue: mockTransferRepo },
{ provide: getRepositoryToken(InventoryItem), useValue: mockInventoryRepo },
],
}).compile();

service = module.get<AssetTransfersService>(AssetTransfersService);
transferRepo = module.get<Repository<AssetTransfer>>(getRepositoryToken(AssetTransfer));
inventoryRepo = module.get<Repository<InventoryItem>>(getRepositoryToken(InventoryItem));
jest.clearAllMocks();
});

it('should be defined', () => {
expect(service).toBeDefined();
expect(transferRepo).toBeDefined();
expect(inventoryRepo).toBeDefined();
});

it('updates asset currentDepartmentId and logs transfer with previous fromDepartmentId', async () => {
const assetId = 'a-uuid';
const dto: InitiateTransferDto = {
assetId,
toDepartmentId: 20,
initiatedBy: 'john.doe',
reason: 'Relocation',
} as InitiateTransferDto;

const asset: Partial<InventoryItem> = {
id: assetId,
currentDepartmentId: 10,
};

const createdTransfer: Partial<AssetTransfer> = {
id: 1,
assetId,
fromDepartmentId: 10,
toDepartmentId: 20,
transferDate: new Date(),
initiatedBy: dto.initiatedBy,
reason: dto.reason,
};

mockInventoryRepo.findOne.mockResolvedValue(asset);
mockTransferRepo.create.mockImplementation((data) => ({ id: 1, ...data }));
mockTransferRepo.save.mockImplementation((data) => data);
mockInventoryRepo.save.mockImplementation((data) => data);

const result = await service.initiateTransfer(dto);

expect(mockInventoryRepo.findOne).toHaveBeenCalledWith({ where: { id: assetId } });
expect(mockInventoryRepo.save).toHaveBeenCalledWith({ ...asset, currentDepartmentId: 20 });
expect(mockTransferRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
assetId,
fromDepartmentId: 10,
toDepartmentId: 20,
initiatedBy: 'john.doe',
reason: 'Relocation',
}),
);
expect(result).toEqual(expect.objectContaining({ id: 1, assetId }));
});

it('prefers provided fromDepartmentId when passed in dto', async () => {
const assetId = 'a-uuid';
const dto: InitiateTransferDto = {
assetId,
fromDepartmentId: 5,
toDepartmentId: 7,
initiatedBy: 'ops',
} as InitiateTransferDto;

mockInventoryRepo.findOne.mockResolvedValue({ id: assetId, currentDepartmentId: 10 });
mockTransferRepo.create.mockImplementation((data) => ({ id: 2, ...data }));
mockTransferRepo.save.mockImplementation((data) => data);
mockInventoryRepo.save.mockImplementation((data) => data);

const result = await service.initiateTransfer(dto);

expect(mockTransferRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ fromDepartmentId: 5, toDepartmentId: 7 }),
);
expect(result).toEqual(expect.objectContaining({ id: 2 }));
});

it('throws when asset not found', async () => {
const dto: InitiateTransferDto = {
assetId: 'missing',
toDepartmentId: 1,
initiatedBy: 'ops',
} as InitiateTransferDto;
mockInventoryRepo.findOne.mockResolvedValue(undefined);
await expect(service.initiateTransfer(dto)).rejects.toThrow('Asset missing not found');
});
});


42 changes: 42 additions & 0 deletions backend/src/asset-transfers/asset-transfers.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetTransfer } from './entities/asset-transfer.entity';
import { InitiateTransferDto } from './dto/initiate-transfer.dto';
import { InventoryItem } from '../../inventory-items/entities/inventory-item.entity';

@Injectable()
export class AssetTransfersService {
constructor(
@InjectRepository(AssetTransfer)
private readonly transferRepository: Repository<AssetTransfer>,
@InjectRepository(InventoryItem)
private readonly inventoryRepository: Repository<InventoryItem>,
) {}

async initiateTransfer(dto: InitiateTransferDto): Promise<AssetTransfer> {
const asset = await this.inventoryRepository.findOne({ where: { id: dto.assetId } });
if (!asset) {
throw new NotFoundException(`Asset ${dto.assetId} not found`);
}

// Capture original department before update
const previousDepartmentId = (asset as any).currentDepartmentId ?? null;

// Update asset ownership (department)
(asset as any).currentDepartmentId = dto.toDepartmentId;
await this.inventoryRepository.save(asset);

const transfer = this.transferRepository.create({
assetId: dto.assetId,
fromDepartmentId: dto.fromDepartmentId ?? previousDepartmentId,
toDepartmentId: dto.toDepartmentId,
transferDate: new Date(),
initiatedBy: dto.initiatedBy,
reason: dto.reason,
});
return await this.transferRepository.save(transfer);
}
}


26 changes: 26 additions & 0 deletions backend/src/asset-transfers/dto/initiate-transfer.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { IsUUID, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';

export class InitiateTransferDto {
@IsUUID()
@IsNotEmpty()
assetId: string;

@IsInt()
@IsOptional()
fromDepartmentId?: number;

@IsInt()
@IsNotEmpty()
toDepartmentId: number;

@IsString()
@IsNotEmpty()
@MaxLength(255)
initiatedBy: string;

@IsString()
@IsOptional()
reason?: string;
}


30 changes: 30 additions & 0 deletions backend/src/asset-transfers/entities/asset-transfer.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';

@Entity('asset_transfers')
export class AssetTransfer {
@PrimaryGeneratedColumn()
id: number;

@Column({ type: 'uuid' })
assetId: string;

@Column({ type: 'int', nullable: true })
fromDepartmentId: number;

@Column({ type: 'int' })
toDepartmentId: number;

@Column({ type: 'timestamp' })
transferDate: Date;

@Column({ type: 'varchar', length: 255 })
initiatedBy: string;

@Column({ type: 'text', nullable: true })
reason: string;

@CreateDateColumn()
createdAt: Date;
}


39 changes: 39 additions & 0 deletions backend/src/branches/branches.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe } from '@nestjs/common';
import { BranchesService } from './branches.service';
import { CreateBranchDto } from './dto/create-branch.dto';
import { UpdateBranchDto } from './dto/update-branch.dto';

@Controller('branches')
export class BranchesController {
constructor(private readonly branchesService: BranchesService) {}

@Post()
create(@Body() createBranchDto: CreateBranchDto) {
return this.branchesService.create(createBranchDto);
}

@Get()
findAll() {
return this.branchesService.findAll();
}

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.branchesService.findOne(id);
}

@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateBranchDto: UpdateBranchDto,
) {
return this.branchesService.update(id, updateBranchDto);
}

@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.branchesService.remove(id);
}
}


16 changes: 16 additions & 0 deletions backend/src/branches/branches.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BranchesService } from './branches.service';
import { BranchesController } from './branches.controller';
import { Branch } from './entities/branch.entity';
import { Company } from '../companies/entities/company.entity';

@Module({
imports: [TypeOrmModule.forFeature([Branch, Company])],
controllers: [BranchesController],
providers: [BranchesService],
exports: [BranchesService],
})
export class BranchesModule {}


Loading