import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { v4 as uuidv4 } from 'uuid'; 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'; import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto'; import { VenueLedger } from '../tabs/venue-ledger'; import { applyPendingAdjustments } from './stock-adjustments'; 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, ) {} /** Adjustments are applied at once, so there is nothing pending to show with the products. */ async findAll(venueId: string) { const products = await this.prisma.product.findMany({ where: { venueId }, orderBy: [{ category: 'asc' }, { name: 'asc' }], }); return { products: products.map(productView), // Null until the venue has products reportedAt: products.reduce((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.`, }); } } // Applied by the server right away, once, also when every terminal is offline; the terminals get the new stock // on their next sync const requestedBy = user.name || user.email; const created = await this.prisma.$transaction(async (tx) => { const now = new Date(); const ledger = await VenueLedger.open(tx, venueId); const ids: string[] = []; for (const entry of entries) { const { id } = await tx.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, createdAt: now, }, select: { id: true }, }); ids.push(id); } await applyPendingAdjustments(tx, ledger, now, ids); await ledger.save(tx, now); const adjustments = await tx.stockAdjustment.findMany({ where: { id: { in: ids } }, select: adjustmentSelect }); return ids.map((id) => adjustments.find((a) => a.id === id)!); }); 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 }; } /** * Adds a product to the venue's catalog; the terminals get it on their next sync. A starting count tracks its stock * from now on and is kept in the stock history. */ async createProduct(venueId: string, dto: CreateProductDto, user: { id: string; email: string; name?: string }) { const product = await this.prisma.$transaction(async (tx) => { const now = new Date(); // Locked like a sync, so a name check and the stock can't race with one await VenueLedger.open(tx, venueId); await this.ensureNameFree(tx, venueId, dto.name); const stock = dto.stock ?? null; const created = await tx.product.create({ data: { venueId, productId: uuidv4(), name: dto.name, category: dto.category ?? null, priceInCents: dto.priceInCents, stock, countedAt: stock === null ? null : now, lowStockThreshold: dto.lowStockThreshold ?? null, reportedAt: now, }, }); if (stock !== null) { await tx.stockAdjustment.create({ data: { venueId, productId: created.productId, productName: created.name, type: 'set', quantity: stock, requestedBy: user.name || user.email, requestedById: user.id, status: 'applied', stockAfter: stock, createdAt: now, processedAt: now, }, }); } return created; }); await this.auditService.log(venueId, 'client_user', user.id, 'product_created', { productId: product.productId, name: product.name, priceInCents: product.priceInCents, }); this.realtime.notify(venueId, 'stock'); return productView(product); } /** * Changes a product's name, category, price, warning level, or archives it (a deleted product stays, hidden, because * tabs and history refer to it). Lines already on a tab keep the name and price they were rung up with. */ async updateProduct(venueId: string, productId: string, dto: UpdateProductDto, user: { id: string }) { const product = await this.prisma.$transaction(async (tx) => { await VenueLedger.open(tx, venueId); const existing = await tx.product.findUnique({ where: { venueId_productId: { venueId, productId } } }); if (!existing) { throw new NotFoundException({ code: 'product_not_found', message: 'Product not found' }); } const archived = dto.archived ?? existing.archivedAt != null; const name = dto.name ?? existing.name; // A product coming back, or renamed, must not take an active product's name if (!archived && (name !== existing.name || existing.archivedAt != null)) { await this.ensureNameFree(tx, venueId, name, productId); } return tx.product.update({ where: { venueId_productId: { venueId, productId } }, data: { name, ...(dto.category !== undefined ? { category: dto.category } : {}), ...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}), ...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}), archivedAt: archived ? (existing.archivedAt ?? new Date()) : null, reportedAt: new Date(), }, }); }); await this.auditService.log(venueId, 'client_user', user.id, 'product_updated', { productId, changes: dto }); this.realtime.notify(venueId, 'stock'); return productView(product); } private async ensureNameFree(tx: Prisma.TransactionClient, venueId: string, name: string, exceptProductId?: string) { const products = await tx.product.findMany({ where: { venueId, archivedAt: null }, select: { productId: true, name: true }, }); const key = name.trim().toLowerCase(); if (products.some((p) => p.productId !== exceptProductId && p.name.trim().toLowerCase() === key)) { throw new ConflictException({ code: 'product_name_exists', message: 'A product with this name already exists' }); } } } function productView(p: { productId: string; name: string; category: string | null; priceInCents: number; stock: number | null; lowStockThreshold: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null; }) { return { productId: p.productId, name: p.name, category: p.category, priceInCents: p.priceInCents, stock: p.stock, lowStockThreshold: p.lowStockThreshold, // The latest count, on the dashboard or a terminal: how fresh the stock is countedAt: p.countedAt, reportedAt: p.reportedAt, archivedAt: p.archivedAt, }; }