Skip to content

Commit 4f09102

Browse files
Merge pull request #286 from nafiuishaaq/assetTransfer
Asset transfer
2 parents 119e356 + f9b3068 commit 4f09102

23 files changed

+888
-3
lines changed

backend/inventory-items/entities/inventory-item.entity.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export class InventoryItem {
1414
@Column({ type: 'int', default: 10 })
1515
reorderLevel: number; // This is the threshold for reordering
1616

17+
@Column({ type: 'int', nullable: true })
18+
currentDepartmentId: number;
19+
1720
@CreateDateColumn()
1821
createdAt: Date;
1922

backend/src/app.module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { User } from './users/entities/user.entity';
3232
}),
3333
AssetCategoriesModule,
3434
DepartmentsModule,
35+
AssetTransfersModule,
3536
UsersModule,
3637
],
3738
controllers: [AppController],
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Controller, Post, Body } from '@nestjs/common';
2+
import { AssetTransfersService } from './asset-transfers.service';
3+
import { InitiateTransferDto } from './dto/initiate-transfer.dto';
4+
5+
@Controller('asset-transfers')
6+
export class AssetTransfersController {
7+
constructor(private readonly service: AssetTransfersService) {}
8+
9+
@Post('initiate')
10+
initiate(@Body() dto: InitiateTransferDto) {
11+
return this.service.initiateTransfer(dto);
12+
}
13+
}
14+
15+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { AssetTransfersService } from './asset-transfers.service';
4+
import { AssetTransfersController } from './asset-transfers.controller';
5+
import { AssetTransfer } from './entities/asset-transfer.entity';
6+
import { InventoryItem } from '../../inventory-items/entities/inventory-item.entity';
7+
8+
@Module({
9+
imports: [TypeOrmModule.forFeature([AssetTransfer, InventoryItem])],
10+
controllers: [AssetTransfersController],
11+
providers: [AssetTransfersService],
12+
})
13+
export class AssetTransfersModule {}
14+
15+
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { AssetTransfersService } from './asset-transfers.service';
5+
import { AssetTransfer } from './entities/asset-transfer.entity';
6+
import { InventoryItem } from '../../inventory-items/entities/inventory-item.entity';
7+
import { InitiateTransferDto } from './dto/initiate-transfer.dto';
8+
9+
describe('AssetTransfersService', () => {
10+
let service: AssetTransfersService;
11+
let transferRepo: Repository<AssetTransfer>;
12+
let inventoryRepo: Repository<InventoryItem>;
13+
14+
const mockTransferRepo = {
15+
create: jest.fn(),
16+
save: jest.fn(),
17+
};
18+
19+
const mockInventoryRepo = {
20+
findOne: jest.fn(),
21+
save: jest.fn(),
22+
};
23+
24+
beforeEach(async () => {
25+
const module: TestingModule = await Test.createTestingModule({
26+
providers: [
27+
AssetTransfersService,
28+
{ provide: getRepositoryToken(AssetTransfer), useValue: mockTransferRepo },
29+
{ provide: getRepositoryToken(InventoryItem), useValue: mockInventoryRepo },
30+
],
31+
}).compile();
32+
33+
service = module.get<AssetTransfersService>(AssetTransfersService);
34+
transferRepo = module.get<Repository<AssetTransfer>>(getRepositoryToken(AssetTransfer));
35+
inventoryRepo = module.get<Repository<InventoryItem>>(getRepositoryToken(InventoryItem));
36+
jest.clearAllMocks();
37+
});
38+
39+
it('should be defined', () => {
40+
expect(service).toBeDefined();
41+
expect(transferRepo).toBeDefined();
42+
expect(inventoryRepo).toBeDefined();
43+
});
44+
45+
it('updates asset currentDepartmentId and logs transfer with previous fromDepartmentId', async () => {
46+
const assetId = 'a-uuid';
47+
const dto: InitiateTransferDto = {
48+
assetId,
49+
toDepartmentId: 20,
50+
initiatedBy: 'john.doe',
51+
reason: 'Relocation',
52+
} as InitiateTransferDto;
53+
54+
const asset: Partial<InventoryItem> = {
55+
id: assetId,
56+
currentDepartmentId: 10,
57+
};
58+
59+
const createdTransfer: Partial<AssetTransfer> = {
60+
id: 1,
61+
assetId,
62+
fromDepartmentId: 10,
63+
toDepartmentId: 20,
64+
transferDate: new Date(),
65+
initiatedBy: dto.initiatedBy,
66+
reason: dto.reason,
67+
};
68+
69+
mockInventoryRepo.findOne.mockResolvedValue(asset);
70+
mockTransferRepo.create.mockImplementation((data) => ({ id: 1, ...data }));
71+
mockTransferRepo.save.mockImplementation((data) => data);
72+
mockInventoryRepo.save.mockImplementation((data) => data);
73+
74+
const result = await service.initiateTransfer(dto);
75+
76+
expect(mockInventoryRepo.findOne).toHaveBeenCalledWith({ where: { id: assetId } });
77+
expect(mockInventoryRepo.save).toHaveBeenCalledWith({ ...asset, currentDepartmentId: 20 });
78+
expect(mockTransferRepo.create).toHaveBeenCalledWith(
79+
expect.objectContaining({
80+
assetId,
81+
fromDepartmentId: 10,
82+
toDepartmentId: 20,
83+
initiatedBy: 'john.doe',
84+
reason: 'Relocation',
85+
}),
86+
);
87+
expect(result).toEqual(expect.objectContaining({ id: 1, assetId }));
88+
});
89+
90+
it('prefers provided fromDepartmentId when passed in dto', async () => {
91+
const assetId = 'a-uuid';
92+
const dto: InitiateTransferDto = {
93+
assetId,
94+
fromDepartmentId: 5,
95+
toDepartmentId: 7,
96+
initiatedBy: 'ops',
97+
} as InitiateTransferDto;
98+
99+
mockInventoryRepo.findOne.mockResolvedValue({ id: assetId, currentDepartmentId: 10 });
100+
mockTransferRepo.create.mockImplementation((data) => ({ id: 2, ...data }));
101+
mockTransferRepo.save.mockImplementation((data) => data);
102+
mockInventoryRepo.save.mockImplementation((data) => data);
103+
104+
const result = await service.initiateTransfer(dto);
105+
106+
expect(mockTransferRepo.create).toHaveBeenCalledWith(
107+
expect.objectContaining({ fromDepartmentId: 5, toDepartmentId: 7 }),
108+
);
109+
expect(result).toEqual(expect.objectContaining({ id: 2 }));
110+
});
111+
112+
it('throws when asset not found', async () => {
113+
const dto: InitiateTransferDto = {
114+
assetId: 'missing',
115+
toDepartmentId: 1,
116+
initiatedBy: 'ops',
117+
} as InitiateTransferDto;
118+
mockInventoryRepo.findOne.mockResolvedValue(undefined);
119+
await expect(service.initiateTransfer(dto)).rejects.toThrow('Asset missing not found');
120+
});
121+
});
122+
123+
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Injectable, NotFoundException } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { AssetTransfer } from './entities/asset-transfer.entity';
5+
import { InitiateTransferDto } from './dto/initiate-transfer.dto';
6+
import { InventoryItem } from '../../inventory-items/entities/inventory-item.entity';
7+
8+
@Injectable()
9+
export class AssetTransfersService {
10+
constructor(
11+
@InjectRepository(AssetTransfer)
12+
private readonly transferRepository: Repository<AssetTransfer>,
13+
@InjectRepository(InventoryItem)
14+
private readonly inventoryRepository: Repository<InventoryItem>,
15+
) {}
16+
17+
async initiateTransfer(dto: InitiateTransferDto): Promise<AssetTransfer> {
18+
const asset = await this.inventoryRepository.findOne({ where: { id: dto.assetId } });
19+
if (!asset) {
20+
throw new NotFoundException(`Asset ${dto.assetId} not found`);
21+
}
22+
23+
// Capture original department before update
24+
const previousDepartmentId = (asset as any).currentDepartmentId ?? null;
25+
26+
// Update asset ownership (department)
27+
(asset as any).currentDepartmentId = dto.toDepartmentId;
28+
await this.inventoryRepository.save(asset);
29+
30+
const transfer = this.transferRepository.create({
31+
assetId: dto.assetId,
32+
fromDepartmentId: dto.fromDepartmentId ?? previousDepartmentId,
33+
toDepartmentId: dto.toDepartmentId,
34+
transferDate: new Date(),
35+
initiatedBy: dto.initiatedBy,
36+
reason: dto.reason,
37+
});
38+
return await this.transferRepository.save(transfer);
39+
}
40+
}
41+
42+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { IsUUID, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
2+
3+
export class InitiateTransferDto {
4+
@IsUUID()
5+
@IsNotEmpty()
6+
assetId: string;
7+
8+
@IsInt()
9+
@IsOptional()
10+
fromDepartmentId?: number;
11+
12+
@IsInt()
13+
@IsNotEmpty()
14+
toDepartmentId: number;
15+
16+
@IsString()
17+
@IsNotEmpty()
18+
@MaxLength(255)
19+
initiatedBy: string;
20+
21+
@IsString()
22+
@IsOptional()
23+
reason?: string;
24+
}
25+
26+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
2+
3+
@Entity('asset_transfers')
4+
export class AssetTransfer {
5+
@PrimaryGeneratedColumn()
6+
id: number;
7+
8+
@Column({ type: 'uuid' })
9+
assetId: string;
10+
11+
@Column({ type: 'int', nullable: true })
12+
fromDepartmentId: number;
13+
14+
@Column({ type: 'int' })
15+
toDepartmentId: number;
16+
17+
@Column({ type: 'timestamp' })
18+
transferDate: Date;
19+
20+
@Column({ type: 'varchar', length: 255 })
21+
initiatedBy: string;
22+
23+
@Column({ type: 'text', nullable: true })
24+
reason: string;
25+
26+
@CreateDateColumn()
27+
createdAt: Date;
28+
}
29+
30+
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe } from '@nestjs/common';
2+
import { BranchesService } from './branches.service';
3+
import { CreateBranchDto } from './dto/create-branch.dto';
4+
import { UpdateBranchDto } from './dto/update-branch.dto';
5+
6+
@Controller('branches')
7+
export class BranchesController {
8+
constructor(private readonly branchesService: BranchesService) {}
9+
10+
@Post()
11+
create(@Body() createBranchDto: CreateBranchDto) {
12+
return this.branchesService.create(createBranchDto);
13+
}
14+
15+
@Get()
16+
findAll() {
17+
return this.branchesService.findAll();
18+
}
19+
20+
@Get(':id')
21+
findOne(@Param('id', ParseIntPipe) id: number) {
22+
return this.branchesService.findOne(id);
23+
}
24+
25+
@Patch(':id')
26+
update(
27+
@Param('id', ParseIntPipe) id: number,
28+
@Body() updateBranchDto: UpdateBranchDto,
29+
) {
30+
return this.branchesService.update(id, updateBranchDto);
31+
}
32+
33+
@Delete(':id')
34+
remove(@Param('id', ParseIntPipe) id: number) {
35+
return this.branchesService.remove(id);
36+
}
37+
}
38+
39+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { BranchesService } from './branches.service';
4+
import { BranchesController } from './branches.controller';
5+
import { Branch } from './entities/branch.entity';
6+
import { Company } from '../companies/entities/company.entity';
7+
8+
@Module({
9+
imports: [TypeOrmModule.forFeature([Branch, Company])],
10+
controllers: [BranchesController],
11+
providers: [BranchesService],
12+
exports: [BranchesService],
13+
})
14+
export class BranchesModule {}
15+
16+

0 commit comments

Comments
 (0)