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