feat: add stuff
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 14:45:22 +02:00
parent ee06af8395
commit 1e4438ec5a
24 changed files with 1734 additions and 14 deletions
@@ -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;
+49
View File
@@ -22,6 +22,8 @@ model Venue {
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])
}
+2
View File
@@ -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: [
+91 -1
View File
@@ -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);
});
});
+84
View File
@@ -15,6 +15,7 @@ import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
import { customerNameKey } from '../planned-tabs/customer-name';
import { AckCommandDto } from './dto/ack-command.dto';
import { parseTabEvents, tabEventRow } from './tab-events';
import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync';
@Injectable()
export class DevicesService {
@@ -39,6 +40,15 @@ export class DevicesService {
for (const { id, reason } of events.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`);
}
// Omitted by app versions without stock: the products are then left as they are
const reported = dto.products ? parseProducts(dto.products) : null;
for (const { id, reason } of reported?.rejected ?? []) {
this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`);
}
const stockResults = parseStockResults(dto.stockAdjustmentResults ?? []);
for (const { id, reason } of stockResults.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`);
}
const openTabs = dto.openTabs || [];
const openTabIds = openTabs.map((t) => t.id);
@@ -67,6 +77,22 @@ export class DevicesService {
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
let plannedDelivered = 0;
// Products belong to the venue rather than the device, so they survive pairing a new terminal
const previousProducts = reported
? await this.prisma.product.findMany({
where: { venueId: device.venueId },
select: { productId: true, name: true, category: true, priceInCents: true, stock: true },
})
: [];
const previousProductsById = new Map(previousProducts.map((p) => [p.productId, p]));
const changedProducts = (reported?.products ?? []).filter((p) => {
const stored = previousProductsById.get(p.id);
return !stored || !sameProduct(stored, p);
});
const productsChanged = reported !== null
&& (previousProducts.length !== reported.products.length || changedProducts.length > 0);
let stockResultsRecorded = 0;
for (const ownership of takenOver) {
await this.auditService.log(
device.venueId,
@@ -148,6 +174,43 @@ export class DevicesService {
}));
}
if (reported) {
const reportedIds = reported.products.map((p) => p.id);
await tx.product.deleteMany({ where: { venueId: device.venueId, productId: { notIn: reportedIds } } });
await tx.product.updateMany({
where: { venueId: device.venueId, productId: { in: reportedIds } },
data: { reportedAt: now },
});
for (const product of changedProducts) {
const data = {
name: product.name,
category: product.category,
priceInCents: product.priceInCents,
stock: product.stock,
reportedAt: now,
};
await tx.product.upsert({
where: { venueId_productId: { venueId: device.venueId, productId: product.id } },
create: { venueId: device.venueId, productId: product.id, ...data },
update: data,
});
}
}
// The terminal's outcome is the truth, even for an adjustment cancelled while it was on its way
for (const result of stockResults.accepted) {
const { count } = await tx.stockAdjustment.updateMany({
where: { id: result.id, venueId: device.venueId, status: { in: ['pending', 'cancelled'] } },
data: {
status: result.status,
stockAfter: result.status === 'applied' ? result.stockAfter : null,
result: result.reason ?? null,
processedAt: now,
},
});
stockResultsRecorded += count;
}
if (events.accepted.length > 0) {
await tx.tabEvent.createMany({
data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)),
@@ -203,10 +266,19 @@ export class DevicesService {
select: { customerName: true },
});
// Sent on every sync until the terminal reports an outcome; the terminal dedupes by id
const stockAdjustments = await this.prisma.stockAdjustment.findMany({
where: { venueId: device.venueId, status: 'pending' },
orderBy: { createdAt: 'asc' },
take: 500,
select: { id: true, productId: true, type: true, quantity: true, note: true, requestedBy: true, createdAt: true },
});
const topics: RealtimeTopic[] = [];
if (tabsChanged) topics.push('tabs');
if (plannedDelivered > 0) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands');
if (productsChanged || stockResultsRecorded > 0) topics.push('stock');
// Only when the device comes back online; dashboards poll the device status themselves
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(device.venueId, ...topics);
@@ -227,6 +299,11 @@ export class DevicesService {
acknowledgedEventIds: events.accepted.map((e) => e.id),
// Invalid and never stored: the terminal should log and drop these too, re-sending won't help
rejectedEvents: events.rejected,
stockAdjustments,
// Recorded (or already recorded earlier): the terminal can drop these results from its outbox
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
// Invalid and never recorded: drop these too, re-sending won't help
rejectedStockAdjustmentResults: stockResults.rejected,
};
}
@@ -314,6 +391,13 @@ export class DevicesService {
}
}
type StoredProduct = { name: string; category: string | null; priceInCents: number; stock: number | null };
function sameProduct(stored: StoredProduct, reported: ParsedProduct): boolean {
return stored.name === reported.name && stored.category === reported.category
&& stored.priceInCents === reported.priceInCents && stored.stock === reported.stock;
}
type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
@@ -0,0 +1,55 @@
import { IsString, IsIn, IsInt, Min, IsNotEmpty, MaxLength, IsOptional, IsUUID, ValidateIf } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ReportedProductDto {
@ApiProperty({ example: 'local-product-id', description: "The terminal's own id for the product" })
@IsString()
@IsNotEmpty()
@MaxLength(100)
id: string;
@ApiProperty({ example: 'Cola' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
name: string;
@ApiPropertyOptional({ example: 'Frisdrank' })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@ApiProperty({ example: 250 })
@IsInt()
@Min(0)
priceInCents: number;
@ApiProperty({ example: 48, nullable: true, description: 'Null when stock is not tracked; may be negative' })
@ValidateIf((product: ReportedProductDto) => product.stock !== null)
@IsInt()
stock: number | null;
}
export const STOCK_RESULT_STATUSES = ['applied', 'rejected'] as const;
export class StockAdjustmentResultDto {
@ApiProperty({ description: 'The stock adjustment id from the sync response' })
@IsUUID()
id: string;
@ApiProperty({ enum: STOCK_RESULT_STATUSES })
@IsIn(STOCK_RESULT_STATUSES)
status: (typeof STOCK_RESULT_STATUSES)[number];
@ApiPropertyOptional({ example: 72, description: 'applied: the stock right after applying it' })
@ValidateIf((result: StockAdjustmentResultDto) => result.status === 'applied')
@IsInt()
stockAfter?: number;
@ApiPropertyOptional({ example: 'unknown_product', description: 'rejected: why' })
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}
+15
View File
@@ -77,4 +77,19 @@ export class SyncDto {
@IsArray()
@ArrayMaxSize(1000)
events?: unknown[];
// Validated one by one in the service, like events
@ApiPropertyOptional({
description: "ReportedProductDto[]: the terminal's complete product catalog with stock. Omitted by app versions without stock",
})
@IsOptional()
@IsArray()
@ArrayMaxSize(2000)
products?: unknown[];
@ApiPropertyOptional({ description: 'StockAdjustmentResultDto[]: outcomes of stock adjustments the terminal received' })
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
stockAdjustmentResults?: unknown[];
}
+54
View File
@@ -0,0 +1,54 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { ReportedProductDto, StockAdjustmentResultDto } from './dto/stock-sync.dto';
import { describe } from './tab-events';
export type Rejected = { id: string | null; reason: string };
/**
* Validates entries one by one, like tab events: one bad product or result is skipped on its own
* instead of failing the sync (and with it the open tabs and sales).
*/
function parseEach<T extends object>(cls: new () => T, raw: unknown[]): { accepted: T[]; rejected: Rejected[] } {
const accepted: T[] = [];
const rejected: Rejected[] = [];
for (const value of raw) {
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
rejected.push({ id, reason: 'must be an object' });
continue;
}
const entry = plainToInstance(cls, value);
const errors = validateSync(entry, { whitelist: true });
if (errors.length > 0) {
rejected.push({ id, reason: describe(errors) });
continue;
}
accepted.push(entry);
}
return { accepted, rejected };
}
export function parseProducts(raw: unknown[]) {
const { accepted, rejected } = parseEach(ReportedProductDto, raw);
// A duplicated id would make the upserts fight; the last one reported wins
const byId = new Map(accepted.map((product) => [product.id, product]));
return {
products: [...byId.values()].map((p) => ({
id: p.id,
name: p.name.trim(),
category: p.category?.trim() || null,
priceInCents: p.priceInCents,
stock: p.stock ?? null,
})),
rejected,
};
}
export type ParsedProduct = ReturnType<typeof parseProducts>['products'][number];
export function parseStockResults(raw: unknown[]) {
return parseEach(StockAdjustmentResultDto, raw);
}
+1 -1
View File
@@ -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 ?? {}),
+1 -1
View File
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Namespace } from 'socket.io';
/** What changed; the dashboard refetches the matching data over the normal API. */
export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users';
export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users' | 'stock';
export const venueRoom = (venueId: string) => `venue:${venueId}`;
@@ -0,0 +1,43 @@
import {
IsString, IsIn, IsInt, Min, Max, IsNotEmpty, MaxLength, IsOptional, IsArray, ArrayMinSize, ArrayMaxSize,
ValidateNested,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export const STOCK_ADJUSTMENT_TYPES = ['add', 'set'] as const;
export type StockAdjustmentType = (typeof STOCK_ADJUSTMENT_TYPES)[number];
export class StockAdjustmentEntryDto {
@ApiProperty({ example: 'local-product-id' })
@IsString()
@IsNotEmpty()
productId: string;
@ApiProperty({ enum: STOCK_ADJUSTMENT_TYPES, description: 'add: change the stock by quantity; set: the stock is now quantity' })
@IsIn(STOCK_ADJUSTMENT_TYPES)
type: StockAdjustmentType;
@ApiProperty({ example: 24, description: 'add: non-zero, negative to take stock out; set: zero or more' })
@IsInt()
@Min(-100000)
@Max(100000)
quantity: number;
@ApiPropertyOptional({ example: 'Delivery from the brewery' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsString()
@MaxLength(200)
note?: string;
}
export class CreateStockAdjustmentsDto {
@ApiProperty({ type: [StockAdjustmentEntryDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => StockAdjustmentEntryDto)
adjustments: StockAdjustmentEntryDto[];
}
@@ -0,0 +1,11 @@
import { IsInt, Min, Max, ValidateIf } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateProductDto {
@ApiProperty({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
@ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null)
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold: number | null;
}
+66
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+86
View File
@@ -0,0 +1,86 @@
import { StockService } from './stock.service';
const user = { id: 'u1', email: 'bram@example.com' };
function createService({ products = [] as { productId: string; name: string; stock: number | null }[], cancelled = 1, existing = null as unknown } = {}) {
const prisma = {
product: { findMany: jest.fn().mockResolvedValue(products), updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
stockAdjustment: {
create: jest.fn((args: { data: Record<string, unknown> }) => ({ id: 'a1', ...args.data })),
updateMany: jest.fn().mockResolvedValue({ count: cancelled }),
findFirst: jest.fn().mockResolvedValue(existing),
},
$transaction: jest.fn((operations: unknown[]) => Promise.all(operations)),
};
const audit = { log: jest.fn() };
const realtime = { notify: jest.fn() };
const service = new StockService(prisma as any, audit as any, realtime as any);
return { service, prisma, audit, realtime };
}
const cola = { productId: 'p1', name: 'Cola', stock: 10 };
const water = { productId: 'p2', name: 'Water', stock: null };
describe('StockService.createAdjustments', () => {
it('creates one pending adjustment per entry with the product name and requester', async () => {
const { service, prisma, realtime } = createService({ products: [cola, water] });
const result = await service.createAdjustments('v1', [
{ productId: 'p1', type: 'add', quantity: 24, note: 'Delivery' },
{ productId: 'p2', type: 'set', quantity: 12 },
], user);
expect(prisma.stockAdjustment.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ venueId: 'v1', productId: 'p1', productName: 'Cola', type: 'add', quantity: 24, note: 'Delivery', requestedBy: 'bram@example.com' }),
}));
expect(result.adjustments).toHaveLength(2);
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('rejects a negative count and adding zero', async () => {
const { service } = createService({ products: [cola] });
await expect(service.createAdjustments('v1', [{ productId: 'p1', type: 'set', quantity: -1 }], user))
.rejects.toMatchObject({ response: { code: 'invalid_quantity' } });
await expect(service.createAdjustments('v1', [{ productId: 'p1', type: 'add', quantity: 0 }], user))
.rejects.toMatchObject({ response: { code: 'invalid_quantity' } });
});
it('rejects unknown products and adding to an untracked product, creating nothing', async () => {
const { service, prisma } = createService({ products: [cola, water] });
await expect(service.createAdjustments('v1', [{ productId: 'p9', type: 'set', quantity: 1 }], user))
.rejects.toMatchObject({ response: { code: 'product_not_found' } });
await expect(service.createAdjustments('v1', [
{ productId: 'p1', type: 'add', quantity: 6 },
{ productId: 'p2', type: 'add', quantity: 6 },
], user)).rejects.toMatchObject({ response: { code: 'stock_not_tracked' } });
expect(prisma.stockAdjustment.create).not.toHaveBeenCalled();
});
});
describe('StockService.cancelAdjustment', () => {
it('cancels a pending adjustment', async () => {
const { service, prisma, realtime } = createService();
await expect(service.cancelAdjustment('v1', 'a1', user)).resolves.toEqual({ ok: true });
expect(prisma.stockAdjustment.updateMany).toHaveBeenCalledWith({
where: { id: 'a1', venueId: 'v1', status: 'pending' },
data: { status: 'cancelled', processedAt: expect.any(Date) },
});
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
});
it('refuses once the terminal processed it', async () => {
const { service } = createService({ cancelled: 0, existing: { id: 'a1', status: 'applied' } });
await expect(service.cancelAdjustment('v1', 'a1', user)).rejects.toMatchObject({ response: { code: 'adjustment_not_pending' } });
});
it('is idempotent and 404s for another venue', async () => {
await expect(createService({ cancelled: 0, existing: { id: 'a1', status: 'cancelled' } }).service.cancelAdjustment('v1', 'a1', user))
.resolves.toEqual({ ok: true });
await expect(createService({ cancelled: 0 }).service.cancelAdjustment('v1', 'a1', user))
.rejects.toMatchObject({ response: { code: 'adjustment_not_found' } });
});
});
+172
View File
@@ -0,0 +1,172 @@
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto';
const adjustmentSelect = {
id: true,
productId: true,
productName: true,
type: true,
quantity: true,
note: true,
requestedBy: true,
status: true,
stockAfter: true,
result: true,
createdAt: true,
processedAt: true,
} as const;
@Injectable()
export class StockService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
) {}
async findAll(venueId: string) {
const [products, pending] = await Promise.all([
this.prisma.product.findMany({
where: { venueId },
orderBy: [{ category: 'asc' }, { name: 'asc' }],
}),
this.prisma.stockAdjustment.findMany({
where: { venueId, status: 'pending' },
orderBy: { createdAt: 'asc' },
select: adjustmentSelect,
}),
]);
const pendingByProduct = new Map<string, typeof pending>();
for (const adjustment of pending) {
pendingByProduct.set(adjustment.productId, [...(pendingByProduct.get(adjustment.productId) ?? []), adjustment]);
}
return {
products: products.map((p) => ({
productId: p.productId,
name: p.name,
category: p.category,
priceInCents: p.priceInCents,
stock: p.stock,
lowStockThreshold: p.lowStockThreshold,
reportedAt: p.reportedAt,
pendingAdjustments: pendingByProduct.get(p.productId) ?? [],
})),
// Null until a terminal running an app version with stock has synced
reportedAt: products.reduce<Date | null>((latest, p) => (!latest || p.reportedAt > latest ? p.reportedAt : latest), null),
};
}
async history(venueId: string, options: { productId?: string; limit: number; offset: number }) {
const where = { venueId, ...(options.productId ? { productId: options.productId } : {}) };
const [adjustments, total] = await Promise.all([
this.prisma.stockAdjustment.findMany({
where,
orderBy: { createdAt: 'desc' },
take: options.limit,
skip: options.offset,
select: adjustmentSelect,
}),
this.prisma.stockAdjustment.count({ where }),
]);
return { adjustments, total };
}
async createAdjustments(venueId: string, entries: StockAdjustmentEntryDto[], user: { id: string; email: string; name?: string }) {
for (const entry of entries) {
if (entry.type === 'set' && entry.quantity < 0) {
throw new BadRequestException({ code: 'invalid_quantity', message: 'A stock count cannot be negative' });
}
if (entry.type === 'add' && entry.quantity === 0) {
throw new BadRequestException({ code: 'invalid_quantity', message: 'Adding zero changes nothing' });
}
}
const products = await this.prisma.product.findMany({
where: { venueId, productId: { in: entries.map((e) => e.productId) } },
select: { productId: true, name: true, stock: true },
});
const productsById = new Map(products.map((p) => [p.productId, p]));
for (const entry of entries) {
const product = productsById.get(entry.productId);
if (!product) {
throw new NotFoundException({ code: 'product_not_found', message: 'This product is not on the terminal' });
}
// Counting is how tracking starts; adding to an untracked product has nothing to add to
if (entry.type === 'add' && product.stock === null) {
throw new BadRequestException({
code: 'stock_not_tracked',
message: `Stock is not tracked for ${product.name}. Count it first.`,
});
}
}
const requestedBy = user.name || user.email;
const created = await this.prisma.$transaction(
entries.map((entry) => this.prisma.stockAdjustment.create({
data: {
venueId,
productId: entry.productId,
productName: productsById.get(entry.productId)!.name,
type: entry.type,
quantity: entry.quantity,
note: entry.note ?? null,
requestedBy,
requestedById: user.id,
},
select: adjustmentSelect,
})),
);
await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustments_created', {
adjustments: created.map((a) => ({ id: a.id, productId: a.productId, type: a.type, quantity: a.quantity })),
});
this.realtime.notify(venueId, 'stock');
return { adjustments: created };
}
async cancelAdjustment(venueId: string, id: string, user: { id: string }) {
const { count } = await this.prisma.stockAdjustment.updateMany({
where: { id, venueId, status: 'pending' },
data: { status: 'cancelled', processedAt: new Date() },
});
if (count === 0) {
const existing = await this.prisma.stockAdjustment.findFirst({ where: { id, venueId } });
if (!existing) {
throw new NotFoundException({ code: 'adjustment_not_found', message: 'Stock adjustment not found' });
}
if (existing.status === 'cancelled') return { ok: true }; // Idempotent
throw new ConflictException({
code: 'adjustment_not_pending',
message: 'The terminal already processed this stock adjustment',
});
}
await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustment_cancelled', { adjustmentId: id });
this.realtime.notify(venueId, 'stock');
return { ok: true };
}
async updateProduct(venueId: string, productId: string, lowStockThreshold: number | null) {
const { count } = await this.prisma.product.updateMany({
where: { venueId, productId },
data: { lowStockThreshold },
});
if (count === 0) {
throw new NotFoundException({ code: 'product_not_found', message: 'This product is not on the terminal' });
}
this.realtime.notify(venueId, 'stock');
return { ok: true };
}
}
@@ -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 },
@@ -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<StockAdjustment, 'type' | 'quantity'>): 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<Filter>('all');
const [adjusting, setAdjusting] = useState<Product | null>(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 <LoadingState />;
if (error) return <ErrorState message={te(error, 'stock.load_failed')} />;
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 (
<div>
<PageHeader
title={t('stock.title')}
description={data?.reportedAt
? `${tn('stock.count', products.length)} · ${tn('stock.low_count', lowCount)} · ${t('stock.reported', { time: relativeTime(data.reportedAt) })}`
: undefined}
actions={
<>
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['stock'] })}>
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
</Button>
{products.some((p) => p.stock !== null) && (
<Button size="sm" onClick={() => setDeliveryOpen(true)}>
<Truck className="h-4 w-4 mr-2" />{t('stock.delivery')}
</Button>
)}
</>
}
/>
{products.length === 0 ? (
<EmptyState title={t('stock.empty')} description={t('stock.empty_desc')} />
) : (
<>
<div className="flex gap-2 mb-3 flex-wrap">
<div className="relative flex-1 min-w-[12rem] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={t('stock.search_placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
aria-label={t('stock.search_placeholder')}
/>
</div>
{filters.map((f) => (
<Button key={f.value} variant={filter === f.value ? 'default' : 'outline'} size="sm" onClick={() => setFilter(f.value)}>
{t(f.label)}
</Button>
))}
</div>
<div className="rounded-md border mb-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('stock.product')}</TableHead>
<TableHead>{t('stock.price')}</TableHead>
<TableHead>{t('stock.stock')}</TableHead>
<TableHead>{t('stock.pending')}</TableHead>
<TableHead className="text-right">{t('stock.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-6 text-center text-sm text-muted-foreground">
{t('stock.no_matches')}
</TableCell>
</TableRow>
)}
{groups.map((group) => (
<Fragment key={group.category ?? ''}>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={5} className="py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{group.category ?? t('stock.no_category')}
</TableCell>
</TableRow>
{group.products.map((product) => (
<TableRow key={product.productId}>
<TableCell className="font-medium">{product.name}</TableCell>
<TableCell className="tabular-nums">{formatCents(product.priceInCents)}</TableCell>
<TableCell><StockLevel product={product} /></TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{product.pendingAdjustments.map((a) => (
<span key={a.id} className="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs tabular-nums" title={a.note ?? undefined}>
{formatChange(a)}
<button
onClick={() => cancelMutation.mutate(a.id)}
disabled={cancelMutation.isPending}
className="text-muted-foreground hover:text-destructive"
aria-label={t('stock.cancel_adjustment', { change: formatChange(a), name: product.name })}
>
<X className="h-3 w-3" />
</button>
</span>
))}
{product.pendingAdjustments.length === 0 && <span className="text-muted-foreground">—</span>}
</div>
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" onClick={() => setAdjusting(product)}>
{product.stock === null ? t('stock.start_tracking') : t('stock.adjust')}
</Button>
</TableCell>
</TableRow>
))}
</Fragment>
))}
</TableBody>
</Table>
</div>
</>
)}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2"><History className="h-4 w-4" /> {t('stock.history')}</CardTitle>
</CardHeader>
<CardContent>
{(historyQuery.data?.adjustments ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t('stock.history_empty')}</p>
) : (
<ul className="space-y-2 text-sm">
{historyQuery.data!.adjustments.map((a) => (
<li key={a.id} className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<span className="font-medium">{a.productName}</span>
<span className="ml-2 tabular-nums">{formatChange(a)}</span>
{a.stockAfter !== null && <span className="ml-2 text-muted-foreground">{t('stock.now', { stock: a.stockAfter })}</span>}
{a.note && <span className="ml-2 text-muted-foreground">· {a.note}</span>}
{a.status === 'rejected' && a.result && (
<span className="ml-2 text-destructive">· {KNOWN_REASONS.includes(a.result) ? t(`stock.reason.${a.result}`) : a.result}</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0 text-muted-foreground">
<Badge variant={statusVariant[a.status]}>{t(`stock.status.${a.status}`)}</Badge>
<span>{a.requestedBy}</span>
<span>{relativeTime(a.createdAt)}</span>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{adjusting && <AdjustDialog product={adjusting} venueId={venueId} canSetThreshold={isAdmin} onClose={() => setAdjusting(null)} />}
{deliveryOpen && (
<DeliveryDialog products={products.filter((p) => p.stock !== null)} venueId={venueId} onClose={() => setDeliveryOpen(false)} />
)}
</div>
);
}
function StockLevel({ product }: { product: Product }) {
const { t } = useLang();
if (product.stock === null) return <span className="text-muted-foreground">{t('stock.not_tracked')}</span>;
return (
<div className="flex items-center gap-2">
<span className={product.stock <= 0 ? 'font-semibold text-destructive tabular-nums' : 'font-semibold tabular-nums'}>{product.stock}</span>
{product.stock <= 0
? <Badge variant="destructive">{t('stock.out')}</Badge>
: isLow(product) && <Badge variant="warning">{t('stock.low')}</Badge>}
</div>
);
}
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<Mode>(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 (
<Dialog open onOpenChange={(open) => !open && !mutation.isPending && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{product.name}</DialogTitle>
<DialogDescription>
{tracked ? t('stock.current', { stock: product.stock! }) : t('stock.start_tracking_desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{tracked && (
<RadioGroup value={mode} onValueChange={(value) => setMode(value as Mode)}>
<div className="flex items-center space-x-2"><RadioGroupItem value="add" id="mode-add" /><Label htmlFor="mode-add">{t('stock.mode_add')}</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="remove" id="mode-remove" /><Label htmlFor="mode-remove">{t('stock.mode_remove')}</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="count" id="mode-count" /><Label htmlFor="mode-count">{t('stock.mode_count')}</Label></div>
</RadioGroup>
)}
<div className="space-y-1.5">
<Label htmlFor="quantity">{mode === 'count' ? t('stock.counted') : t('stock.quantity')}</Label>
<Input
id="quantity"
inputMode="numeric"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
autoFocus
/>
{preview !== null && <p className="text-xs text-muted-foreground">{t('stock.preview', { from: product.stock!, to: preview })}</p>}
{mode === 'count' && tracked && <p className="text-xs text-muted-foreground">{t('stock.count_hint')}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="note">{t('stock.note')}</Label>
<Input id="note" value={note} maxLength={200} placeholder={t('stock.note_placeholder')} onChange={(e) => setNote(e.target.value)} />
</div>
{/* The warning level is an admin setting; everyone can change the stock itself */}
{tracked && canSetThreshold && (
<div className="space-y-1.5 border-t pt-4">
<Label htmlFor="threshold">{t('stock.threshold')}</Label>
<div className="flex gap-2">
<Input id="threshold" inputMode="numeric" value={threshold} placeholder={t('stock.threshold_none')} onChange={(e) => setThreshold(e.target.value)} className="max-w-[8rem]" />
<Button
variant="outline"
size="sm"
disabled={thresholdValue === undefined || thresholdValue === product.lowStockThreshold || thresholdMutation.isPending}
onClick={() => thresholdMutation.mutate()}
>
{t('stock.threshold_save')}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={mutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={!valid || mutation.isPending}>{mutation.isPending ? t('stock.sending') : t('stock.send')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** 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<Record<string, string>>({});
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 (
<Dialog open onOpenChange={(open) => !open && !mutation.isPending && onClose()}>
<DialogContent className="max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t('stock.delivery_title')}</DialogTitle>
<DialogDescription>{t('stock.delivery_desc')}</DialogDescription>
</DialogHeader>
<Input type="search" placeholder={t('stock.search_placeholder')} value={search} onChange={(e) => setSearch(e.target.value)} aria-label={t('stock.search_placeholder')} />
<div className="flex-1 overflow-y-auto space-y-1 py-2 min-h-0">
{visible.map((product) => (
<div key={product.productId} className="flex items-center justify-between gap-3">
<Label htmlFor={`delivery-${product.productId}`} className="flex-1 min-w-0 truncate font-normal">
{product.name}
<span className="ml-2 text-muted-foreground tabular-nums">({product.stock})</span>
</Label>
<Input
id={`delivery-${product.productId}`}
inputMode="numeric"
placeholder="0"
value={quantities[product.productId] ?? ''}
onChange={(e) => setQuantities((current) => ({ ...current, [product.productId]: e.target.value }))}
className="w-20 h-8 text-right"
/>
</div>
))}
</div>
<div className="space-y-1.5">
<Label htmlFor="delivery-note">{t('stock.note')}</Label>
<Input id="delivery-note" value={note} maxLength={200} placeholder={t('stock.delivery_note_placeholder')} onChange={(e) => setNote(e.target.value)} />
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={mutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={entries.length === 0 || invalid || mutation.isPending}>
{mutation.isPending ? t('stock.sending') : tn('stock.delivery_send', entries.length)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+61
View File
@@ -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 {
+134
View File
@@ -262,6 +262,73 @@ const translations: Record<Lang, Record<string, string>> = {
'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<Lang, Record<string, string>> = {
'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',
},
};
+1
View File
@@ -13,6 +13,7 @@ const TOPIC_QUERIES: Record<string, string[][]> = {
device: [['device']],
reminders: [['reminder-candidates'], ['reminder-history']],
users: [['users']],
stock: [['stock'], ['stock-history']],
};
const RealtimeContext = createContext({ connected: false });
+22
View File
@@ -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
+268
View File
@@ -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<Column> 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": <the new stock> }`.
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<Map<String, dynamic>> products` and `List<Map<String, dynamic>> stockAdjustmentResults`.
Add to `SyncResponse` (all optional, default to empty lists so an older server still works):
```dart
final List<StockAdjustment> stockAdjustments; // json['stockAdjustments']
final List<String> acknowledgedStockAdjustmentResultIds; // json['acknowledgedStockAdjustmentResultIds']
final List<RejectedEvent> 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
+7 -1
View File
@@ -16,4 +16,10 @@ export type AuditAction =
| 'command_expired'
| 'device_registered'
| 'device_revoked'
| 'snapshot_conflict';
| 'snapshot_conflict'
| 'stock_adjustments_created'
| 'stock_adjustment_cancelled';
export type StockAdjustmentType = 'add' | 'set';
export type StockAdjustmentStatus = 'pending' | 'applied' | 'rejected' | 'cancelled';