This commit is contained in:
@@ -8,9 +8,14 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { SkipThrottle, Throttle } from '@nestjs/throttler';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes } from '@nestjs/swagger';
|
||||
import { ImagesService } from '../images/images.service';
|
||||
import { MAX_UPLOAD_BYTES } from '../images/image-processing';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
@@ -27,6 +32,7 @@ export class DevicesController {
|
||||
constructor(
|
||||
private devicesService: DevicesService,
|
||||
private authService: AuthService,
|
||||
private images: ImagesService,
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
@@ -48,6 +54,18 @@ export class DevicesController {
|
||||
return this.devicesService.sync(req.user, dto);
|
||||
}
|
||||
|
||||
// A picture taken on a terminal. Only stored: the terminal links it to its product with a product_updated event,
|
||||
// so an offline edit and its picture follow the same rules as every other change
|
||||
@Post(':deviceId/images')
|
||||
@SkipThrottle()
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } }))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload a product picture from a terminal' })
|
||||
async uploadImage(@UploadedFile() file: Express.Multer.File | undefined) {
|
||||
return { imageId: await this.images.store(file?.buffer) };
|
||||
}
|
||||
|
||||
@Post(':deviceId/commands/:commandId/ack')
|
||||
@SkipThrottle()
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DevicesController } from './devices.controller';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { ImagesModule } from '../images/images.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, ImagesModule],
|
||||
controllers: [DevicesController],
|
||||
providers: [DevicesService],
|
||||
exports: [DevicesService],
|
||||
|
||||
@@ -44,6 +44,8 @@ const events = {
|
||||
amountInCents: quantity * 250, paymentMethod: 'payconiq',
|
||||
items: [{ productId: 'cola', productName: 'Cola', quantity, unitPriceInCents: 250 }],
|
||||
}),
|
||||
product: (type: string, productId: string, product: Record<string, unknown> | undefined, minutes = 0) => (
|
||||
{ id: uuidv4(), type, productId, ...(product ? { product } : {}), occurredAt: at(minutes) }),
|
||||
stockSet: (productId: string, quantity: number | null, minutes = 0) => (
|
||||
{ id: uuidv4(), type: 'stock_set', productId, quantity, occurredAt: at(minutes) }),
|
||||
};
|
||||
@@ -531,3 +533,134 @@ describe('DevicesService.sync: website product changes reach the tablets', () =>
|
||||
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', name: 'Coca-Cola' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync: products created and changed on a tablet', () => {
|
||||
const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' };
|
||||
|
||||
function setupWithWebsite() {
|
||||
const venue = setup();
|
||||
const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any);
|
||||
return { ...venue, stock };
|
||||
}
|
||||
|
||||
const catalogEntry = (result: SyncResult, id: string) => result.catalog.find((p) => p.id === id);
|
||||
|
||||
it('creates a product made offline, and sells and counts it in the same sync', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
|
||||
await sync(tabletA, {
|
||||
events: [
|
||||
events.product('product_created', 'iced', { name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3 }, 1),
|
||||
events.stockSet('iced', 12, 2),
|
||||
events.opened('t1', 'Alice', 3),
|
||||
events.line('line_added', 't1', 2, { minutes: 4, productId: 'iced', productName: 'Ice Coffee', price: 350 }),
|
||||
],
|
||||
});
|
||||
const result = await sync(tabletB);
|
||||
|
||||
expect(catalogEntry(result, 'iced')).toEqual(
|
||||
{ id: 'iced', name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3, archived: false, imageId: null });
|
||||
expect(result.stock.find((s) => s.productId === 'iced')?.stock).toBe(10);
|
||||
});
|
||||
|
||||
it('keeps both a website price change and an offline rename of the same product', async () => {
|
||||
const { sync, tabletA, stock, prisma } = setupWithWebsite();
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
|
||||
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
|
||||
// Renamed offline before the price change, synced after it
|
||||
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 5)] });
|
||||
|
||||
expect(catalogEntry(result, 'cola')).toMatchObject({ name: 'Coca-Cola', priceInCents: 270 });
|
||||
expect(prisma.product.rows[0].fieldUpdatedAt).toMatchObject({ name: at(5) });
|
||||
});
|
||||
|
||||
it('lets the latest change of a field win, also when the older one arrives last', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
|
||||
await sync(tabletB, { events: [events.product('product_updated', 'cola', { priceInCents: 300 }, 20)] });
|
||||
// Tablet A was offline: its older price change arrives later and loses
|
||||
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 10)] });
|
||||
|
||||
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(300);
|
||||
});
|
||||
|
||||
it('a website change made after a tablet change wins', async () => {
|
||||
const { sync, tabletA, stock } = setupWithWebsite();
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 1)] });
|
||||
|
||||
await stock.updateProduct('v1', 'cola', { priceInCents: 260 }, admin);
|
||||
const result = await sync(tabletA);
|
||||
|
||||
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(260);
|
||||
});
|
||||
|
||||
it('archives and restores a product from a tablet', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
|
||||
const archived = await sync(tabletA, { events: [events.product('product_archived', 'cola', undefined, 1)] });
|
||||
expect(catalogEntry(archived, 'cola')!.archived).toBe(true);
|
||||
|
||||
const restored = await sync(tabletA, { events: [events.product('product_restored', 'cola', undefined, 2)] });
|
||||
expect(catalogEntry(restored, 'cola')!.archived).toBe(false);
|
||||
});
|
||||
|
||||
it('only links a picture that was uploaded', async () => {
|
||||
const { sync, tabletA, prisma } = setup();
|
||||
prisma.productImage.rows.push({ id: 'a'.repeat(64), contentType: 'image/webp', width: 512, height: 512, bytes: Buffer.from('x') });
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
|
||||
const unknown = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'b'.repeat(64) }, 1)] });
|
||||
expect(catalogEntry(unknown, 'cola')!.imageId).toBeNull();
|
||||
|
||||
const known = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'a'.repeat(64) }, 2)] });
|
||||
expect(catalogEntry(known, 'cola')!.imageId).toBe('a'.repeat(64));
|
||||
|
||||
const removed = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: null }, 3)] });
|
||||
expect(catalogEntry(removed, 'cola')!.imageId).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts two products with the same name from two offline tablets', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola-a', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
const result = await sync(tabletB, { events: [events.product('product_created', 'cola-b', { name: 'cola', priceInCents: 250 }, 0)] });
|
||||
|
||||
expect(result.catalog.map((p) => p.id).sort()).toEqual(['cola-a', 'cola-b']);
|
||||
});
|
||||
|
||||
it('acknowledges a change to a product the venue does not have, without creating it', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
const change = events.product('product_updated', 'gone', { name: 'Gone' }, 0);
|
||||
|
||||
const result = await sync(tabletA, { events: [change] });
|
||||
|
||||
expect(result.acknowledgedEventIds).toEqual([change.id]);
|
||||
expect(result.catalog).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a new product without a price', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
const incomplete = events.product('product_created', 'x', { name: 'X' }, 0);
|
||||
|
||||
const result = await sync(tabletA, { events: [incomplete] });
|
||||
|
||||
expect(result.rejectedEvents).toEqual([{ id: incomplete.id, reason: 'a new product needs a name and a price' }]);
|
||||
});
|
||||
|
||||
it('applies a product event once, also when it is sent again', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
const created = events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0);
|
||||
const renamed = events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 1);
|
||||
await sync(tabletA, { events: [created, renamed] });
|
||||
await sync(tabletB, { events: [events.product('product_updated', 'cola', { name: 'Cola Classic' }, 2)] });
|
||||
|
||||
// The old rename again, with its id: already applied, so it must not undo the newer name
|
||||
const result = await sync(tabletA, { events: [renamed] });
|
||||
|
||||
expect(catalogEntry(result, 'cola')!.name).toBe('Cola Classic');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ import { VenueLedger } from '../tabs/venue-ledger';
|
||||
import { seedTabs, tabJson } from '../tabs/tab-state';
|
||||
import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync';
|
||||
import { applyPendingAdjustments } from '../stock/stock-adjustments';
|
||||
import { applyProductEvent } from '../stock/product-events';
|
||||
import { isProductEvent } from './dto/tab-event.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
@@ -59,7 +61,7 @@ export class DevicesService {
|
||||
const ledger = await VenueLedger.open(tx, venueId);
|
||||
|
||||
// 1. The dashboard manages the catalog: a terminal's own catalog is only taken over once, with the tabs
|
||||
const productsChanged = reported && !ledger.seeded
|
||||
let productsChanged = reported && !ledger.seeded
|
||||
? await this.saveProducts(tx, ledger, reported.products, now)
|
||||
: false;
|
||||
|
||||
@@ -83,6 +85,11 @@ export class DevicesService {
|
||||
} else {
|
||||
for (const event of fresh) {
|
||||
const occurredAt = eventTime(event, now);
|
||||
// Products first: a sale or count of a product created offline arrives in the same request
|
||||
if (isProductEvent(event)) {
|
||||
if (await applyProductEvent(tx, ledger, event, occurredAt)) productsChanged = true;
|
||||
continue;
|
||||
}
|
||||
const settlement = ledger.tabs.apply(event, occurredAt);
|
||||
if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
|
||||
ledger.stock.apply(event, occurredAt);
|
||||
@@ -117,7 +124,10 @@ export class DevicesService {
|
||||
const catalog = await tx.product.findMany({
|
||||
where: { venueId },
|
||||
orderBy: [{ category: 'asc' }, { name: 'asc' }],
|
||||
select: { productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true },
|
||||
select: {
|
||||
productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true,
|
||||
imageId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -198,6 +208,8 @@ export class DevicesService {
|
||||
priceInCents: p.priceInCents,
|
||||
lowStockThreshold: p.lowStockThreshold,
|
||||
archived: p.archivedAt !== null,
|
||||
// The picture is at /images/<imageId>; null: none
|
||||
imageId: p.imageId,
|
||||
})),
|
||||
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
|
||||
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
IsString, IsIn, IsInt, Min, IsUUID, IsNotEmpty, MaxLength, IsISO8601, ValidateIf, IsOptional, IsArray,
|
||||
IsString, IsIn, IsInt, Min, Max, IsUUID, IsNotEmpty, MaxLength, IsISO8601, ValidateIf, IsOptional, IsArray,
|
||||
ArrayMaxSize, ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
@@ -8,7 +8,10 @@ import { OpenTabItemDto } from './sync.dto';
|
||||
|
||||
export const TAB_EVENT_TYPES = [
|
||||
'tab_opened', 'tab_renamed', 'tab_deleted', 'line_added', 'line_removed', 'settled', 'stock_set',
|
||||
'product_created', 'product_updated', 'product_archived', 'product_restored',
|
||||
] as const;
|
||||
|
||||
export const PRODUCT_EVENT_TYPES = ['product_created', 'product_updated', 'product_archived', 'product_restored'] as const;
|
||||
export type TabEventType = (typeof TAB_EVENT_TYPES)[number];
|
||||
|
||||
export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending', 'other'] as const;
|
||||
@@ -16,7 +19,45 @@ export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending'
|
||||
const isLine = (event: TabEventDto) => event.type === 'line_added' || event.type === 'line_removed';
|
||||
const isSettlement = (event: TabEventDto) => event.type === 'settled';
|
||||
const isStockSet = (event: TabEventDto) => event.type === 'stock_set';
|
||||
const isAboutTab = (event: TabEventDto) => !isStockSet(event);
|
||||
export const isProductEvent = (event: TabEventDto) => (PRODUCT_EVENT_TYPES as readonly string[]).includes(event.type);
|
||||
const isAboutTab = (event: TabEventDto) => !isStockSet(event) && !isProductEvent(event);
|
||||
const carriesProduct = (event: TabEventDto) => event.type === 'product_created' || event.type === 'product_updated';
|
||||
|
||||
/** A product as created or changed on a terminal: only the fields that changed are sent. */
|
||||
export class ProductEventFieldsDto {
|
||||
@ApiPropertyOptional({ example: 'Cola' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
category?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 250 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1_000_000)
|
||||
priceInCents?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 12, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
lowStockThreshold?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'A picture uploaded to /devices/:deviceId/images first; null removes it', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
imageId?: string | null;
|
||||
}
|
||||
|
||||
export class SettledItemDto extends OpenTabItemDto {
|
||||
@ApiPropertyOptional({ example: '12', description: 'Missing in events from older app versions' })
|
||||
@@ -64,7 +105,7 @@ export class TabEventDto {
|
||||
lineId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '12', description: 'line_added / line_removed (missing from older app versions), stock_set' })
|
||||
@ValidateIf((event: TabEventDto) => isStockSet(event) || event.productId !== undefined)
|
||||
@ValidateIf((event: TabEventDto) => isStockSet(event) || isProductEvent(event) || event.productId !== undefined)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
@@ -109,6 +150,12 @@ export class TabEventDto {
|
||||
@IsUUID()
|
||||
commandId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: ProductEventFieldsDto, description: 'product_created / product_updated' })
|
||||
@ValidateIf(carriesProduct)
|
||||
@ValidateNested()
|
||||
@Type(() => ProductEventFieldsDto)
|
||||
product?: ProductEventFieldsDto;
|
||||
|
||||
@ApiPropertyOptional({ type: [SettledItemDto], description: 'settled: the lines that were settled' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync, ValidationError } from 'class-validator';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { TabEventDto } from './dto/tab-event.dto';
|
||||
import { isProductEvent, TabEventDto } from './dto/tab-event.dto';
|
||||
|
||||
// A terminal clock that runs ahead must not put sales in the future
|
||||
const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000;
|
||||
@@ -30,6 +30,10 @@ export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejec
|
||||
rejected.push({ id, reason: 'quantity must not be less than 1' });
|
||||
continue;
|
||||
}
|
||||
if (event.type === 'product_created' && (!event.product?.name || event.product.priceInCents === undefined)) {
|
||||
rejected.push({ id, reason: 'a new product needs a name and a price' });
|
||||
continue;
|
||||
}
|
||||
accepted.push(event);
|
||||
}
|
||||
|
||||
@@ -57,6 +61,13 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
|
||||
if (event.type === 'stock_set') {
|
||||
return { ...base, productId: event.productId, stockSetTo: event.quantity ?? null };
|
||||
}
|
||||
if (isProductEvent(event)) {
|
||||
return {
|
||||
...base,
|
||||
productId: event.productId,
|
||||
productData: event.product ? ({ ...event.product } as Prisma.InputJsonValue) : Prisma.JsonNull,
|
||||
};
|
||||
}
|
||||
if (event.type === 'tab_opened' || event.type === 'tab_renamed' || event.type === 'tab_deleted') {
|
||||
return base;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user