Skip to content

Commit efeac13

Browse files
committed
feat: complete PO lifecycle, add PO line items, and branch CRUD
- BE-118: TransferStatus.COMPLETED and CANCELLED are now reachable via complete() and cancel() methods on TransfersService - BE-119: PurchaseOrdersService now supports full DRAFT -> SUBMITTED -> APPROVED -> RECEIVED lifecycle with submit(), approve(), and guarded receive() - BE-120: Added PurchaseOrderLineItem entity; totalAmount is computed from line items; added update() for draft POs and cancel() for draft/submitted POs - BE-121: BranchesService gains update() and delete() with referential integrity check; BranchesController gains PATCH and DELETE routes Closes #1260 Closes #1261 Closes #1262 Closes #1263
1 parent cbee0aa commit efeac13

9 files changed

Lines changed: 250 additions & 12 deletions

backend/src/branches/branches.controller.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
1+
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
22
import {
33
ApiTags,
44
ApiOperation,
@@ -34,4 +34,18 @@ export class BranchesController {
3434
findOne(@Param('id') id: string) {
3535
return this.branchesService.findById(id);
3636
}
37+
38+
@Patch(':id')
39+
@ApiOperation({ summary: 'Update a branch' })
40+
@ApiResponse({ status: 200, description: 'Branch updated' })
41+
update(@Param('id') id: string, @Body() dto: any) {
42+
return this.branchesService.update(id, dto);
43+
}
44+
45+
@Delete(':id')
46+
@ApiOperation({ summary: 'Delete a branch' })
47+
@ApiResponse({ status: 200, description: 'Branch deleted' })
48+
delete(@Param('id') id: string) {
49+
return this.branchesService.delete(id);
50+
}
3751
}

backend/src/branches/branches.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Module } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
33
import { Branch } from './entities/branch.entity';
4+
import { Asset } from '../assets/entities/asset.entity';
45
import { BranchesService } from './branches.service';
56
import { BranchesController } from './branches.controller';
67

78
@Module({
8-
imports: [TypeOrmModule.forFeature([Branch])],
9+
imports: [TypeOrmModule.forFeature([Branch, Asset])],
910
providers: [BranchesService],
1011
controllers: [BranchesController],
1112
exports: [BranchesService],

backend/src/branches/branches.service.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
import { Injectable, NotFoundException } from '@nestjs/common';
1+
import {
2+
Injectable,
3+
NotFoundException,
4+
BadRequestException,
5+
} from '@nestjs/common';
26
import { InjectRepository } from '@nestjs/typeorm';
37
import { Repository } from 'typeorm';
48
import { Branch } from './entities/branch.entity';
9+
import { Asset } from '../assets/entities/asset.entity';
510

611
@Injectable()
712
export class BranchesService {
813
constructor(
914
@InjectRepository(Branch)
1015
private readonly branchRepo: Repository<Branch>,
16+
@InjectRepository(Asset)
17+
private readonly assetRepo: Repository<Asset>,
1118
) {}
1219

1320
async findAll() {
@@ -24,4 +31,23 @@ export class BranchesService {
2431
const branch = this.branchRepo.create(dto);
2532
return this.branchRepo.save(branch);
2633
}
34+
35+
async update(id: string, dto: Partial<Branch>) {
36+
const branch = await this.findById(id);
37+
Object.assign(branch, dto);
38+
return this.branchRepo.save(branch);
39+
}
40+
41+
async delete(id: string) {
42+
const branch = await this.findById(id);
43+
const assetCount = await this.assetRepo.count({
44+
where: { branchId: id },
45+
});
46+
if (assetCount > 0) {
47+
throw new BadRequestException(
48+
`Cannot delete branch: ${assetCount} asset(s) are still assigned to it`,
49+
);
50+
}
51+
return this.branchRepo.remove(branch);
52+
}
2753
}

backend/src/purchase-orders/entities/purchase-order.entity.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
PrimaryGeneratedColumn,
55
CreateDateColumn,
66
UpdateDateColumn,
7+
OneToMany,
78
} from 'typeorm';
89

910
export enum POStatus {
@@ -14,6 +15,33 @@ export enum POStatus {
1415
CANCELLED = 'CANCELLED',
1516
}
1617

18+
@Entity('purchase_order_line_items')
19+
export class PurchaseOrderLineItem {
20+
@PrimaryGeneratedColumn('uuid')
21+
id: string;
22+
23+
@Column()
24+
purchaseOrderId: string;
25+
26+
@Column()
27+
description: string;
28+
29+
@Column({ type: 'integer', default: 1 })
30+
quantity: number;
31+
32+
@Column({ type: 'integer', default: 0 })
33+
unitCost: number;
34+
35+
@Column({ nullable: true })
36+
category?: string;
37+
38+
@CreateDateColumn()
39+
createdAt: Date;
40+
41+
@UpdateDateColumn()
42+
updatedAt: Date;
43+
}
44+
1745
@Entity('purchase_orders')
1846
export class PurchaseOrder {
1947
@PrimaryGeneratedColumn('uuid')
@@ -37,6 +65,13 @@ export class PurchaseOrder {
3765
@Column({ nullable: true })
3866
createdByUserId?: string;
3967

68+
@OneToMany(
69+
() => PurchaseOrderLineItem,
70+
(item) => item.purchaseOrderId,
71+
{ cascade: true },
72+
)
73+
lineItems?: PurchaseOrderLineItem[];
74+
4075
@CreateDateColumn()
4176
createdAt: Date;
4277

backend/src/purchase-orders/purchase-orders.controller.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
1+
import { Controller, Get, Post, Patch, Param, Body, Req } from '@nestjs/common';
22
import {
33
ApiTags,
44
ApiOperation,
@@ -35,10 +35,39 @@ export class PurchaseOrdersController {
3535
return this.poService.findById(id);
3636
}
3737

38+
@Patch(':id')
39+
@ApiOperation({ summary: 'Update a draft purchase order' })
40+
@ApiResponse({ status: 200, description: 'Purchase order updated' })
41+
update(@Param('id') id: string, @Body() dto: any) {
42+
return this.poService.update(id, dto);
43+
}
44+
45+
@Post(':id/submit')
46+
@ApiOperation({ summary: 'Submit a draft purchase order for approval' })
47+
@ApiResponse({ status: 200, description: 'Purchase order submitted' })
48+
submit(@Param('id') id: string) {
49+
return this.poService.submit(id);
50+
}
51+
52+
@Post(':id/approve')
53+
@ApiOperation({ summary: 'Approve a submitted purchase order' })
54+
@ApiResponse({ status: 200, description: 'Purchase order approved' })
55+
approve(@Param('id') id: string, @Req() req: any) {
56+
const approverId = req.user?.id || 'usr-1';
57+
return this.poService.approve(id, approverId);
58+
}
59+
3860
@Post(':id/receive')
3961
@ApiOperation({ summary: 'Receive purchase order line items' })
4062
@ApiResponse({ status: 200, description: 'Purchase order received' })
4163
receive(@Param('id') id: string) {
4264
return this.poService.receive(id);
4365
}
66+
67+
@Post(':id/cancel')
68+
@ApiOperation({ summary: 'Cancel a draft or submitted purchase order' })
69+
@ApiResponse({ status: 200, description: 'Purchase order cancelled' })
70+
cancel(@Param('id') id: string) {
71+
return this.poService.cancel(id);
72+
}
4473
}

backend/src/purchase-orders/purchase-orders.module.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Module } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
3-
import { PurchaseOrder } from './entities/purchase-order.entity';
3+
import {
4+
PurchaseOrder,
5+
PurchaseOrderLineItem,
6+
} from './entities/purchase-order.entity';
47
import { PurchaseOrdersService } from './purchase-orders.service';
58
import { PurchaseOrdersController } from './purchase-orders.controller';
69

710
@Module({
8-
imports: [TypeOrmModule.forFeature([PurchaseOrder])],
11+
imports: [TypeOrmModule.forFeature([PurchaseOrder, PurchaseOrderLineItem])],
912
providers: [PurchaseOrdersService],
1013
controllers: [PurchaseOrdersController],
1114
exports: [PurchaseOrdersService],
Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,131 @@
1-
import { Injectable, NotFoundException } from '@nestjs/common';
1+
import {
2+
Injectable,
3+
NotFoundException,
4+
BadRequestException,
5+
} from '@nestjs/common';
26
import { InjectRepository } from '@nestjs/typeorm';
37
import { Repository } from 'typeorm';
4-
import { PurchaseOrder, POStatus } from './entities/purchase-order.entity';
8+
import {
9+
PurchaseOrder,
10+
PurchaseOrderLineItem,
11+
POStatus,
12+
} from './entities/purchase-order.entity';
513

614
@Injectable()
715
export class PurchaseOrdersService {
816
constructor(
917
@InjectRepository(PurchaseOrder)
1018
private readonly poRepo: Repository<PurchaseOrder>,
19+
@InjectRepository(PurchaseOrderLineItem)
20+
private readonly lineItemRepo: Repository<PurchaseOrderLineItem>,
1121
) {}
1222

1323
async findAll() {
14-
return this.poRepo.find();
24+
return this.poRepo.find({ relations: ['lineItems'] });
1525
}
1626

1727
async findById(id: string) {
18-
const po = await this.poRepo.findOne({ where: { id } });
28+
const po = await this.poRepo.findOne({
29+
where: { id },
30+
relations: ['lineItems'],
31+
});
1932
if (!po) throw new NotFoundException(`PO ${id} not found`);
2033
return po;
2134
}
2235

23-
async create(dto: Partial<PurchaseOrder>) {
36+
async create(dto: Partial<PurchaseOrder> & { lineItems?: Partial<PurchaseOrderLineItem>[] }) {
2437
const count = await this.poRepo.count();
2538
const poNumber = dto.poNumber || `PO-${String(count + 1).padStart(5, '0')}`;
26-
const po = this.poRepo.create({ ...dto, poNumber });
39+
const { lineItems, ...poData } = dto;
40+
const po = this.poRepo.create({ ...poData, poNumber, status: POStatus.DRAFT });
41+
const saved = await this.poRepo.save(po);
42+
43+
if (lineItems && lineItems.length > 0) {
44+
const items = lineItems.map((item) =>
45+
this.lineItemRepo.create({ ...item, purchaseOrderId: saved.id }),
46+
);
47+
await this.lineItemRepo.save(items);
48+
saved.totalAmount = items.reduce(
49+
(sum, item) => sum + item.quantity * item.unitCost,
50+
0,
51+
);
52+
await this.poRepo.save(saved);
53+
}
54+
55+
return this.findById(saved.id);
56+
}
57+
58+
async update(
59+
id: string,
60+
dto: Partial<PurchaseOrder> & { lineItems?: Partial<PurchaseOrderLineItem>[] },
61+
) {
62+
const po = await this.findById(id);
63+
if (po.status !== POStatus.DRAFT) {
64+
throw new BadRequestException('Only draft purchase orders can be edited');
65+
}
66+
67+
const { lineItems, ...poData } = dto;
68+
Object.assign(po, poData);
69+
70+
if (lineItems) {
71+
await this.lineItemRepo.delete({ purchaseOrderId: id });
72+
const items = lineItems.map((item) =>
73+
this.lineItemRepo.create({ ...item, purchaseOrderId: id }),
74+
);
75+
await this.lineItemRepo.save(items);
76+
po.totalAmount = items.reduce(
77+
(sum, item) => sum + item.quantity * item.unitCost,
78+
0,
79+
);
80+
}
81+
82+
await this.poRepo.save(po);
83+
return this.findById(id);
84+
}
85+
86+
async submit(id: string) {
87+
const po = await this.findById(id);
88+
if (po.status !== POStatus.DRAFT) {
89+
throw new BadRequestException('Only draft purchase orders can be submitted');
90+
}
91+
po.status = POStatus.SUBMITTED;
92+
return this.poRepo.save(po);
93+
}
94+
95+
async approve(id: string, approverUserId: string) {
96+
const po = await this.findById(id);
97+
if (po.status !== POStatus.SUBMITTED) {
98+
throw new BadRequestException(
99+
'Only submitted purchase orders can be approved',
100+
);
101+
}
102+
po.status = POStatus.APPROVED;
103+
po.createdByUserId = approverUserId;
27104
return this.poRepo.save(po);
28105
}
29106

30107
async receive(id: string) {
31108
const po = await this.findById(id);
109+
if (po.status !== POStatus.APPROVED) {
110+
throw new BadRequestException(
111+
'Only approved purchase orders can be received',
112+
);
113+
}
32114
po.status = POStatus.RECEIVED;
33115
return this.poRepo.save(po);
34116
}
117+
118+
async cancel(id: string) {
119+
const po = await this.findById(id);
120+
if (
121+
po.status !== POStatus.DRAFT &&
122+
po.status !== POStatus.SUBMITTED
123+
) {
124+
throw new BadRequestException(
125+
'Only draft or submitted purchase orders can be cancelled',
126+
);
127+
}
128+
po.status = POStatus.CANCELLED;
129+
return this.poRepo.save(po);
130+
}
35131
}

backend/src/transfers/transfers.controller.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,18 @@ export class TransfersController {
4949
reject(@Param('id') id: string, @Body('reason') reason: string) {
5050
return this.transfersService.reject(id, reason || 'Rejected by manager');
5151
}
52+
53+
@Post(':id/cancel')
54+
@ApiOperation({ summary: 'Cancel a pending asset transfer' })
55+
@ApiResponse({ status: 200, description: 'Transfer cancelled' })
56+
cancel(@Param('id') id: string) {
57+
return this.transfersService.cancel(id);
58+
}
59+
60+
@Post(':id/complete')
61+
@ApiOperation({ summary: 'Mark an approved transfer as completed' })
62+
@ApiResponse({ status: 200, description: 'Transfer completed' })
63+
complete(@Param('id') id: string) {
64+
return this.transfersService.complete(id);
65+
}
5266
}

backend/src/transfers/transfers.service.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,24 @@ export class TransfersService {
5454
tr.rejectionReason = reason;
5555
return this.transferRepo.save(tr);
5656
}
57+
58+
async cancel(id: string) {
59+
const tr = await this.findById(id);
60+
if (tr.status !== TransferStatus.PENDING) {
61+
throw new BadRequestException('Only pending transfers can be cancelled');
62+
}
63+
tr.status = TransferStatus.CANCELLED;
64+
return this.transferRepo.save(tr);
65+
}
66+
67+
async complete(id: string) {
68+
const tr = await this.findById(id);
69+
if (tr.status !== TransferStatus.APPROVED) {
70+
throw new BadRequestException(
71+
'Only approved transfers can be completed',
72+
);
73+
}
74+
tr.status = TransferStatus.COMPLETED;
75+
return this.transferRepo.save(tr);
76+
}
5777
}

0 commit comments

Comments
 (0)