@@ -0,0 +1,172 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto';
|
||||
|
||||
const adjustmentSelect = {
|
||||
id: true,
|
||||
productId: true,
|
||||
productName: true,
|
||||
type: true,
|
||||
quantity: true,
|
||||
note: true,
|
||||
requestedBy: true,
|
||||
status: true,
|
||||
stockAfter: true,
|
||||
result: true,
|
||||
createdAt: true,
|
||||
processedAt: true,
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class StockService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
private realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
async findAll(venueId: string) {
|
||||
const [products, pending] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
where: { venueId },
|
||||
orderBy: [{ category: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
this.prisma.stockAdjustment.findMany({
|
||||
where: { venueId, status: 'pending' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: adjustmentSelect,
|
||||
}),
|
||||
]);
|
||||
|
||||
const pendingByProduct = new Map<string, typeof pending>();
|
||||
for (const adjustment of pending) {
|
||||
pendingByProduct.set(adjustment.productId, [...(pendingByProduct.get(adjustment.productId) ?? []), adjustment]);
|
||||
}
|
||||
|
||||
return {
|
||||
products: products.map((p) => ({
|
||||
productId: p.productId,
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
priceInCents: p.priceInCents,
|
||||
stock: p.stock,
|
||||
lowStockThreshold: p.lowStockThreshold,
|
||||
reportedAt: p.reportedAt,
|
||||
pendingAdjustments: pendingByProduct.get(p.productId) ?? [],
|
||||
})),
|
||||
// Null until a terminal running an app version with stock has synced
|
||||
reportedAt: products.reduce<Date | null>((latest, p) => (!latest || p.reportedAt > latest ? p.reportedAt : latest), null),
|
||||
};
|
||||
}
|
||||
|
||||
async history(venueId: string, options: { productId?: string; limit: number; offset: number }) {
|
||||
const where = { venueId, ...(options.productId ? { productId: options.productId } : {}) };
|
||||
const [adjustments, total] = await Promise.all([
|
||||
this.prisma.stockAdjustment.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: options.limit,
|
||||
skip: options.offset,
|
||||
select: adjustmentSelect,
|
||||
}),
|
||||
this.prisma.stockAdjustment.count({ where }),
|
||||
]);
|
||||
return { adjustments, total };
|
||||
}
|
||||
|
||||
async createAdjustments(venueId: string, entries: StockAdjustmentEntryDto[], user: { id: string; email: string; name?: string }) {
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'set' && entry.quantity < 0) {
|
||||
throw new BadRequestException({ code: 'invalid_quantity', message: 'A stock count cannot be negative' });
|
||||
}
|
||||
if (entry.type === 'add' && entry.quantity === 0) {
|
||||
throw new BadRequestException({ code: 'invalid_quantity', message: 'Adding zero changes nothing' });
|
||||
}
|
||||
}
|
||||
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { venueId, productId: { in: entries.map((e) => e.productId) } },
|
||||
select: { productId: true, name: true, stock: true },
|
||||
});
|
||||
const productsById = new Map(products.map((p) => [p.productId, p]));
|
||||
|
||||
for (const entry of entries) {
|
||||
const product = productsById.get(entry.productId);
|
||||
if (!product) {
|
||||
throw new NotFoundException({ code: 'product_not_found', message: 'This product is not on the terminal' });
|
||||
}
|
||||
// Counting is how tracking starts; adding to an untracked product has nothing to add to
|
||||
if (entry.type === 'add' && product.stock === null) {
|
||||
throw new BadRequestException({
|
||||
code: 'stock_not_tracked',
|
||||
message: `Stock is not tracked for ${product.name}. Count it first.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const requestedBy = user.name || user.email;
|
||||
const created = await this.prisma.$transaction(
|
||||
entries.map((entry) => this.prisma.stockAdjustment.create({
|
||||
data: {
|
||||
venueId,
|
||||
productId: entry.productId,
|
||||
productName: productsById.get(entry.productId)!.name,
|
||||
type: entry.type,
|
||||
quantity: entry.quantity,
|
||||
note: entry.note ?? null,
|
||||
requestedBy,
|
||||
requestedById: user.id,
|
||||
},
|
||||
select: adjustmentSelect,
|
||||
})),
|
||||
);
|
||||
|
||||
await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustments_created', {
|
||||
adjustments: created.map((a) => ({ id: a.id, productId: a.productId, type: a.type, quantity: a.quantity })),
|
||||
});
|
||||
|
||||
this.realtime.notify(venueId, 'stock');
|
||||
|
||||
return { adjustments: created };
|
||||
}
|
||||
|
||||
async cancelAdjustment(venueId: string, id: string, user: { id: string }) {
|
||||
const { count } = await this.prisma.stockAdjustment.updateMany({
|
||||
where: { id, venueId, status: 'pending' },
|
||||
data: { status: 'cancelled', processedAt: new Date() },
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
const existing = await this.prisma.stockAdjustment.findFirst({ where: { id, venueId } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException({ code: 'adjustment_not_found', message: 'Stock adjustment not found' });
|
||||
}
|
||||
if (existing.status === 'cancelled') return { ok: true }; // Idempotent
|
||||
throw new ConflictException({
|
||||
code: 'adjustment_not_pending',
|
||||
message: 'The terminal already processed this stock adjustment',
|
||||
});
|
||||
}
|
||||
|
||||
await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustment_cancelled', { adjustmentId: id });
|
||||
this.realtime.notify(venueId, 'stock');
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async updateProduct(venueId: string, productId: string, lowStockThreshold: number | null) {
|
||||
const { count } = await this.prisma.product.updateMany({
|
||||
where: { venueId, productId },
|
||||
data: { lowStockThreshold },
|
||||
});
|
||||
if (count === 0) {
|
||||
throw new NotFoundException({ code: 'product_not_found', message: 'This product is not on the terminal' });
|
||||
}
|
||||
|
||||
this.realtime.notify(venueId, 'stock');
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user