278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
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<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.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
}
|