From 1e4438ec5a239c3d396b1466d5e7918322415054 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Tue, 20 Oct 2026 14:45:22 +0200 Subject: [PATCH] feat: add stuff --- .../20261021120000_stock/migration.sql | 45 ++ apps/api/prisma/schema.prisma | 67 ++- apps/api/src/app.module.ts | 2 + apps/api/src/devices/devices.service.spec.ts | 92 +++- apps/api/src/devices/devices.service.ts | 84 ++++ apps/api/src/devices/dto/stock-sync.dto.ts | 55 +++ apps/api/src/devices/dto/sync.dto.ts | 17 +- apps/api/src/devices/stock-sync.ts | 54 +++ apps/api/src/devices/tab-events.ts | 2 +- apps/api/src/realtime/realtime.service.ts | 2 +- .../stock/dto/create-stock-adjustments.dto.ts | 43 ++ apps/api/src/stock/dto/update-product.dto.ts | 11 + apps/api/src/stock/stock.controller.ts | 66 +++ apps/api/src/stock/stock.module.ts | 9 + apps/api/src/stock/stock.service.spec.ts | 86 ++++ apps/api/src/stock/stock.service.ts | 172 +++++++ apps/dashboard/src/app/dashboard/layout.tsx | 2 + .../src/app/dashboard/stock/page.tsx | 445 ++++++++++++++++++ apps/dashboard/src/lib/api/client.ts | 61 +++ apps/dashboard/src/lib/i18n.tsx | 134 ++++++ apps/dashboard/src/lib/realtime.tsx | 1 + docs/api.md | 22 + docs/flutter-stock-prompt.md | 268 +++++++++++ packages/shared/src/types/index.ts | 8 +- 24 files changed, 1734 insertions(+), 14 deletions(-) create mode 100644 apps/api/prisma/migrations/20261021120000_stock/migration.sql create mode 100644 apps/api/src/devices/dto/stock-sync.dto.ts create mode 100644 apps/api/src/devices/stock-sync.ts create mode 100644 apps/api/src/stock/dto/create-stock-adjustments.dto.ts create mode 100644 apps/api/src/stock/dto/update-product.dto.ts create mode 100644 apps/api/src/stock/stock.controller.ts create mode 100644 apps/api/src/stock/stock.module.ts create mode 100644 apps/api/src/stock/stock.service.spec.ts create mode 100644 apps/api/src/stock/stock.service.ts create mode 100644 apps/dashboard/src/app/dashboard/stock/page.tsx create mode 100644 docs/flutter-stock-prompt.md diff --git a/apps/api/prisma/migrations/20261021120000_stock/migration.sql b/apps/api/prisma/migrations/20261021120000_stock/migration.sql new file mode 100644 index 0000000..c6c9d60 --- /dev/null +++ b/apps/api/prisma/migrations/20261021120000_stock/migration.sql @@ -0,0 +1,45 @@ +-- CreateTable +CREATE TABLE "Product" ( + "venueId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "category" TEXT, + "priceInCents" INTEGER NOT NULL, + "stock" INTEGER, + "lowStockThreshold" INTEGER, + "reportedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("venueId","productId") +); + +-- CreateTable +CREATE TABLE "StockAdjustment" ( + "id" TEXT NOT NULL, + "venueId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "productName" TEXT NOT NULL, + "type" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "note" TEXT, + "requestedBy" TEXT NOT NULL, + "requestedById" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "stockAfter" INTEGER, + "result" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "processedAt" TIMESTAMP(3), + + CONSTRAINT "StockAdjustment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "StockAdjustment_venueId_status_createdAt_idx" ON "StockAdjustment"("venueId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "StockAdjustment_venueId_productId_createdAt_idx" ON "StockAdjustment"("venueId", "productId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 224c433..7698348 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -13,15 +13,17 @@ model Venue { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - devices Device[] - clientUsers ClientUser[] - tabOwnerships TabOwnership[] - remoteCommands RemoteCommand[] - auditLogs AuditLog[] - regCodes RegistrationCode[] - plannedTabs PlannedTab[] - tabReminders TabReminder[] - tabEvents TabEvent[] + devices Device[] + clientUsers ClientUser[] + tabOwnerships TabOwnership[] + remoteCommands RemoteCommand[] + auditLogs AuditLog[] + regCodes RegistrationCode[] + plannedTabs PlannedTab[] + tabReminders TabReminder[] + tabEvents TabEvent[] + products Product[] + stockAdjustments StockAdjustment[] } model Device { @@ -214,3 +216,50 @@ model TabEvent { @@index([venueId, occurredAt]) @@index([venueId, type, occurredAt]) } + +// The terminal's product catalog with its stock, as last reported by the terminal. The terminal owns the +// stock: it counts sales down offline, so the dashboard only changes it through StockAdjustments. +model Product { + venueId String + // Generated by the terminal + productId String + name String + category String? + priceInCents Int + // Null when the terminal doesn't track stock for this product. Can be negative: a sale is never blocked. + stock Int? + // Dashboard-only setting, never sent to or overwritten by the terminal + lowStockThreshold Int? + reportedAt DateTime + + venue Venue @relation(fields: [venueId], references: [id]) + + @@id([venueId, productId]) +} + +// A stock change requested from the dashboard. It is sent to the terminal on every sync until the terminal +// reports it applied or rejected. +model StockAdjustment { + id String @id @default(uuid()) + venueId String + productId String + productName String + // add (signed: a delivery, or breakage when negative) | set (a stock count) + type String + quantity Int + note String? + requestedBy String + requestedById String + // pending | applied | rejected | cancelled + status String @default("pending") + // The terminal's stock right after applying it + stockAfter Int? + result String? + createdAt DateTime @default(now()) + processedAt DateTime? + + venue Venue @relation(fields: [venueId], references: [id]) + + @@index([venueId, status, createdAt]) + @@index([venueId, productId, createdAt]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b524a5a..4f307dd 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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: [ diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts index 3a8810c..2fdcafa 100644 --- a/apps/api/src/devices/devices.service.spec.ts +++ b/apps/api/src/devices/devices.service.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index c3a0e69..39777ff 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -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 { diff --git a/apps/api/src/devices/dto/stock-sync.dto.ts b/apps/api/src/devices/dto/stock-sync.dto.ts new file mode 100644 index 0000000..3530bb4 --- /dev/null +++ b/apps/api/src/devices/dto/stock-sync.dto.ts @@ -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; +} diff --git a/apps/api/src/devices/dto/sync.dto.ts b/apps/api/src/devices/dto/sync.dto.ts index b811fd9..aeead5a 100644 --- a/apps/api/src/devices/dto/sync.dto.ts +++ b/apps/api/src/devices/dto/sync.dto.ts @@ -77,4 +77,19 @@ export class SyncDto { @IsArray() @ArrayMaxSize(1000) events?: unknown[]; -} \ No newline at end of file + + // 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[]; +} diff --git a/apps/api/src/devices/stock-sync.ts b/apps/api/src/devices/stock-sync.ts new file mode 100644 index 0000000..eab1057 --- /dev/null +++ b/apps/api/src/devices/stock-sync.ts @@ -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(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['products'][number]; + +export function parseStockResults(raw: unknown[]) { + return parseEach(StockAdjustmentResultDto, raw); +} diff --git a/apps/api/src/devices/tab-events.ts b/apps/api/src/devices/tab-events.ts index 6b3c34e..611aa88 100644 --- a/apps/api/src/devices/tab-events.ts +++ b/apps/api/src/devices/tab-events.ts @@ -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 ?? {}), diff --git a/apps/api/src/realtime/realtime.service.ts b/apps/api/src/realtime/realtime.service.ts index 38688d2..76dd34c 100644 --- a/apps/api/src/realtime/realtime.service.ts +++ b/apps/api/src/realtime/realtime.service.ts @@ -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}`; diff --git a/apps/api/src/stock/dto/create-stock-adjustments.dto.ts b/apps/api/src/stock/dto/create-stock-adjustments.dto.ts new file mode 100644 index 0000000..0d424bb --- /dev/null +++ b/apps/api/src/stock/dto/create-stock-adjustments.dto.ts @@ -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[]; +} diff --git a/apps/api/src/stock/dto/update-product.dto.ts b/apps/api/src/stock/dto/update-product.dto.ts new file mode 100644 index 0000000..a5ab8dc --- /dev/null +++ b/apps/api/src/stock/dto/update-product.dto.ts @@ -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; +} diff --git a/apps/api/src/stock/stock.controller.ts b/apps/api/src/stock/stock.controller.ts new file mode 100644 index 0000000..d335fe6 --- /dev/null +++ b/apps/api/src/stock/stock.controller.ts @@ -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); + } +} diff --git a/apps/api/src/stock/stock.module.ts b/apps/api/src/stock/stock.module.ts new file mode 100644 index 0000000..6cf062a --- /dev/null +++ b/apps/api/src/stock/stock.module.ts @@ -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 {} diff --git a/apps/api/src/stock/stock.service.spec.ts b/apps/api/src/stock/stock.service.spec.ts new file mode 100644 index 0000000..1e1b0eb --- /dev/null +++ b/apps/api/src/stock/stock.service.spec.ts @@ -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 }) => ({ 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' } }); + }); +}); diff --git a/apps/api/src/stock/stock.service.ts b/apps/api/src/stock/stock.service.ts new file mode 100644 index 0000000..12b73ac --- /dev/null +++ b/apps/api/src/stock/stock.service.ts @@ -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(); + 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((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 }; + } +} diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx index 49720e8..3f5ae08 100644 --- a/apps/dashboard/src/app/dashboard/layout.tsx +++ b/apps/dashboard/src/app/dashboard/layout.tsx @@ -13,6 +13,7 @@ import { Menu, X, Bell, + Package, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/cn'; @@ -28,6 +29,7 @@ const navItems = [ { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false }, { href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false }, { href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false }, + { href: '/dashboard/stock', key: 'nav.stock', icon: Package, adminOnly: false }, { href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true }, { href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false }, { href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false }, diff --git a/apps/dashboard/src/app/dashboard/stock/page.tsx b/apps/dashboard/src/app/dashboard/stock/page.tsx new file mode 100644 index 0000000..839ce20 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/stock/page.tsx @@ -0,0 +1,445 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Fragment, useState } from 'react'; +import { stockApi, type NewStockAdjustment, type Product, type StockAdjustment } from '@/lib/api/client'; +import { PageHeader } from '@/components/page-header'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { EmptyState } from '@/components/empty-state'; +import { LoadingState } from '@/components/loading-state'; +import { ErrorState } from '@/components/error-state'; +import { toast } from '@/components/ui/toast'; +import { formatCents } from '@/lib/utils'; +import { RefreshCw, Search, Truck, X, History } from 'lucide-react'; +import { useLang } from '@/lib/i18n'; +import { useRealtime } from '@/lib/realtime'; +import { useSession } from '@/lib/session'; + +type Filter = 'all' | 'low' | 'untracked'; +type Mode = 'add' | 'remove' | 'count'; + +const filters: { value: Filter; label: string }[] = [ + { value: 'all', label: 'stock.filter_all' }, + { value: 'low', label: 'stock.filter_low' }, + { value: 'untracked', label: 'stock.filter_untracked' }, +]; + +/** At or below its warning level, or out of stock. */ +function isLow(product: Product): boolean { + if (product.stock === null) return false; + return product.stock <= 0 || (product.lowStockThreshold !== null && product.stock <= product.lowStockThreshold); +} + +/** "+24", "−6" or "= 48". */ +function formatChange(adjustment: Pick): string { + if (adjustment.type === 'set') return `= ${adjustment.quantity}`; + return adjustment.quantity > 0 ? `+${adjustment.quantity}` : `−${-adjustment.quantity}`; +} + +// Rejection reasons the terminal sends as a code; anything else is shown as it was sent +const KNOWN_REASONS = ['unknown_product', 'stock_not_tracked']; + +const statusVariant = { + pending: 'warning', + applied: 'success', + rejected: 'destructive', + cancelled: 'secondary', +} as const; + +export default function StockPage() { + // Live updates make polling unnecessary; it only runs while the socket is down + const { connected: live } = useRealtime(); + const { venueId, role } = useSession(); + const isAdmin = role === 'admin'; + const queryClient = useQueryClient(); + const { t, tn, te, relativeTime } = useLang(); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState('all'); + const [adjusting, setAdjusting] = useState(null); + const [deliveryOpen, setDeliveryOpen] = useState(false); + + const { data, isLoading, error } = useQuery({ + queryKey: ['stock', venueId], + queryFn: () => stockApi.list(venueId), + refetchInterval: live ? false : 15000, + }); + + const historyQuery = useQuery({ + queryKey: ['stock-history', venueId], + queryFn: () => stockApi.history(venueId, { limit: 20 }), + refetchInterval: live ? false : 15000, + }); + + const cancelMutation = useMutation({ + mutationFn: (id: string) => stockApi.cancel(venueId, id), + onSuccess: () => toast({ title: t('stock.cancelled') }), + onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['stock'] }); + queryClient.invalidateQueries({ queryKey: ['stock-history'] }); + }, + }); + + if (isLoading) return ; + if (error) return ; + + const products = data?.products ?? []; + const lowCount = products.filter(isLow).length; + const query = search.trim().toLowerCase(); + const visible = products.filter((p) => { + if (query && !p.name.toLowerCase().includes(query) && !(p.category ?? '').toLowerCase().includes(query)) return false; + if (filter === 'low') return isLow(p); + if (filter === 'untracked') return p.stock === null; + return true; + }); + + // Products arrive sorted by category, then name + const groups: { category: string | null; products: Product[] }[] = []; + for (const product of visible) { + const last = groups[groups.length - 1]; + if (last && last.category === product.category) last.products.push(product); + else groups.push({ category: product.category, products: [product] }); + } + + return ( +
+ + + {products.some((p) => p.stock !== null) && ( + + )} + + } + /> + + {products.length === 0 ? ( + + ) : ( + <> +
+
+ + setSearch(e.target.value)} + className="pl-8" + aria-label={t('stock.search_placeholder')} + /> +
+ {filters.map((f) => ( + + ))} +
+ +
+ + + + {t('stock.product')} + {t('stock.price')} + {t('stock.stock')} + {t('stock.pending')} + {t('stock.actions')} + + + + {visible.length === 0 && ( + + + {t('stock.no_matches')} + + + )} + {groups.map((group) => ( + + + + {group.category ?? t('stock.no_category')} + + + {group.products.map((product) => ( + + {product.name} + {formatCents(product.priceInCents)} + + +
+ {product.pendingAdjustments.map((a) => ( + + {formatChange(a)} + + + ))} + {product.pendingAdjustments.length === 0 && —} +
+
+ + + +
+ ))} +
+ ))} +
+
+
+ + )} + + + + {t('stock.history')} + + + {(historyQuery.data?.adjustments ?? []).length === 0 ? ( +

{t('stock.history_empty')}

+ ) : ( +
    + {historyQuery.data!.adjustments.map((a) => ( +
  • +
    + {a.productName} + {formatChange(a)} + {a.stockAfter !== null && {t('stock.now', { stock: a.stockAfter })}} + {a.note && · {a.note}} + {a.status === 'rejected' && a.result && ( + · {KNOWN_REASONS.includes(a.result) ? t(`stock.reason.${a.result}`) : a.result} + )} +
    +
    + {t(`stock.status.${a.status}`)} + {a.requestedBy} + {relativeTime(a.createdAt)} +
    +
  • + ))} +
+ )} +
+
+ + {adjusting && setAdjusting(null)} />} + {deliveryOpen && ( + p.stock !== null)} venueId={venueId} onClose={() => setDeliveryOpen(false)} /> + )} +
+ ); +} + +function StockLevel({ product }: { product: Product }) { + const { t } = useLang(); + if (product.stock === null) return {t('stock.not_tracked')}; + return ( +
+ {product.stock} + {product.stock <= 0 + ? {t('stock.out')} + : isLow(product) && {t('stock.low')}} +
+ ); +} + +function useAdjustMutation(venueId: string, onDone: () => void) { + const queryClient = useQueryClient(); + const { t, te } = useLang(); + return useMutation({ + mutationFn: (adjustments: NewStockAdjustment[]) => stockApi.adjust(venueId, adjustments), + onSuccess: () => { + toast({ title: t('stock.sent'), description: t('stock.sent_desc'), variant: 'success' }); + onDone(); + }, + onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['stock'] }); + queryClient.invalidateQueries({ queryKey: ['stock-history'] }); + }, + }); +} + +function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product: Product; venueId: string; canSetThreshold: boolean; onClose: () => void }) { + const { t, te } = useLang(); + const queryClient = useQueryClient(); + const tracked = product.stock !== null; + const [mode, setMode] = useState(tracked ? 'add' : 'count'); + const [quantity, setQuantity] = useState(''); + const [note, setNote] = useState(''); + const [threshold, setThreshold] = useState(product.lowStockThreshold?.toString() ?? ''); + const mutation = useAdjustMutation(venueId, onClose); + + const amount = /^\d+$/.test(quantity.trim()) ? parseInt(quantity, 10) : null; + const valid = amount !== null && (mode === 'count' || amount > 0); + const preview = valid && tracked + ? mode === 'count' ? amount! : product.stock! + (mode === 'add' ? amount! : -amount!) + : null; + + const thresholdValue = threshold.trim() === '' ? null : /^\d+$/.test(threshold.trim()) ? parseInt(threshold, 10) : undefined; + const thresholdMutation = useMutation({ + mutationFn: () => stockApi.setLowStockThreshold(venueId, product.productId, thresholdValue!), + onSuccess: () => { + toast({ title: t('stock.threshold_saved') }); + queryClient.invalidateQueries({ queryKey: ['stock'] }); + }, + onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }), + }); + + function submit() { + if (!valid) return; + mutation.mutate([{ + productId: product.productId, + type: mode === 'count' ? 'set' : 'add', + quantity: mode === 'remove' ? -amount! : amount!, + note: note.trim() || undefined, + }]); + } + + return ( + !open && !mutation.isPending && onClose()}> + + + {product.name} + + {tracked ? t('stock.current', { stock: product.stock! }) : t('stock.start_tracking_desc')} + + +
+ {tracked && ( + setMode(value as Mode)}> +
+
+
+
+ )} +
+ + setQuantity(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && submit()} + autoFocus + /> + {preview !== null &&

{t('stock.preview', { from: product.stock!, to: preview })}

} + {mode === 'count' && tracked &&

{t('stock.count_hint')}

} +
+
+ + setNote(e.target.value)} /> +
+ {/* The warning level is an admin setting; everyone can change the stock itself */} + {tracked && canSetThreshold && ( +
+ +
+ setThreshold(e.target.value)} className="max-w-[8rem]" /> + +
+
+ )} +
+ + + + +
+
+ ); +} + +/** One delivery adds to several products at once. */ +function DeliveryDialog({ products, venueId, onClose }: { products: Product[]; venueId: string; onClose: () => void }) { + const { t, tn } = useLang(); + const [quantities, setQuantities] = useState>({}); + const [note, setNote] = useState(''); + const [search, setSearch] = useState(''); + const mutation = useAdjustMutation(venueId, onClose); + + const entries = Object.entries(quantities) + .filter(([, value]) => /^\d+$/.test(value.trim()) && parseInt(value, 10) > 0) + .map(([productId, value]) => ({ productId, quantity: parseInt(value, 10) })); + const invalid = Object.values(quantities).some((value) => value.trim() !== '' && !/^\d+$/.test(value.trim())); + const query = search.trim().toLowerCase(); + const visible = products.filter((p) => !query || p.name.toLowerCase().includes(query)); + + function submit() { + if (entries.length === 0 || invalid) return; + mutation.mutate(entries.map((e) => ({ productId: e.productId, type: 'add', quantity: e.quantity, note: note.trim() || undefined }))); + } + + return ( + !open && !mutation.isPending && onClose()}> + + + {t('stock.delivery_title')} + {t('stock.delivery_desc')} + + setSearch(e.target.value)} aria-label={t('stock.search_placeholder')} /> +
+ {visible.map((product) => ( +
+ + setQuantities((current) => ({ ...current, [product.productId]: e.target.value }))} + className="w-20 h-8 text-right" + /> +
+ ))} +
+
+ + setNote(e.target.value)} /> +
+ + + + +
+
+ ); +} diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index cbd2532..e9ce24d 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -92,6 +92,41 @@ export interface Reminder { status: string; } +export type StockAdjustmentType = 'add' | 'set'; +export type StockAdjustmentStatus = 'pending' | 'applied' | 'rejected' | 'cancelled'; + +export interface StockAdjustment { + id: string; + productId: string; + productName: string; + /** add: the stock changes by quantity (negative takes stock out); set: the stock is now quantity */ + type: StockAdjustmentType; + quantity: number; + note: string | null; + requestedBy: string; + status: StockAdjustmentStatus; + /** The terminal's stock right after applying it */ + stockAfter: number | null; + /** Why the terminal rejected it */ + result: string | null; + createdAt: string; + processedAt: string | null; +} + +export interface Product { + productId: string; + name: string; + category: string | null; + priceInCents: number; + /** Null when the terminal doesn't track stock for this product; can be negative */ + stock: number | null; + lowStockThreshold: number | null; + reportedAt: string; + pendingAdjustments: StockAdjustment[]; +} + +export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string }; + export function getCookie(name: string): string | null { if (typeof document === 'undefined') return null; return document.cookie @@ -294,6 +329,32 @@ export const remindersApi = { api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }), }; +// Stock API +export const stockApi = { + list: (venueId: string) => + api<{ products: Product[]; reportedAt: string | null }>(`/venues/${venueId}/stock`), + history: (venueId: string, params?: { productId?: string; limit?: number; offset?: number }) => { + const searchParams = new URLSearchParams(); + if (params?.productId) searchParams.set('productId', params.productId); + if (params?.limit) searchParams.set('limit', String(params.limit)); + if (params?.offset) searchParams.set('offset', String(params.offset)); + const qs = searchParams.toString(); + return api<{ adjustments: StockAdjustment[]; total: number }>(`/venues/${venueId}/stock/adjustments${qs ? `?${qs}` : ''}`); + }, + adjust: (venueId: string, adjustments: NewStockAdjustment[]) => + api<{ adjustments: StockAdjustment[] }>(`/venues/${venueId}/stock/adjustments`, { + method: 'POST', + body: JSON.stringify({ adjustments }), + }), + cancel: (venueId: string, id: string) => + api(`/venues/${venueId}/stock/adjustments/${id}/cancel`, { method: 'POST' }), + setLowStockThreshold: (venueId: string, productId: string, lowStockThreshold: number | null) => + api(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, { + method: 'PUT', + body: JSON.stringify({ lowStockThreshold }), + }), +}; + export { setTokens, clearTokens, refreshAccessToken }; export function getAccessToken(): string | null { diff --git a/apps/dashboard/src/lib/i18n.tsx b/apps/dashboard/src/lib/i18n.tsx index f163d34..3b3a716 100644 --- a/apps/dashboard/src/lib/i18n.tsx +++ b/apps/dashboard/src/lib/i18n.tsx @@ -262,6 +262,73 @@ const translations: Record> = { 'reminders.person_history': 'Reminders for {name}', 'reminders.person_history_desc': '{count} reminder sent', 'reminders.person_history_desc_plural': '{count} reminders sent', + 'nav.stock': 'Stock', + 'errors.product_not_found': 'This product is no longer on the terminal', + 'errors.stock_not_tracked': "Stock isn't tracked for this product yet. Count it first.", + 'errors.invalid_quantity': 'Enter a valid quantity', + 'errors.adjustment_not_pending': 'The terminal already processed this change', + 'errors.adjustment_not_found': 'This stock change no longer exists', + 'stock.title': 'Stock', + 'stock.count': '{count} product', + 'stock.count_plural': '{count} products', + 'stock.low_count': '{count} running low', + 'stock.low_count_plural': '{count} running low', + 'stock.reported': 'reported {time}', + 'stock.load_failed': 'Failed to load stock', + 'stock.empty': 'No products yet', + 'stock.empty_desc': 'The terminal reports its products and stock once it runs an app version with stock.', + 'stock.search_placeholder': 'Search products', + 'stock.filter_all': 'All', + 'stock.filter_low': 'Running low', + 'stock.filter_untracked': 'Not tracked', + 'stock.no_matches': 'No products match', + 'stock.no_category': 'Other', + 'stock.product': 'Product', + 'stock.price': 'Price', + 'stock.stock': 'Stock', + 'stock.pending': 'Waiting for terminal', + 'stock.actions': 'Actions', + 'stock.not_tracked': 'Not tracked', + 'stock.low': 'Low', + 'stock.out': 'Out', + 'stock.adjust': 'Adjust', + 'stock.start_tracking': 'Track stock', + 'stock.start_tracking_desc': "The terminal doesn't track stock for this product yet. Enter how many you have to start tracking it.", + 'stock.cancel_adjustment': 'Cancel {change} for {name}', + 'stock.cancelled': 'Change cancelled', + 'stock.current': 'Currently {stock} in stock on the terminal.', + 'stock.mode_add': 'Add (delivery)', + 'stock.mode_remove': 'Take out (breakage, own use…)', + 'stock.mode_count': 'Count (set the stock to what you counted)', + 'stock.quantity': 'Quantity', + 'stock.counted': 'Counted', + 'stock.preview': '{from} → {to}', + 'stock.count_hint': 'Drinks sold between your count and the terminal receiving it are not subtracted.', + 'stock.note': 'Note (optional)', + 'stock.note_placeholder': 'e.g. delivery from the brewery', + 'stock.threshold': 'Warn when stock is at or below', + 'stock.threshold_none': 'No warning', + 'stock.threshold_save': 'Save', + 'stock.threshold_saved': 'Warning level saved', + 'stock.send': 'Send to terminal', + 'stock.sending': 'Sending…', + 'stock.sent': 'Sent to the terminal', + 'stock.sent_desc': 'The stock changes on the terminal at its next sync.', + 'stock.delivery': 'Register delivery', + 'stock.delivery_title': 'Register delivery', + 'stock.delivery_desc': 'Enter how many of each product were delivered. Products you leave empty are not changed.', + 'stock.delivery_note_placeholder': 'e.g. supplier or delivery note number', + 'stock.delivery_send': 'Add {count} product', + 'stock.delivery_send_plural': 'Add {count} products', + 'stock.history': 'Recent stock changes', + 'stock.history_empty': 'No stock changes from the dashboard yet', + 'stock.now': '→ {stock}', + 'stock.status.pending': 'Waiting', + 'stock.status.applied': 'Applied', + 'stock.status.rejected': 'Rejected', + 'stock.status.cancelled': 'Cancelled', + 'stock.reason.unknown_product': 'product not on the terminal', + 'stock.reason.stock_not_tracked': 'stock not tracked on the terminal', }, nl: { 'nav.overview': 'Overzicht', @@ -521,6 +588,73 @@ const translations: Record> = { 'reminders.person_history': 'Herinneringen voor {name}', 'reminders.person_history_desc': '{count} herinnering verstuurd', 'reminders.person_history_desc_plural': '{count} herinneringen verstuurd', + 'nav.stock': 'Voorraad', + 'errors.product_not_found': 'Dit product staat niet meer op de kassa', + 'errors.stock_not_tracked': 'Voor dit product wordt nog geen voorraad bijgehouden. Tel het eerst.', + 'errors.invalid_quantity': 'Vul een geldig aantal in', + 'errors.adjustment_not_pending': 'De kassa heeft deze wijziging al verwerkt', + 'errors.adjustment_not_found': 'Deze voorraadwijziging bestaat niet meer', + 'stock.title': 'Voorraad', + 'stock.count': '{count} product', + 'stock.count_plural': '{count} producten', + 'stock.low_count': '{count} bijna op', + 'stock.low_count_plural': '{count} bijna op', + 'stock.reported': 'gemeld {time}', + 'stock.load_failed': 'Voorraad laden mislukt', + 'stock.empty': 'Nog geen producten', + 'stock.empty_desc': 'De kassa meldt haar producten en voorraad zodra ze een app-versie met voorraad heeft.', + 'stock.search_placeholder': 'Zoek producten', + 'stock.filter_all': 'Alle', + 'stock.filter_low': 'Bijna op', + 'stock.filter_untracked': 'Niet bijgehouden', + 'stock.no_matches': 'Geen producten gevonden', + 'stock.no_category': 'Overige', + 'stock.product': 'Product', + 'stock.price': 'Prijs', + 'stock.stock': 'Voorraad', + 'stock.pending': 'Wacht op kassa', + 'stock.actions': 'Acties', + 'stock.not_tracked': 'Niet bijgehouden', + 'stock.low': 'Bijna op', + 'stock.out': 'Op', + 'stock.adjust': 'Aanpassen', + 'stock.start_tracking': 'Voorraad bijhouden', + 'stock.start_tracking_desc': 'De kassa houdt voor dit product nog geen voorraad bij. Vul in hoeveel je er hebt om te beginnen.', + 'stock.cancel_adjustment': '{change} voor {name} annuleren', + 'stock.cancelled': 'Wijziging geannuleerd', + 'stock.current': 'Nu {stock} in voorraad op de kassa.', + 'stock.mode_add': 'Bijvullen (levering)', + 'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)', + 'stock.mode_count': 'Tellen (voorraad wordt wat je telde)', + 'stock.quantity': 'Aantal', + 'stock.counted': 'Geteld', + 'stock.preview': '{from} → {to}', + 'stock.count_hint': 'Wat verkocht wordt tussen je telling en het moment dat de kassa ze ontvangt, wordt niet afgetrokken.', + 'stock.note': 'Notitie (optioneel)', + 'stock.note_placeholder': 'bv. levering brouwerij', + 'stock.threshold': 'Waarschuw vanaf', + 'stock.threshold_none': 'Geen waarschuwing', + 'stock.threshold_save': 'Opslaan', + 'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen', + 'stock.send': 'Naar kassa sturen', + 'stock.sending': 'Versturen…', + 'stock.sent': 'Naar de kassa gestuurd', + 'stock.sent_desc': 'De voorraad wijzigt op de kassa bij de volgende synchronisatie.', + 'stock.delivery': 'Levering invoeren', + 'stock.delivery_title': 'Levering invoeren', + 'stock.delivery_desc': 'Vul in hoeveel er van elk product geleverd is. Lege producten blijven ongewijzigd.', + 'stock.delivery_note_placeholder': 'bv. leverancier of leveringsbon', + 'stock.delivery_send': '{count} product bijvullen', + 'stock.delivery_send_plural': '{count} producten bijvullen', + 'stock.history': 'Recente voorraadwijzigingen', + 'stock.history_empty': 'Nog geen voorraadwijzigingen vanuit het dashboard', + 'stock.now': '→ {stock}', + 'stock.status.pending': 'Wacht', + 'stock.status.applied': 'Verwerkt', + 'stock.status.rejected': 'Geweigerd', + 'stock.status.cancelled': 'Geannuleerd', + 'stock.reason.unknown_product': 'product staat niet op de kassa', + 'stock.reason.stock_not_tracked': 'voorraad niet bijgehouden op de kassa', }, }; diff --git a/apps/dashboard/src/lib/realtime.tsx b/apps/dashboard/src/lib/realtime.tsx index f93c5b4..ce06cb3 100644 --- a/apps/dashboard/src/lib/realtime.tsx +++ b/apps/dashboard/src/lib/realtime.tsx @@ -13,6 +13,7 @@ const TOPIC_QUERIES: Record = { device: [['device']], reminders: [['reminder-candidates'], ['reminder-history']], users: [['users']], + stock: [['stock'], ['stock-history']], }; const RealtimeContext = createContext({ connected: false }); diff --git a/docs/api.md b/docs/api.md index 459644a..bbe07cf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -55,6 +55,20 @@ Sync semantics: The response lists `acknowledgedEventIds` (stored, including events already stored by an earlier sync) and `rejectedEvents` (`[{ id, reason }]`, invalid and never stored). The terminal removes both from its outbox. An invalid event never fails the sync. At most 1000 events per sync. +- `products` (optional) is the terminal's **complete** product catalog: + `[{ id, name, category?, priceInCents, stock }]`, where `stock` is an integer (may be negative) or `null` when + the terminal doesn't track stock for that product. Products left out are removed from the dashboard. When the + field is omitted (older app versions) the stored products are left alone. An invalid product is skipped and + logged, never failing the sync. At most 2000 products. +- `stockAdjustments` in the response lists **every** pending stock change requested from the dashboard + for the venue, oldest first: `[{ id, productId, type, quantity, note, requestedBy, createdAt }]`. + `type: "add"` changes the stock by `quantity` (negative takes stock out); `type: "set"` makes the stock + `quantity` (a count, ≥ 0, and starts tracking an untracked product). They are re-sent on every sync until the + terminal reports an outcome, so the terminal must apply each id only once. +- `stockAdjustmentResults` (optional) reports outcomes: `[{ id, status: "applied" | "rejected", stockAfter?, reason? }]` + (`stockAfter` is required for `applied`). The response lists `acknowledgedStockAdjustmentResultIds` and + `rejectedStockAdjustmentResults` (`[{ id, reason }]`); the terminal drops both from its outbox. The terminal's + outcome is recorded even if the adjustment was cancelled on the dashboard in the meantime. ### Tabs `GET /venues/:venueId/tabs` - List open tabs @@ -75,6 +89,14 @@ Names match case-insensitively and keep their existing spelling; `replace` also `GET /venues/:venueId/reminders?customerName=` - Reminder history, optionally for one customer `POST /venues/:venueId/reminders/test/:plannedTabId` - Send a test email (admin) +### Stock +`GET /venues/:venueId/stock` - The terminal's products with stock, low stock level and pending adjustments +`GET /venues/:venueId/stock/adjustments?productId=&limit=&offset=` - Stock adjustment history, newest first +`POST /venues/:venueId/stock/adjustments` - Request stock changes (admins and viewers): `{ adjustments: [{ productId, type: 'add' | 'set', quantity, note? }] }`. + `add` takes a non-zero quantity (negative for breakage) and needs a tracked product; `set` takes 0 or more. +`POST /venues/:venueId/stock/adjustments/:id/cancel` - Cancel an adjustment the terminal hasn't received yet (admins and viewers) +`PUT /venues/:venueId/stock/products/:productId` - Set `{ lowStockThreshold: number | null }` (admin, dashboard only) + ### Commands `POST /venues/:venueId/commands` - Create a command `GET /venues/:venueId/commands` - List command history diff --git a/docs/flutter-stock-prompt.md b/docs/flutter-stock-prompt.md new file mode 100644 index 0000000..84bae76 --- /dev/null +++ b/docs/flutter-stock-prompt.md @@ -0,0 +1,268 @@ +## KoolTap Flutter App — Stock: Track It, Report It, Apply Changes from the Dashboard + +The dashboard (backoffice) now has a **Stock** page. Managers can see how much of every product is left, set a +low stock warning level, register deliveries, take stock out (breakage, own use) and enter stock counts. + +The **terminal stays the owner of the stock**. It counts sales down itself, also while offline, and reports +its product catalog with the current stock on every sync. The dashboard never overwrites the stock directly: +it sends **stock adjustments** through the sync response, the terminal applies them and reports the outcome +in the next sync. + +Add three things to the app: + +1. **Stock tracking**: a nullable stock per product that goes down when drinks are put on a tab and back up when + they are taken off. +2. **Report the catalog**: send the complete product list with stock in every sync. +3. **Apply stock adjustments** from the sync response, exactly once each, and report the outcome through a + small outbox. + +Nothing else about the sync changes: `openTabs`, `events`, commands, acks and `plannedTabNames` keep working +exactly as they do now. + +### Existing Files (read these first) + +- `lib/models/remote_command.dart` — `SyncResponse`, `RemoteCommand`, `SnapshotTab` and the sync request +- `lib/services/remote_sync_service.dart` — the sync loop (`_performSync()` or similar) +- `lib/services/bar_tab_service.dart` — creates and changes tabs, adds/removes lines, settles tabs, and writes + the tab events (`line_added`, `line_removed`, `settled`) to the event outbox +- `lib/database/app_database.dart` — the Drift database schema and migrations (has the `TabEvents` outbox) +- The product model, its table and the screen(s) where products are managed (search for `Product`, `products`, + `price`) + +Before writing code: +- Find the product model and table. Note how a product is identified (its id must be **stable**: the same + product must keep the same id across app restarts and edits) and whether it already has a stock or category + field. +- Find **every** place that writes a `line_added` or `line_removed` event. Stock changes in exactly those + places. List them in your plan. + +### 1. Stock per Product (Drift) + +If the product table has no stock column yet, add one, bump `schemaVersion` and add a migration step +(existing data must stay): + +```dart +IntColumn get stock => integer().nullable()(); // null = stock is not tracked for this product +``` + +- `null` means **not tracked**: nothing happens to it on sales, and it is shown as "not tracked" on the + dashboard. All existing products start as `null`. +- The value **may go negative**. Never block or warn-block a sale because stock is 0 or below: the bar must + keep serving, the count was simply off. +- If the product table already has a stock field, use it instead of adding a new one, and map its meaning to + the rules above (e.g. a separate "track stock" flag → send `null` when tracking is off). + +### 2. Change Stock Together with the Tab Events + +Every time the app writes a tab event for a line, change the stock of that product **in the same database +transaction** (only when the product's stock is not `null`): + +| What happens on the tab | Event written (already exists) | Stock change | +|---|---|---| +| Product added / quantity increased by N | `line_added` with `quantity: N` | `stock -= N` | +| Quantity decreased by N / line removed | `line_removed` with `quantity: N` | `stock += N` | +| Price of a line changed | `line_removed` + `line_added` | net zero (do both, they cancel out) | +| Tab deleted while it still has lines | `line_removed` per line | `stock += quantity` per line | +| Tab settled (locally or by a remote `close_tab`) | `settled` | **no change** — the drinks were already counted down when they were added | +| Customer renamed | none | none | + +Lines are matched to products by the product the line was created from. If a line has no link to a product +(e.g. a free-text line, or the product was deleted since), change no stock for it. + +Put this in the same helper that writes the line events (e.g. `TabEventFactory` or the methods on +`BarTabService`), so a line event and its stock change can never drift apart. + +Optional, if it fits the existing UI: show the stock on the product buttons when tracked (e.g. a small +number in a corner, red at 0 or below). Optional too: a local "set stock" action on the product management +screen — a local change is simply part of the stock that the next sync reports. + +### 3. Report the Catalog in the Sync + +Add a `products` field to the sync request with **all** products (also untracked ones and products that are +hidden/inactive but still exist), every sync: + +```json +{ + "deviceId": "…", + "openTabs": [ … ], + "events": [ … ], + "products": [ + { "id": "12", "name": "Cola", "category": "Frisdrank", "priceInCents": 250, "stock": 48 }, + { "id": "13", "name": "Water", "category": null, "priceInCents": 200, "stock": null } + ], + "stockAdjustmentResults": [ … ] +} +``` + +| Field | Type | Notes | +|---|---|---| +| `id` | string | The product's stable id. If it is an int in the app, send `id.toString()`. Max 100 chars. | +| `name` | string | As shown on the terminal. Not empty, max 200 chars. | +| `category` | string or null | The product's category/group name if the app has one, else `null` | +| `priceInCents` | int ≥ 0 | Current price | +| `stock` | int or null | Current stock, may be negative; `null` when not tracked. **Always send the key**, also when null. | + +- This is the **complete** list: a product left out is removed from the dashboard. Never send a partial list. +- Read the products in the same database read as the open tabs and events (after applying the stock + adjustments of the previous response, see step 4), so the stock matches the events that are sent. +- At most 2000 products. The server skips an invalid product (and logs it) instead of failing the sync. + +### 4. Apply Stock Adjustments from the Response + +The sync response now contains: + +```json +{ + "stockAdjustments": [ + { "id": "5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c", "productId": "12", "type": "add", "quantity": 24, + "note": "Delivery brewery", "requestedBy": "bram@example.com", "createdAt": "2026-10-24T15:02:11.000Z" }, + { "id": "6f1c9c5f-2e3d-4a4b-8c9d-8e7f6a5b4c3d", "productId": "13", "type": "set", "quantity": 30, + "note": null, "requestedBy": "bram@example.com", "createdAt": "2026-10-24T15:03:40.000Z" } + ], + "acknowledgedStockAdjustmentResultIds": [ "…" ], + "rejectedStockAdjustmentResults": [ { "id": "…", "reason": "…" } ] +} +``` + +- `type: "add"` → `stock += quantity`. `quantity` is never 0 and **can be negative** (stock taken out: + breakage, own use). +- `type: "set"` → `stock = quantity` (a stock count, always ≥ 0). On a product whose stock is `null` this + **starts tracking** it. +- They arrive **oldest first**. Apply them in that order. +- The server sends **every** pending adjustment on **every** sync until it has received the outcome. The same + adjustment will therefore arrive several times: **apply each id only once**. + +Add an outbox table for the outcomes, which doubles as the record of what was already applied: + +```dart +class StockAdjustmentResults extends Table { + TextColumn get id => text()(); // the adjustment id from the server + TextColumn get payload => text()(); // the result as JSON, see below + BoolColumn get acknowledged => boolean().withDefault(const Constant(false))(); + DateTimeColumn get createdAt => dateTime()(); + @override + Set get primaryKey => {id}; +} +``` + +For each adjustment in `stockAdjustments`, in order, in **one database transaction per adjustment**: + +1. If a row with this id exists in `StockAdjustmentResults` → skip it (already applied or rejected). +2. Look up the product by `productId`: + - Not found → result `{ "id", "status": "rejected", "reason": "unknown_product" }`. + - `type == "add"` and the product's stock is `null` → result + `{ "id", "status": "rejected", "reason": "stock_not_tracked" }`. + - Otherwise apply it and write result `{ "id", "status": "applied", "stockAfter": }`. +3. Insert the result row (`acknowledged = false`) in the same transaction as the stock change, so a crash can + never apply an adjustment without remembering it (it would be applied twice) or the other way around. + +The reasons `unknown_product` and `stock_not_tracked` are shown translated on the dashboard; any other +reason string is shown as is. + +If any adjustment was applied, start the next sync right away (instead of waiting for the normal interval), +so the dashboard shows the new stock within seconds. Don't loop: one extra sync is enough. + +### 5. Send the Outcomes and Clean Up + +In the sync request, add `stockAdjustmentResults` with the payloads of the **oldest 500** rows where +`acknowledged = false` (ordered by `createdAt`): + +```json +"stockAdjustmentResults": [ + { "id": "5e0b8b4e-1d2c-4f3a-9b8c-7d6e5f4a3b2c", "status": "applied", "stockAfter": 72 }, + { "id": "6f1c9c5f-2e3d-4a4b-8c9d-8e7f6a5b4c3d", "status": "rejected", "reason": "unknown_product" } +] +``` + +After a successful sync: +- Set `acknowledged = true` for every id in `acknowledgedStockAdjustmentResultIds`. +- Do the same for every id in `rejectedStockAdjustmentResults` and log `id` + `reason` (debug log is fine): + re-sending won't help, it's a bug in the app. +- **Don't delete** acknowledged rows right away: they are the record that prevents applying an adjustment + twice if it is still in a response that was computed before the server saw the outcome. Delete + acknowledged rows older than 30 days (e.g. once per app start). + +If the sync request fails (network, 5xx), change nothing; the same results are sent again next time. The +server records each outcome once, so re-sending is safe. A late outcome is still recorded on the server even +if the adjustment was cancelled on the dashboard in the meantime: what the terminal did is the truth. + +### Order Within One Sync + +1. Read events, open tabs, **products** and unacknowledged **stock results** (one consistent read). +2. Send the request. +3. On success: delete acknowledged/rejected events (existing), mark acknowledged/rejected stock results. +4. Process commands (existing), planned tab names (existing), then **apply `stockAdjustments`**. +5. If any adjustment was applied, schedule one immediate follow-up sync. + +### Models (`lib/models/remote_command.dart`) + +Add to the request: `List> products` and `List> stockAdjustmentResults`. + +Add to `SyncResponse` (all optional, default to empty lists so an older server still works): + +```dart +final List stockAdjustments; // json['stockAdjustments'] +final List acknowledgedStockAdjustmentResultIds; // json['acknowledgedStockAdjustmentResultIds'] +final List rejectedStockAdjustmentResults; // json['rejectedStockAdjustmentResults']: [{ "id": String?, "reason": String }] +``` + +```dart +class StockAdjustment { + final String id; + final String productId; + final String type; // 'add' | 'set'; ignore (don't apply, don't report) any other type + final int quantity; + final String? note; + final String requestedBy; + final DateTime createdAt; +} +``` + +Reuse the existing `RejectedEvent` class for `rejectedStockAdjustmentResults` (same shape). + +### Important Notes + +- Amounts are integers in cents everywhere, like `SnapshotTab.totalInCents`. +- Stock is a whole number of pieces (bottles, cans, glasses…), never a fraction. +- Stock tracking must never slow down or block putting drinks on a tab. +- Keep all of this in the local database, never only in memory: an adjustment received just before the tablet + dies must not be applied twice or lost. +- Don't change how tabs, versions, `openTabs`, `events`, commands or planned tabs work. + +### Testing + +Unit tests: +- Adding 3 Cola to a tab lowers Cola's stock by 3; reducing the line to 1 raises it by 2; deleting the tab with + the line raises it by 1. All in the same transaction as the matching event. +- Settling a tab (locally and via `close_tab`) does not change stock. +- A product with `stock == null` never changes on sales. +- Stock can go below 0 and the sale still goes through. +- `add` +24 on stock 48 → 72, result `applied` with `stockAfter: 72`. `add` −6 → 42. +- `set` 30 on an untracked product → stock 30 (tracking starts), result `applied`. +- `add` on an untracked product → `rejected` / `stock_not_tracked`, stock stays `null`. +- Unknown `productId` → `rejected` / `unknown_product`. +- The same adjustment received twice (also in two different sync responses) is applied once. +- The sync sends the complete product list (stock `null` included as a key) and at most 500 unacknowledged + results, oldest first; it marks exactly the acknowledged and rejected ids; a failed sync changes nothing. + +Manual check against the server: +1. Sync once: the dashboard's Stock page lists all products, all "Not tracked". +2. On the dashboard click **Track stock** on Cola and count 48. Within a sync or two the terminal shows 48 and + the dashboard history shows the change as **Applied** with "→ 48". +3. Put 2 Cola on a tab: after the next sync the dashboard shows 46. +4. **Register delivery** with 24 Cola: the terminal shows 70. +5. Turn off wifi, send a delivery from the dashboard, sell a few Cola on the terminal, turn wifi back on: the + delivery is applied once, the sales are counted down, and the dashboard ends up with the same number as the + terminal. +6. On the server: `SELECT "productName", type, quantity, status, "stockAfter", result FROM "StockAdjustment" ORDER BY "createdAt";` + +### Files to Modify + +- `lib/database/app_database.dart` — `stock` on the product table (if missing), `StockAdjustmentResults` table, + schema version and migration +- `lib/services/bar_tab_service.dart` (and the event helper) — change stock together with every line event +- `lib/models/remote_command.dart` — `products` and `stockAdjustmentResults` in the sync request; + `stockAdjustments`, `acknowledgedStockAdjustmentResultIds` and `rejectedStockAdjustmentResults` in `SyncResponse` +- `lib/services/remote_sync_service.dart` — send products and results, apply adjustments, mark results + acknowledged, prune old ones, one immediate follow-up sync after applying +- Optional: the product grid / product management screen to show (and locally set) stock diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 3ec624c..58f4d86 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -16,4 +16,10 @@ export type AuditAction = | 'command_expired' | 'device_registered' | 'device_revoked' - | 'snapshot_conflict'; \ No newline at end of file + | 'snapshot_conflict' + | 'stock_adjustments_created' + | 'stock_adjustment_cancelled'; + +export type StockAdjustmentType = 'add' | 'set'; + +export type StockAdjustmentStatus = 'pending' | 'applied' | 'rejected' | 'cancelled'; \ No newline at end of file