@@ -12,6 +12,7 @@ import { AuditModule } from './audit/audit.module';
|
||||
import { JobsModule } from './jobs/jobs.module';
|
||||
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
|
||||
import { RemindersModule } from './reminders/reminders.module';
|
||||
import { StockModule } from './stock/stock.module';
|
||||
import { RealtimeModule } from './realtime/realtime.module';
|
||||
import { EmailModule } from './email/email.module';
|
||||
import { HealthController } from './health.controller';
|
||||
@@ -32,6 +33,7 @@ import { HealthController } from './health.controller';
|
||||
JobsModule,
|
||||
PlannedTabsModule,
|
||||
RemindersModule,
|
||||
StockModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -3,13 +3,20 @@ import { DevicesService } from './devices.service';
|
||||
|
||||
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown };
|
||||
|
||||
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
|
||||
type StoredProduct = { productId: string; name: string; category: string | null; priceInCents: number; stock: number | null };
|
||||
|
||||
function createService({
|
||||
previous = [] as Snapshot[], delivered = 0, expired = 0, products = [] as StoredProduct[], pendingStock = [] as unknown[],
|
||||
recordedResults = 1,
|
||||
} = {}) {
|
||||
const tx = {
|
||||
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
|
||||
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
|
||||
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
|
||||
device: { update: jest.fn() },
|
||||
tabEvent: { createMany: jest.fn() },
|
||||
product: { deleteMany: jest.fn(), updateMany: jest.fn(), upsert: jest.fn() },
|
||||
stockAdjustment: { updateMany: jest.fn().mockResolvedValue({ count: recordedResults }) },
|
||||
};
|
||||
const prisma = {
|
||||
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
|
||||
@@ -17,6 +24,8 @@ function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0
|
||||
$transaction: jest.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||
remoteCommand: { updateMany: jest.fn().mockResolvedValue({ count: expired }), findMany: jest.fn().mockResolvedValue([]) },
|
||||
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Els' }]) },
|
||||
product: { findMany: jest.fn().mockResolvedValue(products) },
|
||||
stockAdjustment: { findMany: jest.fn().mockResolvedValue(pendingStock) },
|
||||
};
|
||||
const audit = { log: jest.fn() };
|
||||
const realtime = { notify: jest.fn() };
|
||||
@@ -201,3 +210,84 @@ describe('DevicesService.sync tab events', () => {
|
||||
expect(result.acknowledgedEventIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync stock', () => {
|
||||
const cola = { id: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
|
||||
const storedCola = { productId: 'p1', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: 48 };
|
||||
|
||||
it('leaves products alone when the terminal does not report them', async () => {
|
||||
const { service, prisma, tx } = createService({ products: [storedCola] });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||
|
||||
expect(prisma.product.findMany).not.toHaveBeenCalled();
|
||||
expect(tx.product.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only writes changed products, removes missing ones and signals "stock"', async () => {
|
||||
const storedWater = { productId: 'p2', name: 'Water', category: null, priceInCents: 200, stock: null };
|
||||
const { service, tx, realtime } = createService({ products: [storedCola, storedWater] });
|
||||
|
||||
await service.sync(device, {
|
||||
deviceId: 'd1',
|
||||
openTabs: [],
|
||||
products: [{ ...cola, stock: 46 }, { id: 'p3', name: ' Bier ', priceInCents: 300, stock: null }],
|
||||
});
|
||||
|
||||
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1', 'p3'] } } });
|
||||
expect(tx.product.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(tx.product.upsert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
create: expect.objectContaining({ productId: 'p3', name: 'Bier', category: null, stock: null }),
|
||||
}));
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
|
||||
});
|
||||
|
||||
it('does not signal anything for an unchanged catalog', async () => {
|
||||
const { service, tx, realtime } = createService({ products: [storedCola], recordedResults: 0 });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola] });
|
||||
|
||||
expect(tx.product.upsert).not.toHaveBeenCalled();
|
||||
expect(realtime.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips an invalid product without failing the sync', async () => {
|
||||
const { service, tx } = createService();
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [], products: [cola, { id: 'p9', name: 'Broken', priceInCents: -1, stock: 3 }] });
|
||||
|
||||
expect(tx.product.deleteMany).toHaveBeenCalledWith({ where: { venueId: 'v1', productId: { notIn: ['p1'] } } });
|
||||
});
|
||||
|
||||
it('records stock adjustment results and acknowledges them', async () => {
|
||||
const { service, tx, realtime } = createService();
|
||||
const id = '5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c';
|
||||
|
||||
const result = await service.sync(device, {
|
||||
deviceId: 'd1',
|
||||
openTabs: [],
|
||||
stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }, { id: 'nope', status: 'applied' }],
|
||||
});
|
||||
|
||||
expect(tx.stockAdjustment.updateMany).toHaveBeenCalledWith({
|
||||
where: { id, venueId: 'v1', status: { in: ['pending', 'cancelled'] } },
|
||||
data: { status: 'applied', stockAfter: 72, result: null, processedAt: expect.any(Date) },
|
||||
});
|
||||
expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]);
|
||||
expect(result.rejectedStockAdjustmentResults.map((r) => r.id)).toEqual(['nope']);
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
|
||||
});
|
||||
|
||||
it('sends the pending stock adjustments of the venue', async () => {
|
||||
const pending = [{ id: 'a1', productId: 'p1', type: 'add', quantity: 24, note: null, requestedBy: 'Bram', createdAt: new Date() }];
|
||||
const { service, prisma } = createService({ pendingStock: pending });
|
||||
|
||||
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
|
||||
|
||||
expect(prisma.stockAdjustment.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { venueId: 'v1', status: 'pending' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}));
|
||||
expect(result.stockAdjustments).toEqual(pending);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
import { AckCommandDto } from './dto/ack-command.dto';
|
||||
import { parseTabEvents, tabEventRow } from './tab-events';
|
||||
import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync';
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
@@ -39,6 +40,15 @@ export class DevicesService {
|
||||
for (const { id, reason } of events.rejected) {
|
||||
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`);
|
||||
}
|
||||
// Omitted by app versions without stock: the products are then left as they are
|
||||
const reported = dto.products ? parseProducts(dto.products) : null;
|
||||
for (const { id, reason } of reported?.rejected ?? []) {
|
||||
this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`);
|
||||
}
|
||||
const stockResults = parseStockResults(dto.stockAdjustmentResults ?? []);
|
||||
for (const { id, reason } of stockResults.rejected) {
|
||||
this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`);
|
||||
}
|
||||
const openTabs = dto.openTabs || [];
|
||||
const openTabIds = openTabs.map((t) => t.id);
|
||||
|
||||
@@ -67,6 +77,22 @@ export class DevicesService {
|
||||
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
|
||||
let plannedDelivered = 0;
|
||||
|
||||
// Products belong to the venue rather than the device, so they survive pairing a new terminal
|
||||
const previousProducts = reported
|
||||
? await this.prisma.product.findMany({
|
||||
where: { venueId: device.venueId },
|
||||
select: { productId: true, name: true, category: true, priceInCents: true, stock: true },
|
||||
})
|
||||
: [];
|
||||
const previousProductsById = new Map(previousProducts.map((p) => [p.productId, p]));
|
||||
const changedProducts = (reported?.products ?? []).filter((p) => {
|
||||
const stored = previousProductsById.get(p.id);
|
||||
return !stored || !sameProduct(stored, p);
|
||||
});
|
||||
const productsChanged = reported !== null
|
||||
&& (previousProducts.length !== reported.products.length || changedProducts.length > 0);
|
||||
let stockResultsRecorded = 0;
|
||||
|
||||
for (const ownership of takenOver) {
|
||||
await this.auditService.log(
|
||||
device.venueId,
|
||||
@@ -148,6 +174,43 @@ export class DevicesService {
|
||||
}));
|
||||
}
|
||||
|
||||
if (reported) {
|
||||
const reportedIds = reported.products.map((p) => p.id);
|
||||
await tx.product.deleteMany({ where: { venueId: device.venueId, productId: { notIn: reportedIds } } });
|
||||
await tx.product.updateMany({
|
||||
where: { venueId: device.venueId, productId: { in: reportedIds } },
|
||||
data: { reportedAt: now },
|
||||
});
|
||||
for (const product of changedProducts) {
|
||||
const data = {
|
||||
name: product.name,
|
||||
category: product.category,
|
||||
priceInCents: product.priceInCents,
|
||||
stock: product.stock,
|
||||
reportedAt: now,
|
||||
};
|
||||
await tx.product.upsert({
|
||||
where: { venueId_productId: { venueId: device.venueId, productId: product.id } },
|
||||
create: { venueId: device.venueId, productId: product.id, ...data },
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The terminal's outcome is the truth, even for an adjustment cancelled while it was on its way
|
||||
for (const result of stockResults.accepted) {
|
||||
const { count } = await tx.stockAdjustment.updateMany({
|
||||
where: { id: result.id, venueId: device.venueId, status: { in: ['pending', 'cancelled'] } },
|
||||
data: {
|
||||
status: result.status,
|
||||
stockAfter: result.status === 'applied' ? result.stockAfter : null,
|
||||
result: result.reason ?? null,
|
||||
processedAt: now,
|
||||
},
|
||||
});
|
||||
stockResultsRecorded += count;
|
||||
}
|
||||
|
||||
if (events.accepted.length > 0) {
|
||||
await tx.tabEvent.createMany({
|
||||
data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)),
|
||||
@@ -203,10 +266,19 @@ export class DevicesService {
|
||||
select: { customerName: true },
|
||||
});
|
||||
|
||||
// Sent on every sync until the terminal reports an outcome; the terminal dedupes by id
|
||||
const stockAdjustments = await this.prisma.stockAdjustment.findMany({
|
||||
where: { venueId: device.venueId, status: 'pending' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 500,
|
||||
select: { id: true, productId: true, type: true, quantity: true, note: true, requestedBy: true, createdAt: true },
|
||||
});
|
||||
|
||||
const topics: RealtimeTopic[] = [];
|
||||
if (tabsChanged) topics.push('tabs');
|
||||
if (plannedDelivered > 0) topics.push('planned-tabs');
|
||||
if (expired.count > 0) topics.push('commands');
|
||||
if (productsChanged || stockResultsRecorded > 0) topics.push('stock');
|
||||
// Only when the device comes back online; dashboards poll the device status themselves
|
||||
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
|
||||
if (topics.length > 0) this.realtime.notify(device.venueId, ...topics);
|
||||
@@ -227,6 +299,11 @@ export class DevicesService {
|
||||
acknowledgedEventIds: events.accepted.map((e) => e.id),
|
||||
// Invalid and never stored: the terminal should log and drop these too, re-sending won't help
|
||||
rejectedEvents: events.rejected,
|
||||
stockAdjustments,
|
||||
// Recorded (or already recorded earlier): the terminal can drop these results from its outbox
|
||||
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
|
||||
// Invalid and never recorded: drop these too, re-sending won't help
|
||||
rejectedStockAdjustmentResults: stockResults.rejected,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -314,6 +391,13 @@ export class DevicesService {
|
||||
}
|
||||
}
|
||||
|
||||
type StoredProduct = { name: string; category: string | null; priceInCents: number; stock: number | null };
|
||||
|
||||
function sameProduct(stored: StoredProduct, reported: ParsedProduct): boolean {
|
||||
return stored.name === reported.name && stored.category === reported.category
|
||||
&& stored.priceInCents === reported.priceInCents && stored.stock === reported.stock;
|
||||
}
|
||||
|
||||
type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IsString, IsIn, IsInt, Min, IsNotEmpty, MaxLength, IsOptional, IsUUID, ValidateIf } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ReportedProductDto {
|
||||
@ApiProperty({ example: 'local-product-id', description: "The terminal's own id for the product" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ example: 'Cola' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Frisdrank' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
category?: string | null;
|
||||
|
||||
@ApiProperty({ example: 250 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priceInCents: number;
|
||||
|
||||
@ApiProperty({ example: 48, nullable: true, description: 'Null when stock is not tracked; may be negative' })
|
||||
@ValidateIf((product: ReportedProductDto) => product.stock !== null)
|
||||
@IsInt()
|
||||
stock: number | null;
|
||||
}
|
||||
|
||||
export const STOCK_RESULT_STATUSES = ['applied', 'rejected'] as const;
|
||||
|
||||
export class StockAdjustmentResultDto {
|
||||
@ApiProperty({ description: 'The stock adjustment id from the sync response' })
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ enum: STOCK_RESULT_STATUSES })
|
||||
@IsIn(STOCK_RESULT_STATUSES)
|
||||
status: (typeof STOCK_RESULT_STATUSES)[number];
|
||||
|
||||
@ApiPropertyOptional({ example: 72, description: 'applied: the stock right after applying it' })
|
||||
@ValidateIf((result: StockAdjustmentResultDto) => result.status === 'applied')
|
||||
@IsInt()
|
||||
stockAfter?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'unknown_product', description: 'rejected: why' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason?: string;
|
||||
}
|
||||
@@ -77,4 +77,19 @@ export class SyncDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(1000)
|
||||
events?: unknown[];
|
||||
}
|
||||
|
||||
// Validated one by one in the service, like events
|
||||
@ApiPropertyOptional({
|
||||
description: "ReportedProductDto[]: the terminal's complete product catalog with stock. Omitted by app versions without stock",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2000)
|
||||
products?: unknown[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'StockAdjustmentResultDto[]: outcomes of stock adjustments the terminal received' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(1000)
|
||||
stockAdjustmentResults?: unknown[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { ReportedProductDto, StockAdjustmentResultDto } from './dto/stock-sync.dto';
|
||||
import { describe } from './tab-events';
|
||||
|
||||
export type Rejected = { id: string | null; reason: string };
|
||||
|
||||
/**
|
||||
* Validates entries one by one, like tab events: one bad product or result is skipped on its own
|
||||
* instead of failing the sync (and with it the open tabs and sales).
|
||||
*/
|
||||
function parseEach<T extends object>(cls: new () => T, raw: unknown[]): { accepted: T[]; rejected: Rejected[] } {
|
||||
const accepted: T[] = [];
|
||||
const rejected: Rejected[] = [];
|
||||
|
||||
for (const value of raw) {
|
||||
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
rejected.push({ id, reason: 'must be an object' });
|
||||
continue;
|
||||
}
|
||||
const entry = plainToInstance(cls, value);
|
||||
const errors = validateSync(entry, { whitelist: true });
|
||||
if (errors.length > 0) {
|
||||
rejected.push({ id, reason: describe(errors) });
|
||||
continue;
|
||||
}
|
||||
accepted.push(entry);
|
||||
}
|
||||
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
export function parseProducts(raw: unknown[]) {
|
||||
const { accepted, rejected } = parseEach(ReportedProductDto, raw);
|
||||
// A duplicated id would make the upserts fight; the last one reported wins
|
||||
const byId = new Map(accepted.map((product) => [product.id, product]));
|
||||
return {
|
||||
products: [...byId.values()].map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name.trim(),
|
||||
category: p.category?.trim() || null,
|
||||
priceInCents: p.priceInCents,
|
||||
stock: p.stock ?? null,
|
||||
})),
|
||||
rejected,
|
||||
};
|
||||
}
|
||||
|
||||
export type ParsedProduct = ReturnType<typeof parseProducts>['products'][number];
|
||||
|
||||
export function parseStockResults(raw: unknown[]) {
|
||||
return parseEach(StockAdjustmentResultDto, raw);
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
|
||||
};
|
||||
}
|
||||
|
||||
function describe(errors: ValidationError[]): string {
|
||||
export function describe(errors: ValidationError[]): string {
|
||||
return errors
|
||||
.flatMap((error) => [
|
||||
...Object.values(error.constraints ?? {}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Namespace } from 'socket.io';
|
||||
|
||||
/** What changed; the dashboard refetches the matching data over the normal API. */
|
||||
export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users';
|
||||
export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users' | 'stock';
|
||||
|
||||
export const venueRoom = (venueId: string) => `venue:${venueId}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
IsString, IsIn, IsInt, Min, Max, IsNotEmpty, MaxLength, IsOptional, IsArray, ArrayMinSize, ArrayMaxSize,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export const STOCK_ADJUSTMENT_TYPES = ['add', 'set'] as const;
|
||||
export type StockAdjustmentType = (typeof STOCK_ADJUSTMENT_TYPES)[number];
|
||||
|
||||
export class StockAdjustmentEntryDto {
|
||||
@ApiProperty({ example: 'local-product-id' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@ApiProperty({ enum: STOCK_ADJUSTMENT_TYPES, description: 'add: change the stock by quantity; set: the stock is now quantity' })
|
||||
@IsIn(STOCK_ADJUSTMENT_TYPES)
|
||||
type: StockAdjustmentType;
|
||||
|
||||
@ApiProperty({ example: 24, description: 'add: non-zero, negative to take stock out; set: zero or more' })
|
||||
@IsInt()
|
||||
@Min(-100000)
|
||||
@Max(100000)
|
||||
quantity: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Delivery from the brewery' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class CreateStockAdjustmentsDto {
|
||||
@ApiProperty({ type: [StockAdjustmentEntryDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => StockAdjustmentEntryDto)
|
||||
adjustments: StockAdjustmentEntryDto[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsInt, Min, Max, ValidateIf } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateProductDto {
|
||||
@ApiProperty({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
|
||||
@ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
lowStockThreshold: number | null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Controller, Get, Post, Put, Param, Query, Body, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { StockService } from './stock.service';
|
||||
import { CreateStockAdjustmentsDto } from './dto/create-stock-adjustments.dto';
|
||||
import { UpdateProductDto } from './dto/update-product.dto';
|
||||
|
||||
@ApiTags('Stock')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
|
||||
@Controller('venues/:venueId/stock')
|
||||
export class StockController {
|
||||
constructor(private service: StockService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "The terminal's products with their stock and pending adjustments" })
|
||||
async findAll(@Param('venueId') venueId: string) {
|
||||
return this.service.findAll(venueId);
|
||||
}
|
||||
|
||||
@Get('adjustments')
|
||||
@ApiOperation({ summary: 'Stock adjustment history' })
|
||||
@ApiQuery({ name: 'productId', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
@ApiQuery({ name: 'offset', required: false })
|
||||
async history(
|
||||
@Param('venueId') venueId: string,
|
||||
@Query('productId') productId?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
return this.service.history(venueId, {
|
||||
productId: productId || undefined,
|
||||
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
|
||||
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// Viewers (bar staff) change stock too; only the warning level is an admin setting
|
||||
@Post('adjustments')
|
||||
@ApiOperation({ summary: 'Add to, take from or count the stock of one or more products' })
|
||||
async create(@Param('venueId') venueId: string, @Body() dto: CreateStockAdjustmentsDto, @Req() req: any) {
|
||||
return this.service.createAdjustments(venueId, dto.adjustments, req.user);
|
||||
}
|
||||
|
||||
@Post('adjustments/:id/cancel')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Cancel a stock adjustment the terminal has not processed yet' })
|
||||
async cancel(@Param('venueId') venueId: string, @Param('id') id: string, @Req() req: any) {
|
||||
return this.service.cancelAdjustment(venueId, id, req.user);
|
||||
}
|
||||
|
||||
@Put('products/:productId')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Set the low stock warning level of a product (admin)' })
|
||||
async updateProduct(
|
||||
@Param('venueId') venueId: string,
|
||||
@Param('productId') productId: string,
|
||||
@Body() dto: UpdateProductDto,
|
||||
) {
|
||||
return this.service.updateProduct(venueId, productId, dto.lowStockThreshold);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StockController } from './stock.controller';
|
||||
import { StockService } from './stock.service';
|
||||
|
||||
@Module({
|
||||
controllers: [StockController],
|
||||
providers: [StockService],
|
||||
})
|
||||
export class StockModule {}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { StockService } from './stock.service';
|
||||
|
||||
const user = { id: 'u1', email: 'bram@example.com' };
|
||||
|
||||
function createService({ products = [] as { productId: string; name: string; stock: number | null }[], cancelled = 1, existing = null as unknown } = {}) {
|
||||
const prisma = {
|
||||
product: { findMany: jest.fn().mockResolvedValue(products), updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||
stockAdjustment: {
|
||||
create: jest.fn((args: { data: Record<string, unknown> }) => ({ id: 'a1', ...args.data })),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: cancelled }),
|
||||
findFirst: jest.fn().mockResolvedValue(existing),
|
||||
},
|
||||
$transaction: jest.fn((operations: unknown[]) => Promise.all(operations)),
|
||||
};
|
||||
const audit = { log: jest.fn() };
|
||||
const realtime = { notify: jest.fn() };
|
||||
const service = new StockService(prisma as any, audit as any, realtime as any);
|
||||
return { service, prisma, audit, realtime };
|
||||
}
|
||||
|
||||
const cola = { productId: 'p1', name: 'Cola', stock: 10 };
|
||||
const water = { productId: 'p2', name: 'Water', stock: null };
|
||||
|
||||
describe('StockService.createAdjustments', () => {
|
||||
it('creates one pending adjustment per entry with the product name and requester', async () => {
|
||||
const { service, prisma, realtime } = createService({ products: [cola, water] });
|
||||
|
||||
const result = await service.createAdjustments('v1', [
|
||||
{ productId: 'p1', type: 'add', quantity: 24, note: 'Delivery' },
|
||||
{ productId: 'p2', type: 'set', quantity: 12 },
|
||||
], user);
|
||||
|
||||
expect(prisma.stockAdjustment.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ venueId: 'v1', productId: 'p1', productName: 'Cola', type: 'add', quantity: 24, note: 'Delivery', requestedBy: 'bram@example.com' }),
|
||||
}));
|
||||
expect(result.adjustments).toHaveLength(2);
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
|
||||
});
|
||||
|
||||
it('rejects a negative count and adding zero', async () => {
|
||||
const { service } = createService({ products: [cola] });
|
||||
|
||||
await expect(service.createAdjustments('v1', [{ productId: 'p1', type: 'set', quantity: -1 }], user))
|
||||
.rejects.toMatchObject({ response: { code: 'invalid_quantity' } });
|
||||
await expect(service.createAdjustments('v1', [{ productId: 'p1', type: 'add', quantity: 0 }], user))
|
||||
.rejects.toMatchObject({ response: { code: 'invalid_quantity' } });
|
||||
});
|
||||
|
||||
it('rejects unknown products and adding to an untracked product, creating nothing', async () => {
|
||||
const { service, prisma } = createService({ products: [cola, water] });
|
||||
|
||||
await expect(service.createAdjustments('v1', [{ productId: 'p9', type: 'set', quantity: 1 }], user))
|
||||
.rejects.toMatchObject({ response: { code: 'product_not_found' } });
|
||||
await expect(service.createAdjustments('v1', [
|
||||
{ productId: 'p1', type: 'add', quantity: 6 },
|
||||
{ productId: 'p2', type: 'add', quantity: 6 },
|
||||
], user)).rejects.toMatchObject({ response: { code: 'stock_not_tracked' } });
|
||||
expect(prisma.stockAdjustment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StockService.cancelAdjustment', () => {
|
||||
it('cancels a pending adjustment', async () => {
|
||||
const { service, prisma, realtime } = createService();
|
||||
|
||||
await expect(service.cancelAdjustment('v1', 'a1', user)).resolves.toEqual({ ok: true });
|
||||
expect(prisma.stockAdjustment.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'a1', venueId: 'v1', status: 'pending' },
|
||||
data: { status: 'cancelled', processedAt: expect.any(Date) },
|
||||
});
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
|
||||
});
|
||||
|
||||
it('refuses once the terminal processed it', async () => {
|
||||
const { service } = createService({ cancelled: 0, existing: { id: 'a1', status: 'applied' } });
|
||||
|
||||
await expect(service.cancelAdjustment('v1', 'a1', user)).rejects.toMatchObject({ response: { code: 'adjustment_not_pending' } });
|
||||
});
|
||||
|
||||
it('is idempotent and 404s for another venue', async () => {
|
||||
await expect(createService({ cancelled: 0, existing: { id: 'a1', status: 'cancelled' } }).service.cancelAdjustment('v1', 'a1', user))
|
||||
.resolves.toEqual({ ok: true });
|
||||
await expect(createService({ cancelled: 0 }).service.cancelAdjustment('v1', 'a1', user))
|
||||
.rejects.toMatchObject({ response: { code: 'adjustment_not_found' } });
|
||||
});
|
||||
});
|
||||
@@ -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