@@ -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 ?? {}),
|
||||
|
||||
Reference in New Issue
Block a user