feat: implement POS order management system

- Added CreateOrderDto and CreateOrderItemDto for order creation.
- Developed PosOrdersController to handle order-related endpoints.
- Created PosOrdersService for business logic related to orders.
- Implemented PosOrdersWorkflow for order processing workflows.
- Established Prisma integration for database operations in orders.
- Introduced SalesInvoicesModule and related DTOs for sales invoice management.
- Enhanced SalesInvoicesService and SalesInvoicesWorkflow for invoice processing.
This commit is contained in:
2026-01-07 15:25:59 +03:30
parent b05048e62f
commit 5fd6611aca
37 changed files with 1974 additions and 291 deletions
@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
import { Type } from 'class-transformer'
import { ArrayMinSize, IsInt, IsNumber, IsOptional, IsString } from 'class-validator'
export class CreateSaleInvoiceDto {
export class CreateOrderDto {
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@@ -0,0 +1,44 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'
import { OrderStatus } from '../../../generated/prisma/enums'
import { CreateOrderDto } from './dto/create-order-item.dto'
import { PosOrdersService } from './orders.service'
@Controller('pos/:posId/orders')
export class PosOrdersController {
constructor(private readonly posOrdersService: PosOrdersService) {}
@Get('')
get(@Param('posId') posId: string) {
return this.posOrdersService.getOrders(Number(posId))
}
@Get('/held')
getHeld(@Param('posId') posId: string) {
return this.posOrdersService.getOrders(Number(posId), OrderStatus.PENDING)
}
@Post('')
async createOrder(@Param('posId') posId: string, @Body() dto: CreateOrderDto) {
return this.posOrdersService.createOrder(Number(posId), dto)
}
@Get('/:orderId')
getOrderDetails(@Param('posId') posId: string, @Param('orderId') orderId: string) {
return this.posOrdersService.getOrderDetails(Number(posId), Number(orderId))
}
@Post('/:orderId/reject')
async rejectOrder(@Param('posId') posId: string, @Param('orderId') orderId: string) {
return this.posOrdersService.rejectOrder(Number(posId), Number(orderId))
}
@Post('/:orderId/done')
async completeOrder(@Param('posId') posId: string, @Param('orderId') orderId: string) {
return this.posOrdersService.completeOrder(Number(posId), Number(orderId))
}
@Post('/:orderId/cancel')
async cancelOrder(@Param('posId') posId: string, @Param('orderId') orderId: string) {
return this.posOrdersService.cancelOrder(Number(posId), Number(orderId))
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { PrismaModule } from '../../../prisma/prisma.module'
import { SalesInvoicesModule } from '../../sales-invoices/sales-invoices.module'
import { PosOrdersController } from './orders.controller'
import { PosOrdersService } from './orders.service'
import { PosOrdersWorkflow } from './orders.workflow'
@Module({
imports: [PrismaModule, SalesInvoicesModule],
controllers: [PosOrdersController],
providers: [PosOrdersService, PosOrdersWorkflow],
})
export class PosOrdersModule {}
+145
View File
@@ -0,0 +1,145 @@
import { Injectable } from '@nestjs/common'
import { ResponseMapper } from '../../../common/response/response-mapper'
import { Prisma } from '../../../generated/prisma/client'
import { OrderStatus } from '../../../generated/prisma/enums'
import { OrderCreateInput } from '../../../generated/prisma/models'
import { PrismaService } from '../../../prisma/prisma.service'
import { CreateOrderDto } from './dto/create-order-item.dto'
import { PosOrdersWorkflow } from './orders.workflow'
@Injectable()
export class PosOrdersService {
constructor(
private prisma: PrismaService,
private posWorkflow: PosOrdersWorkflow,
) {}
async createOrder(posId: number, data: CreateOrderDto) {
const { customerId, items, ...rest } = data
const lastCode = await this.prisma.order
.findFirst({
where: { posAccountId: posId },
orderBy: { orderNumber: 'desc' },
select: { orderNumber: true },
})
.then(si => si?.orderNumber || '0')
const newCode = String(Number(lastCode) + 1)
const totalAmount = data.items.reduce(
(acc, item) => acc + item.count * item.unitPrice,
0,
)
const preparedOrder: OrderCreateInput = {
...rest,
orderNumber: newCode,
posAccount: { connect: { id: posId } },
customer: customerId ? { connect: { id: customerId } } : undefined,
totalAmount,
orderItems: {
create: items.map(item => ({
product: { connect: { id: Number(item.productId) } },
quantity: item.count,
unitPrice: item.unitPrice,
totalAmount: item.count * item.unitPrice,
})),
},
}
const item = await this.prisma.$transaction(async tx => {
const order = await tx.order.create({ data: preparedOrder })
await this.posWorkflow.onCreateOrder(tx, order.id, posId)
})
return ResponseMapper.create(item)
}
async createAndConfirmOrder(posId: number, data: CreateOrderDto) {}
async rejectOrder(posAccountId: number, orderId: number) {
const item = await this.prisma.$transaction(async tx => {
const order = await tx.order.update({
where: { id: orderId, posAccountId },
data: { status: OrderStatus.REJECTED },
})
await this.posWorkflow.onRejectOrder(tx, orderId)
return order
})
return ResponseMapper.single(item)
}
async completeOrder(posAccountId: number, orderId: number) {
const item = await this.prisma.$transaction(async tx => {
const order = await tx.order.update({
where: { id: orderId, posAccountId },
data: { status: OrderStatus.DONE },
})
await this.posWorkflow.onConfirmOrder(tx, orderId)
return order
})
return ResponseMapper.single(item)
}
async cancelOrder(posAccountId: number, orderId: number) {
const item = await this.prisma.$transaction(async tx => {
const order = await tx.order.update({
where: { id: orderId, posAccountId },
data: { status: OrderStatus.CANCELED },
})
await this.posWorkflow.onCancelOrder(tx, orderId)
return order
})
return ResponseMapper.single(item)
}
async getOrders(posId: number, status?: OrderStatus) {
const where: Prisma.OrderWhereInput = {
posAccountId: posId,
status,
}
if (status) {
where.status = status
}
const items = await this.prisma.order.findMany({
where,
include: {
customer: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
orderItems: {
include: {
product: true,
},
},
},
orderBy: { createdAt: 'desc' },
})
return ResponseMapper.list(items)
}
async getOrderDetails(posAccountId: number, orderId: number) {
const item = await this.prisma.order.findUniqueOrThrow({
where: { id: orderId, posAccountId },
include: {
customer: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
orderItems: {
include: {
product: true,
},
},
},
})
return ResponseMapper.single(item)
}
}
+87
View File
@@ -0,0 +1,87 @@
import { Injectable } from '@nestjs/common'
import { Prisma } from '../../../generated/prisma/client'
import { SalesInvoicesWorkflow } from '../../sales-invoices/sales-invoices.workflow'
@Injectable()
export class PosOrdersWorkflow {
constructor(private salesInvoicesWorkflow: SalesInvoicesWorkflow) {}
async onCreateOrder(tx: Prisma.TransactionClient, orderId: number, posId: number) {
const { inventoryId } = await tx.posAccount.findUniqueOrThrow({
where: { id: posId },
select: { inventoryId: true },
})
const orderItems = await tx.orderItem.findMany({
where: { orderId },
select: {
product: true,
quantity: true,
},
})
await tx.stockReservation.createMany({
data: orderItems.map(item => ({
orderId,
productId: item.product.id,
quantity: item.quantity,
inventoryId,
})),
})
}
async onRejectOrder(tx: Prisma.TransactionClient, orderId: number) {
const orderItemIds = await tx.orderItem
.findMany({
where: { orderId },
select: {
productId: true,
},
})
.then(items => items.map(i => i.productId))
await tx.stockReservation.deleteMany({
where: {
orderId,
productId: { in: orderItemIds },
},
})
}
async onConfirmOrder(tx: Prisma.TransactionClient, orderId: number) {
const orderItems = await tx.orderItem.findMany({
where: { orderId },
select: {
product: true,
quantity: true,
},
})
await tx.stockReservation.deleteMany({
where: {
orderId,
productId: { in: orderItems.map(i => i.product.id) },
},
})
await this.salesInvoicesWorkflow.onCreateByOrder(tx, orderId)
}
async onCancelOrder(tx: Prisma.TransactionClient, orderId: number) {
const orderItemIds = await tx.orderItem
.findMany({
where: { orderId },
select: {
productId: true,
},
})
.then(items => items.map(i => i.productId))
await tx.stockReservation.deleteMany({
where: {
orderId,
productId: { in: orderItemIds },
},
})
}
}
+1 -7
View File
@@ -1,5 +1,4 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'
import { CreateSaleInvoiceDto } from './dto/create-pos.dto'
import { Controller, Get, Param, Query } from '@nestjs/common'
import { PosService } from './pos.service'
@Controller('pos')
@@ -32,9 +31,4 @@ export class PosController {
async getProductCategories(@Param('posId') posId: string) {
return this.posService.getProductCategories(Number(posId))
}
@Post('/:posId/orders/create')
async createOrder(@Param('posId') posId: string, @Body() dto: CreateSaleInvoiceDto) {
return this.posService.createOrder(Number(posId), dto)
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'
import { PrismaModule } from '../../prisma/prisma.module'
import { PosOrdersModule } from './orders/orders.module'
import { PosController } from './pos.controller'
import { PosService } from './pos.service'
@Module({
imports: [PrismaModule],
imports: [PrismaModule, PosOrdersModule],
controllers: [PosController],
providers: [PosService],
})
+45 -88
View File
@@ -1,9 +1,6 @@
import { Injectable } from '@nestjs/common'
import { ResponseMapper } from '../../common/response/response-mapper'
import { Prisma } from '../../generated/prisma/client'
import { SalesInvoiceCreateInput } from '../../generated/prisma/models'
import { PrismaService } from '../../prisma/prisma.service'
import { CreateSaleInvoiceDto } from './dto/create-pos.dto'
@Injectable()
export class PosService {
@@ -53,67 +50,64 @@ export class PosService {
page?: number
pageSize?: number
q?: string
productIds?: number[]
} = {},
) {
const { isAvailable, page = 1, pageSize = 50, q } = options
const { isAvailable, page = 1, pageSize = 50, q, productIds } = options
const pos = await this.prisma.posAccount.findUniqueOrThrow({
where: { id: posId },
select: { inventoryId: true },
})
const inventoryId = pos.inventoryId
const query: Prisma.StockBalanceFindManyArgs = {
where: {
inventoryId,
quantity:
isAvailable === true
? { gt: 0 }
: isAvailable === false
? { lte: 0 }
: undefined,
...(q && {
product: {
OR: [{ name: { contains: q } }, { sku: { contains: q } }],
},
}),
},
orderBy: {
quantity: 'desc',
},
let sql = `
SELECT sb.productId, sb.quantity - COALESCE(SUM(sr.quantity), 0) as availableQuantity, p.id, p.name, p.sku, p.description, p.salePrice, p.categoryId
FROM Stock_Balance sb
LEFT JOIN Stock_Reservations sr ON sb.productId = sr.productId AND sb.inventoryId = sr.inventoryId
LEFT JOIN Products p ON sb.productId = p.id
WHERE sb.inventoryId = ?
`
const params: any[] = [inventoryId]
if (productIds && productIds.length > 0) {
sql += ` AND sb.productId IN (${productIds.map(() => '?').join(',')})`
params.push(...productIds)
}
const [items, count] = await this.prisma.$transaction([
this.prisma.stockBalance.findMany({
where: query.where,
include: {
product: {
select: {
id: true,
name: true,
sku: true,
salePrice: true,
category: {
select: { id: true, name: true },
},
},
},
},
orderBy: {
quantity: 'desc',
},
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.stockBalance.count({ where: query.where }),
])
if (q) {
sql += ` AND (p.name LIKE ? OR p.sku LIKE ?)`
params.push(`%${q}%`, `%${q}%`)
}
const mapped = items.map(item => ({
id: item.id,
quantity: Number(item.quantity),
product: item.product,
sql += ` GROUP BY sb.productId`
if (isAvailable !== undefined) {
sql += isAvailable
? ` HAVING availableQuantity > 0`
: ` HAVING availableQuantity <= 0`
}
sql += ` ORDER BY sb.quantity DESC`
const items = await this.prisma.$queryRawUnsafe(sql, ...params)
const mapped = (items as any[]).map(item => ({
id: item.productId,
quantity: Number(item.availableQuantity),
product: {
id: item.id,
name: item.name,
sku: item.sku,
description: item.description,
salePrice: item.salePrice,
categoryId: item.categoryId,
},
}))
return ResponseMapper.paginate(mapped, count, page, pageSize)
const count = mapped.length
const paginated = mapped.slice((page - 1) * pageSize, page * pageSize)
return ResponseMapper.paginate(paginated, count, page, pageSize)
}
async getProductCategories(posId: number) {
@@ -157,41 +151,4 @@ export class PosService {
const categories = Array.from(map.values())
return ResponseMapper.list(categories)
}
async createOrder(posId: number, data: CreateSaleInvoiceDto) {
const { customerId, items, ...res } = data
const lastCode = await this.prisma.salesInvoice
.findFirst({
where: { posAccountId: posId },
orderBy: { code: 'desc' },
select: { code: true },
})
.then(si => si?.code || '0')
const newCode = String(Number(lastCode) + 1)
const totalAmount = data.items.reduce(
(acc, item) => acc + item.count * item.unitPrice,
0,
)
const preparedOrder: SalesInvoiceCreateInput = {
...res,
code: newCode,
posAccount: { connect: { id: posId } },
customer: customerId ? { connect: { id: customerId } } : undefined,
totalAmount: totalAmount,
items: {
create: items.map(item => ({
product: { connect: { id: Number(item.productId) } },
count: item.count,
unitPrice: item.unitPrice,
total: item.count * item.unitPrice,
})),
},
}
const item = await this.prisma.salesInvoice.create({ data: preparedOrder })
return ResponseMapper.create(item)
}
}
@@ -15,7 +15,7 @@ export class UpdatePurchaseReceiptItemDto {
@IsOptional()
@Type(() => Number)
@IsNumber()
total?: number
totalAmount?: number
@IsOptional()
@Type(() => Number)
@@ -19,7 +19,7 @@ export class PurchaseReceiptItemsService {
}
payload.unitPrice = String(payload.unitPrice)
payload.count = String(payload.count)
payload.total = String(payload.total)
payload.totalAmount = String(payload.totalAmount)
const item = await this.prisma.purchaseReceiptItem.create({ data: payload })
return ResponseMapper.create(item)
}
@@ -0,0 +1,20 @@
import { Type } from 'class-transformer'
import { IsInt, IsNumber, IsOptional, IsString } from 'class-validator'
export class CreateSalesInvoiceDto {
@IsString()
code: string
@Type(() => Number)
@IsNumber()
totalAmount: number
@IsOptional()
@IsString()
description?: string
@IsOptional()
@Type(() => Number)
@IsInt()
customerId?: number
}
@@ -0,0 +1,22 @@
import { Type } from 'class-transformer'
import { IsInt, IsNumber, IsOptional, IsString } from 'class-validator'
export class UpdateSalesInvoiceDto {
@IsOptional()
@IsString()
code?: string
@IsOptional()
@Type(() => Number)
@IsNumber()
totalAmount?: number
@IsOptional()
@IsString()
description?: string
@IsOptional()
@Type(() => Number)
@IsInt()
customerId?: number | null
}
@@ -0,0 +1,32 @@
import { Controller, Delete, Get, Param } from '@nestjs/common'
import { SalesInvoicesService } from './sales-invoices.service'
@Controller('sales-invoices')
export class SalesInvoicesController {
constructor(private readonly service: SalesInvoicesService) {}
// @Post()
// create(@Body() dto: CreateSalesInvoiceDto) {
// return this.service.create(dto)
// }
@Get()
findAll() {
return this.service.findAll()
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.service.findOne(Number(id))
}
// @Patch(':id')
// update(@Param('id') id: string, @Body() dto: UpdateSalesInvoiceDto) {
// return this.service.update(Number(id), dto)
// }
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.remove(Number(id))
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { PrismaModule } from '../../prisma/prisma.module'
import { SalesInvoicesController } from './sales-invoices.controller'
import { SalesInvoicesService } from './sales-invoices.service'
import { SalesInvoicesWorkflow } from './sales-invoices.workflow'
@Module({
imports: [PrismaModule],
controllers: [SalesInvoicesController],
providers: [SalesInvoicesService, SalesInvoicesWorkflow],
exports: [SalesInvoicesWorkflow],
})
export class SalesInvoicesModule {}
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common'
import { ResponseMapper } from '../../common/response/response-mapper'
import { PrismaService } from '../../prisma/prisma.service'
@Injectable()
export class SalesInvoicesService {
constructor(private prisma: PrismaService) {}
// async create(dto: CreateSalesInvoiceDto) {
// const payload: any = { ...dto }
// if (Object.prototype.hasOwnProperty.call(payload, 'customerId')) {
// payload.customer = { connect: { id: Number(payload.customerId) } }
// delete payload.customerId
// }
// const item = await this.prisma.salesInvoice.create({ data: payload })
// return ResponseMapper.create(item)
// }
async findAll() {
const items = await this.prisma.salesInvoice.findMany({ include: { customer: true } })
return ResponseMapper.list(items)
}
async findOne(id: number) {
const item = await this.prisma.salesInvoice.findUnique({
where: { id },
include: { items: true, customer: true },
})
if (!item) return null
return ResponseMapper.single(item)
}
// async update(id: number, data: any) {
// const payload: any = { ...data }
// if (Object.prototype.hasOwnProperty.call(payload, 'customerId')) {
// if (payload.customerId === null) payload.customer = { disconnect: true }
// else payload.customer = { connect: { id: Number(payload.customerId) } }
// delete payload.customerId
// }
// const item = await this.prisma.salesInvoice.update({ where: { id }, data: payload })
// return ResponseMapper.update(item)
// }
async remove(id: number) {
const item = await this.prisma.salesInvoice.delete({ where: { id } })
return ResponseMapper.single(item)
}
}
@@ -0,0 +1,38 @@
import {
MovementReferenceType,
MovementType,
Prisma,
} from '../../generated/prisma/client'
export class SalesInvoicesWorkflow {
constructor() {}
async onCreateByOrder(tx: Prisma.TransactionClient, orderId: number) {
const { posAccount } = await tx.order.findUniqueOrThrow({
where: { id: orderId },
select: { posAccount: { select: { inventoryId: true, id: true } } },
})
const posInventoryId = posAccount.inventoryId
const orderItems = await tx.orderItem.findMany({
where: {
orderId,
},
})
await tx.stockMovement.createMany({
data: orderItems.map(item => ({
productId: item.productId,
orderId,
type: MovementType.OUT,
referenceType: MovementReferenceType.SALES,
referenceId: String(orderId),
quantity: item.quantity,
inventoryId: posInventoryId,
unitPrice: item.unitPrice,
totalCost: item.totalAmount,
avgCost: item.unitPrice,
})),
})
}
}