This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "archivedAt" TIMESTAMP(3);
|
||||
|
||||
@@ -232,12 +232,12 @@ model TabEvent {
|
||||
@@index([venueId, type, occurredAt])
|
||||
}
|
||||
|
||||
// The venue's products as reported by its terminals, with the stock. The server owns the stock: it follows the
|
||||
// terminals' line events and stock counts and the dashboard's StockAdjustments. A product is never removed because
|
||||
// one terminal doesn't report it.
|
||||
// The venue's products and their stock. The dashboard manages the catalog and the terminals get it on every sync;
|
||||
// the first terminal's own catalog was taken over once. The stock follows the terminals' line events and stock
|
||||
// counts and the dashboard's StockAdjustments.
|
||||
model Product {
|
||||
venueId String
|
||||
// Generated by the terminal
|
||||
// Generated by the server, or by the terminal that reported it before the dashboard managed the catalog
|
||||
productId String
|
||||
name String
|
||||
category String?
|
||||
@@ -246,9 +246,12 @@ model Product {
|
||||
stock Int?
|
||||
// When the stock was last counted; line events that happened before it are already in the count
|
||||
countedAt DateTime?
|
||||
// Dashboard-only setting, never sent to or overwritten by the terminal
|
||||
// Warn when the stock drops to this level
|
||||
lowStockThreshold Int?
|
||||
// When it was created or last changed
|
||||
reportedAt DateTime
|
||||
// Deleted on the dashboard: hidden on the terminals, kept because tabs and history refer to it
|
||||
archivedAt DateTime?
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
|
||||
@@ -247,21 +247,67 @@ describe('DevicesService.sync: settlements feed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** A product added on the dashboard, untracked. */
|
||||
function addProduct(prisma: FakePrisma, productId = 'cola', fields: Record<string, unknown> = {}) {
|
||||
prisma.product.rows.push({
|
||||
venueId: 'v1', productId, name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: null, countedAt: null,
|
||||
lowStockThreshold: null, archivedAt: null, reportedAt: new Date(), ...fields,
|
||||
});
|
||||
}
|
||||
|
||||
describe('DevicesService.sync: the dashboard manages the catalog', () => {
|
||||
it('sends every product, archived ones marked, and ignores what a tablet reports', async () => {
|
||||
const { sync, tabletA, prisma } = setup();
|
||||
addProduct(prisma, 'cola', { lowStockThreshold: 6 });
|
||||
addProduct(prisma, 'old', { name: 'Old', category: null, archivedAt: new Date() });
|
||||
|
||||
const result = await sync(tabletA, {
|
||||
products: [
|
||||
{ id: 'cola', name: 'Renamed on the tablet', category: null, priceInCents: 999, stock: 3 },
|
||||
{ id: 'local', name: 'Seeded on the tablet', category: null, priceInCents: 100, stock: 24 },
|
||||
],
|
||||
});
|
||||
|
||||
// By category, products without one last (as Postgres sorts)
|
||||
expect(result.catalog).toEqual([
|
||||
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, lowStockThreshold: 6, archived: false },
|
||||
{ id: 'old', name: 'Old', category: null, priceInCents: 250, lowStockThreshold: null, archived: true },
|
||||
]);
|
||||
expect(prisma.product.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("takes over the tablet's catalog when the venue is taken over", async () => {
|
||||
const { sync, tabletA } = setup({ seeded: false });
|
||||
|
||||
const result = await sync(tabletA, { products: catalog(12) });
|
||||
|
||||
expect(result.catalog).toMatchObject([{ id: 'cola', name: 'Cola', archived: false }]);
|
||||
expect(colaStock(result)).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync: the server holds the stock', () => {
|
||||
async function trackedCola(sync: ReturnType<typeof setup>['sync'], tablet: Device, stock = 48) {
|
||||
return sync(tablet, { products: catalog(), events: [events.stockSet('cola', stock, -10)] });
|
||||
return sync(tablet, { events: [events.stockSet('cola', stock, -10)] });
|
||||
}
|
||||
|
||||
it('starts a new product untracked and ignores the stock in the catalog', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
/** Every test here starts with Cola on the dashboard. */
|
||||
function setupWithCola() {
|
||||
const venue = setup();
|
||||
addProduct(venue.prisma);
|
||||
return venue;
|
||||
}
|
||||
|
||||
const result = await sync(tabletA, { products: catalog(99) });
|
||||
it('ignores a count for a product the venue does not have', async () => {
|
||||
const { sync, tabletA } = setupWithCola();
|
||||
|
||||
const result = await sync(tabletA, { events: [events.stockSet('local', 24)] });
|
||||
|
||||
expect(result.stock).toEqual([{ productId: 'cola', stock: null, countedAt: null }]);
|
||||
});
|
||||
|
||||
it('counts the sales of every tablet once', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
const { sync, tabletA, tabletB } = setupWithCola();
|
||||
await trackedCola(sync, tabletA);
|
||||
const sale = events.line('line_added', 't1', 2, { minutes: 1 });
|
||||
|
||||
@@ -273,7 +319,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
|
||||
});
|
||||
|
||||
it('gives stock back for drinks taken off, but not for a settlement', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
const { sync, tabletA } = setupWithCola();
|
||||
await trackedCola(sync, tabletA);
|
||||
|
||||
const result = await sync(tabletA, {
|
||||
@@ -289,7 +335,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
|
||||
});
|
||||
|
||||
it('keeps a count when sales from before it arrive later', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
const { sync, tabletA, tabletB } = setupWithCola();
|
||||
await trackedCola(sync, tabletA);
|
||||
await sync(tabletB, { events: [events.stockSet('cola', 30, 60)] });
|
||||
|
||||
@@ -309,7 +355,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
|
||||
});
|
||||
|
||||
it('applies a pending dashboard adjustment once and no longer sends adjustments', async () => {
|
||||
const { sync, tabletA, tabletB, prisma } = setup();
|
||||
const { sync, tabletA, tabletB, prisma } = setupWithCola();
|
||||
await trackedCola(sync, tabletA);
|
||||
prisma.stockAdjustment.rows.push({
|
||||
id: 'adj-1', venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24,
|
||||
@@ -326,7 +372,7 @@ describe('DevicesService.sync: the server holds the stock', () => {
|
||||
});
|
||||
|
||||
it('records results from older tablets that applied an adjustment themselves', async () => {
|
||||
const { sync, tabletA, prisma } = setup();
|
||||
const { sync, tabletA, prisma } = setupWithCola();
|
||||
await trackedCola(sync, tabletA);
|
||||
const id = uuidv4();
|
||||
prisma.stockAdjustment.rows.push({
|
||||
@@ -340,14 +386,6 @@ describe('DevicesService.sync: the server holds the stock', () => {
|
||||
expect(colaStock(result)).toBe(48);
|
||||
});
|
||||
|
||||
it('never removes a product another tablet does not know', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
await trackedCola(sync, tabletA);
|
||||
|
||||
const result = await sync(tabletB, { products: [{ id: 'water', name: 'Water', category: null, priceInCents: 200, stock: 3 }] });
|
||||
|
||||
expect(result.stock.map((s) => s.productId).sort()).toEqual(['cola', 'water']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync live update signals', () => {
|
||||
@@ -362,9 +400,10 @@ describe('DevicesService.sync live update signals', () => {
|
||||
});
|
||||
|
||||
it('signals "tabs" and "stock" when they changed', async () => {
|
||||
const { sync, tabletA, realtime } = setup();
|
||||
const { sync, tabletA, realtime, prisma } = setup();
|
||||
addProduct(prisma);
|
||||
|
||||
await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] });
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.stockSet('cola', 5)] });
|
||||
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock');
|
||||
});
|
||||
|
||||
@@ -58,8 +58,10 @@ export class DevicesService {
|
||||
// Locks the venue: other terminals' syncs wait, so no change is lost
|
||||
const ledger = await VenueLedger.open(tx, venueId);
|
||||
|
||||
// 1. The catalog: names and prices only, the stock is the server's
|
||||
const productsChanged = reported ? await this.saveProducts(tx, ledger, reported.products, now) : false;
|
||||
// 1. The dashboard manages the catalog: a terminal's own catalog is only taken over once, with the tabs
|
||||
const productsChanged = reported && !ledger.seeded
|
||||
? await this.saveProducts(tx, ledger, reported.products, now)
|
||||
: false;
|
||||
|
||||
// 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives
|
||||
const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()];
|
||||
@@ -112,10 +114,16 @@ export class DevicesService {
|
||||
await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } });
|
||||
|
||||
const feed = await settlementsAfter(tx, venueId, dto.settlementsCursor, now);
|
||||
const catalog = await tx.product.findMany({
|
||||
where: { venueId },
|
||||
orderBy: [{ category: 'asc' }, { name: 'asc' }],
|
||||
select: { productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true },
|
||||
});
|
||||
|
||||
return {
|
||||
ledger,
|
||||
feed,
|
||||
catalog,
|
||||
topics: {
|
||||
tabs: ledger.changedTabs,
|
||||
plannedTabs: plannedDelivered > 0,
|
||||
@@ -156,7 +164,7 @@ export class DevicesService {
|
||||
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
|
||||
if (topics.length > 0) this.realtime.notify(venueId, ...topics);
|
||||
|
||||
const { ledger, feed } = synced;
|
||||
const { ledger, feed, catalog } = synced;
|
||||
return {
|
||||
cursor,
|
||||
commands: commands.map((c) => ({
|
||||
@@ -182,6 +190,15 @@ export class DevicesService {
|
||||
rejectedStockAdjustmentResults: stockResults.rejected,
|
||||
// The venue's complete state after this request's events: the terminal replaces its own with it
|
||||
tabs: ledger.tabs.all().map(tabJson),
|
||||
// Every product of the venue; an archived one is hidden on the terminal (tabs and history refer to it)
|
||||
catalog: catalog.map((p) => ({
|
||||
id: p.productId,
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
priceInCents: p.priceInCents,
|
||||
lowStockThreshold: p.lowStockThreshold,
|
||||
archived: p.archivedAt !== null,
|
||||
})),
|
||||
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
|
||||
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })),
|
||||
settlements: feed.settlements,
|
||||
@@ -190,8 +207,8 @@ export class DevicesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts the reported products without their stock, and never removes one: another terminal may still sell it.
|
||||
* Returns whether anything the dashboard shows changed.
|
||||
* Takes over the terminal's catalog on the first sync since the server holds the tabs; the dashboard manages it
|
||||
* from then on. Never removes a product. Returns whether anything the dashboard shows changed.
|
||||
*/
|
||||
private async saveProducts(tx: Prisma.TransactionClient, ledger: VenueLedger, products: ParsedProduct[], now: Date) {
|
||||
const stored = await tx.product.findMany({
|
||||
|
||||
@@ -1,11 +1,84 @@
|
||||
import { IsInt, Min, Max, ValidateIf } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, Min, Max, ValidateIf, IsString, IsNotEmpty, MaxLength, IsOptional, IsBoolean } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
|
||||
// An empty category is no category
|
||||
const trimToNull = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() || null : value);
|
||||
|
||||
export class CreateProductDto {
|
||||
@ApiProperty({ example: 'Cola' })
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
|
||||
@IsOptional()
|
||||
@Transform(trimToNull)
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
category?: string | null;
|
||||
|
||||
@ApiProperty({ example: 250 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1_000_000)
|
||||
priceInCents: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 24, nullable: true, description: 'The starting count; null or left out: stock is not tracked' })
|
||||
@IsOptional()
|
||||
@ValidateIf((dto: CreateProductDto) => dto.stock !== null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
stock?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
|
||||
@IsOptional()
|
||||
@ValidateIf((dto: CreateProductDto) => dto.lowStockThreshold !== null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
lowStockThreshold?: number | null;
|
||||
}
|
||||
|
||||
/** Only the fields that are sent change. The stock itself changes through stock adjustments. */
|
||||
export class UpdateProductDto {
|
||||
@ApiProperty({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
|
||||
@ApiPropertyOptional({ example: 'Cola Zero' })
|
||||
@IsOptional()
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
|
||||
@IsOptional()
|
||||
@Transform(trimToNull)
|
||||
@ValidateIf((dto: UpdateProductDto) => dto.category !== null)
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
category?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 250 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1_000_000)
|
||||
priceInCents?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
|
||||
@IsOptional()
|
||||
@ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
lowStockThreshold: number | null;
|
||||
lowStockThreshold?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'true hides the product on the terminals (deleting it); false brings it back' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, Put, Param, Query, Body, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Patch, 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';
|
||||
@@ -6,7 +6,7 @@ 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';
|
||||
import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto';
|
||||
|
||||
@ApiTags('Stock')
|
||||
@ApiBearerAuth()
|
||||
@@ -16,7 +16,7 @@ export class StockController {
|
||||
constructor(private service: StockService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "The terminal's products with their stock and pending adjustments" })
|
||||
@ApiOperation({ summary: "The venue's products (also archived ones) with their stock" })
|
||||
async findAll(@Param('venueId') venueId: string) {
|
||||
return this.service.findAll(venueId);
|
||||
}
|
||||
@@ -53,14 +53,23 @@ export class StockController {
|
||||
return this.service.cancelAdjustment(venueId, id, req.user);
|
||||
}
|
||||
|
||||
@Put('products/:productId')
|
||||
// The catalog is managed here; the terminals get it on every sync
|
||||
@Post('products')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Set the low stock warning level of a product (admin)' })
|
||||
@ApiOperation({ summary: 'Add a product (admin)' })
|
||||
async createProduct(@Param('venueId') venueId: string, @Body() dto: CreateProductDto, @Req() req: any) {
|
||||
return this.service.createProduct(venueId, dto, req.user);
|
||||
}
|
||||
|
||||
@Patch('products/:productId')
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Change, archive (delete) or restore a product (admin)' })
|
||||
async updateProduct(
|
||||
@Param('venueId') venueId: string,
|
||||
@Param('productId') productId: string,
|
||||
@Body() dto: UpdateProductDto,
|
||||
@Req() req: any,
|
||||
) {
|
||||
return this.service.updateProduct(venueId, productId, dto.lowStockThreshold);
|
||||
return this.service.updateProduct(venueId, productId, dto, req.user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ function createWithVenue(products: { productId: string; name: string; stock: num
|
||||
const prisma = createFakePrisma();
|
||||
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
|
||||
for (const p of products) {
|
||||
prisma.product.rows.push({ venueId: 'v1', ...p, category: null, priceInCents: 250, countedAt: null, reportedAt: new Date() });
|
||||
prisma.product.rows.push({
|
||||
venueId: 'v1', ...p, category: null, priceInCents: 250, countedAt: null, lowStockThreshold: null, archivedAt: null,
|
||||
reportedAt: new Date(),
|
||||
});
|
||||
}
|
||||
const realtime = { notify: jest.fn() };
|
||||
const service = new StockService(prisma as any, { log: jest.fn() } as any, realtime as any);
|
||||
@@ -101,3 +104,82 @@ describe('StockService.cancelAdjustment', () => {
|
||||
.rejects.toMatchObject({ response: { code: 'adjustment_not_found' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('StockService products', () => {
|
||||
const admin = { id: 'u1', email: 'bram@example.com', name: 'Bram' };
|
||||
|
||||
it('adds a product with a starting count, kept in the stock history', async () => {
|
||||
const { service, prisma, realtime } = createWithVenue([cola]);
|
||||
|
||||
const product = await service.createProduct('v1', { name: 'Water', category: 'Frisdrank', priceInCents: 200, stock: 24 }, admin);
|
||||
|
||||
expect(product).toMatchObject({ name: 'Water', category: 'Frisdrank', priceInCents: 200, stock: 24, archivedAt: null });
|
||||
expect(prisma.product.rows.find((p) => p.productId === product.productId)!.countedAt).toBeInstanceOf(Date);
|
||||
expect(prisma.stockAdjustment.rows).toMatchObject([
|
||||
{ productId: product.productId, type: 'set', quantity: 24, status: 'applied', stockAfter: 24, requestedBy: 'Bram' },
|
||||
]);
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock');
|
||||
});
|
||||
|
||||
it('adds an untracked product without history', async () => {
|
||||
const { service, prisma } = createWithVenue([]);
|
||||
|
||||
const product = await service.createProduct('v1', { name: 'Tap water', priceInCents: 0 }, admin);
|
||||
|
||||
expect(product).toMatchObject({ stock: null, category: null, lowStockThreshold: null });
|
||||
expect(prisma.stockAdjustment.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a second active product with the same name, ignoring case', async () => {
|
||||
const { service } = createWithVenue([cola]);
|
||||
|
||||
await expect(service.createProduct('v1', { name: ' cola ', priceInCents: 250 }, admin))
|
||||
.rejects.toMatchObject({ response: { code: 'product_name_exists' } });
|
||||
});
|
||||
|
||||
it('changes only the fields that are sent', async () => {
|
||||
const { service, prisma } = createWithVenue([cola]);
|
||||
|
||||
await service.updateProduct('v1', 'p1', { priceInCents: 300, category: null }, admin);
|
||||
|
||||
expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola', priceInCents: 300, category: null, stock: 10, archivedAt: null });
|
||||
});
|
||||
|
||||
it('archives a product and brings it back, unless its name was taken meanwhile', async () => {
|
||||
const { service, prisma } = createWithVenue([cola]);
|
||||
|
||||
await service.updateProduct('v1', 'p1', { archived: true }, admin);
|
||||
expect(prisma.product.rows[0].archivedAt).toBeInstanceOf(Date);
|
||||
|
||||
await service.createProduct('v1', { name: 'Cola', priceInCents: 250 }, admin);
|
||||
await expect(service.updateProduct('v1', 'p1', { archived: false }, admin))
|
||||
.rejects.toMatchObject({ response: { code: 'product_name_exists' } });
|
||||
|
||||
await service.updateProduct('v1', 'p1', { archived: false, name: 'Cola (old)' }, admin);
|
||||
expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola (old)', archivedAt: null });
|
||||
});
|
||||
|
||||
it('404s for a product of another venue', async () => {
|
||||
const { service } = createWithVenue([cola]);
|
||||
|
||||
await expect(service.updateProduct('v1', 'p9', { priceInCents: 1 }, admin))
|
||||
.rejects.toMatchObject({ response: { code: 'product_not_found' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('StockService.findAll', () => {
|
||||
it('sends when each product was last counted, archived ones included', async () => {
|
||||
const countedAt = new Date('2026-10-20T08:00:00Z');
|
||||
const { service, prisma } = createWithVenue([cola, water]);
|
||||
prisma.product.rows[0].countedAt = countedAt;
|
||||
prisma.product.rows[1].archivedAt = new Date();
|
||||
|
||||
const { products } = await service.findAll('v1');
|
||||
|
||||
expect(products).toEqual([
|
||||
expect.objectContaining({ productId: 'p1', stock: 10, countedAt, archivedAt: null }),
|
||||
expect.objectContaining({ productId: 'p2', countedAt: null, archivedAt: expect.any(Date) }),
|
||||
]);
|
||||
expect(products[0]).not.toHaveProperty('pendingAdjustments');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
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';
|
||||
import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto';
|
||||
import { VenueLedger } from '../tabs/venue-ledger';
|
||||
import { applyPendingAdjustments } from './stock-adjustments';
|
||||
|
||||
@@ -29,36 +32,16 @@ export class StockService {
|
||||
private realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
/** Adjustments are applied at once, so there is nothing pending to show with the products. */
|
||||
async findAll(venueId: string) {
|
||||
const [products, pending] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
const products = await 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
|
||||
products: products.map(productView),
|
||||
// Null until the venue has products
|
||||
reportedAt: products.reduce<Date | null>((latest, p) => (!latest || p.reportedAt > latest ? p.reportedAt : latest), null),
|
||||
};
|
||||
}
|
||||
@@ -172,17 +155,123 @@ export class StockService {
|
||||
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' });
|
||||
}
|
||||
/**
|
||||
* Adds a product to the venue's catalog; the terminals get it on their next sync. A starting count tracks its stock
|
||||
* from now on and is kept in the stock history.
|
||||
*/
|
||||
async createProduct(venueId: string, dto: CreateProductDto, user: { id: string; email: string; name?: string }) {
|
||||
const product = await this.prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
// Locked like a sync, so a name check and the stock can't race with one
|
||||
await VenueLedger.open(tx, venueId);
|
||||
await this.ensureNameFree(tx, venueId, dto.name);
|
||||
|
||||
const stock = dto.stock ?? null;
|
||||
const created = await tx.product.create({
|
||||
data: {
|
||||
venueId,
|
||||
productId: uuidv4(),
|
||||
name: dto.name,
|
||||
category: dto.category ?? null,
|
||||
priceInCents: dto.priceInCents,
|
||||
stock,
|
||||
countedAt: stock === null ? null : now,
|
||||
lowStockThreshold: dto.lowStockThreshold ?? null,
|
||||
reportedAt: now,
|
||||
},
|
||||
});
|
||||
if (stock !== null) {
|
||||
await tx.stockAdjustment.create({
|
||||
data: {
|
||||
venueId,
|
||||
productId: created.productId,
|
||||
productName: created.name,
|
||||
type: 'set',
|
||||
quantity: stock,
|
||||
requestedBy: user.name || user.email,
|
||||
requestedById: user.id,
|
||||
status: 'applied',
|
||||
stockAfter: stock,
|
||||
createdAt: now,
|
||||
processedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
await this.auditService.log(venueId, 'client_user', user.id, 'product_created', {
|
||||
productId: product.productId, name: product.name, priceInCents: product.priceInCents,
|
||||
});
|
||||
this.realtime.notify(venueId, 'stock');
|
||||
|
||||
return { ok: true };
|
||||
return productView(product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a product's name, category, price, warning level, or archives it (a deleted product stays, hidden, because
|
||||
* tabs and history refer to it). Lines already on a tab keep the name and price they were rung up with.
|
||||
*/
|
||||
async updateProduct(venueId: string, productId: string, dto: UpdateProductDto, user: { id: string }) {
|
||||
const product = await this.prisma.$transaction(async (tx) => {
|
||||
await VenueLedger.open(tx, venueId);
|
||||
const existing = await tx.product.findUnique({ where: { venueId_productId: { venueId, productId } } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException({ code: 'product_not_found', message: 'Product not found' });
|
||||
}
|
||||
|
||||
const archived = dto.archived ?? existing.archivedAt != null;
|
||||
const name = dto.name ?? existing.name;
|
||||
// A product coming back, or renamed, must not take an active product's name
|
||||
if (!archived && (name !== existing.name || existing.archivedAt != null)) {
|
||||
await this.ensureNameFree(tx, venueId, name, productId);
|
||||
}
|
||||
|
||||
return tx.product.update({
|
||||
where: { venueId_productId: { venueId, productId } },
|
||||
data: {
|
||||
name,
|
||||
...(dto.category !== undefined ? { category: dto.category } : {}),
|
||||
...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}),
|
||||
...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}),
|
||||
archivedAt: archived ? (existing.archivedAt ?? new Date()) : null,
|
||||
reportedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.auditService.log(venueId, 'client_user', user.id, 'product_updated', { productId, changes: dto });
|
||||
this.realtime.notify(venueId, 'stock');
|
||||
|
||||
return productView(product);
|
||||
}
|
||||
|
||||
private async ensureNameFree(tx: Prisma.TransactionClient, venueId: string, name: string, exceptProductId?: string) {
|
||||
const products = await tx.product.findMany({
|
||||
where: { venueId, archivedAt: null },
|
||||
select: { productId: true, name: true },
|
||||
});
|
||||
const key = name.trim().toLowerCase();
|
||||
if (products.some((p) => p.productId !== exceptProductId && p.name.trim().toLowerCase() === key)) {
|
||||
throw new ConflictException({ code: 'product_name_exists', message: 'A product with this name already exists' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function productView(p: {
|
||||
productId: string; name: string; category: string | null; priceInCents: number; stock: number | null;
|
||||
lowStockThreshold: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null;
|
||||
}) {
|
||||
return {
|
||||
productId: p.productId,
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
priceInCents: p.priceInCents,
|
||||
stock: p.stock,
|
||||
lowStockThreshold: p.lowStockThreshold,
|
||||
// The latest count, on the dashboard or a terminal: how fresh the stock is
|
||||
countedAt: p.countedAt,
|
||||
reportedAt: p.reportedAt,
|
||||
archivedAt: p.archivedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ function sort(rows: Row[], orderBy?: Row | Row[]) {
|
||||
return [...rows].sort((a, b) => {
|
||||
for (const order of orders) {
|
||||
const [field, direction] = Object.entries(order)[0];
|
||||
// Postgres puts nulls last when ascending, first when descending
|
||||
if ((a[field] ?? null) === null || (b[field] ?? null) === null) {
|
||||
if ((a[field] ?? null) === (b[field] ?? null)) continue;
|
||||
return ((a[field] ?? null) === null ? 1 : -1) * (direction === 'asc' ? 1 : -1);
|
||||
}
|
||||
if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
|
||||
if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
@@ -153,7 +158,7 @@ export function createFakePrisma() {
|
||||
venue: new FakeTable(() => ({ tabsSeededAt: null })),
|
||||
device: new FakeTable(),
|
||||
tab: new FakeTable(() => ({ updatedAt: now() })),
|
||||
product: new FakeTable(() => ({ stock: null, countedAt: null, lowStockThreshold: null })),
|
||||
product: new FakeTable(() => ({ stock: null, countedAt: null, lowStockThreshold: null, archivedAt: null })),
|
||||
tabEvent: new FakeTable(),
|
||||
settlement: new FakeTable(() => ({ createdAt: now() }), 'sequence'),
|
||||
stockAdjustment: new FakeTable(() => ({ status: 'pending', stockAfter: null, result: null, processedAt: null, createdAt: now() })),
|
||||
|
||||
@@ -16,19 +16,20 @@ 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 { centsToInput, formatCents, parseEuros } from '@/lib/utils';
|
||||
import { RefreshCw, Search, Truck, History, Plus, Pencil } from 'lucide-react';
|
||||
import { useLang } from '@/lib/i18n';
|
||||
import { useRealtime } from '@/lib/realtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
type Filter = 'all' | 'low' | 'untracked';
|
||||
type Filter = 'all' | 'low' | 'untracked' | 'archived';
|
||||
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' },
|
||||
{ value: 'archived', label: 'stock.filter_archived' },
|
||||
];
|
||||
|
||||
/** At or below its warning level, or out of stock. */
|
||||
@@ -59,11 +60,13 @@ export default function StockPage() {
|
||||
const { venueId, role } = useSession();
|
||||
const isAdmin = role === 'admin';
|
||||
const queryClient = useQueryClient();
|
||||
const { t, tn, te, relativeTime } = useLang();
|
||||
const { t, tn, te, relativeTime, dateTime } = useLang();
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [adjusting, setAdjusting] = useState<Product | null>(null);
|
||||
const [deliveryOpen, setDeliveryOpen] = useState(false);
|
||||
// null: closed; 'new': adding a product
|
||||
const [editing, setEditing] = useState<Product | 'new' | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['stock', venueId],
|
||||
@@ -77,23 +80,16 @@ export default function StockPage() {
|
||||
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 allProducts = data?.products ?? [];
|
||||
// Archived products are deleted for the terminals; they only show under their own filter
|
||||
const products = allProducts.filter((p) => p.archivedAt === null);
|
||||
const lowCount = products.filter(isLow).length;
|
||||
const categories = [...new Set(allProducts.map((p) => p.category).filter((c): c is string => c !== null))].sort();
|
||||
const query = search.trim().toLowerCase();
|
||||
const visible = products.filter((p) => {
|
||||
const visible = (filter === 'archived' ? allProducts.filter((p) => p.archivedAt !== null) : 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;
|
||||
@@ -121,15 +117,20 @@ export default function StockPage() {
|
||||
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
|
||||
</Button>
|
||||
{products.some((p) => p.stock !== null) && (
|
||||
<Button size="sm" onClick={() => setDeliveryOpen(true)}>
|
||||
<Button size="sm" variant="outline" onClick={() => setDeliveryOpen(true)}>
|
||||
<Truck className="h-4 w-4 mr-2" />{t('stock.delivery')}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button size="sm" onClick={() => setEditing('new')}>
|
||||
<Plus className="h-4 w-4 mr-2" />{t('products.new')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{products.length === 0 ? (
|
||||
{allProducts.length === 0 ? (
|
||||
<EmptyState title={t('stock.empty')} description={t('stock.empty_desc')} />
|
||||
) : (
|
||||
<>
|
||||
@@ -159,7 +160,7 @@ export default function StockPage() {
|
||||
<TableHead>{t('stock.product')}</TableHead>
|
||||
<TableHead>{t('stock.price')}</TableHead>
|
||||
<TableHead>{t('stock.stock')}</TableHead>
|
||||
<TableHead>{t('stock.pending')}</TableHead>
|
||||
<TableHead>{t('stock.counted_at')}</TableHead>
|
||||
<TableHead className="text-right">{t('stock.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -180,31 +181,30 @@ export default function StockPage() {
|
||||
</TableRow>
|
||||
{group.products.map((product) => (
|
||||
<TableRow key={product.productId}>
|
||||
<TableCell className="font-medium">{product.name}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{product.name}
|
||||
{product.archivedAt !== null && <Badge variant="secondary" className="ml-2">{t('stock.archived')}</Badge>}
|
||||
</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 className="text-muted-foreground">
|
||||
{product.stock === null
|
||||
? '—'
|
||||
: product.countedAt
|
||||
? <span title={dateTime(product.countedAt)}>{relativeTime(product.countedAt)}</span>
|
||||
: t('stock.never_counted')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
{product.archivedAt === null && (
|
||||
<Button size="sm" variant="outline" onClick={() => setAdjusting(product)}>
|
||||
{product.stock === null ? t('stock.start_tracking') : t('stock.adjust')}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button size="sm" variant="ghost" className="ml-1" onClick={() => setEditing(product)} aria-label={t('products.edit_title', { name: product.name })}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -248,7 +248,15 @@ export default function StockPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{adjusting && <AdjustDialog product={adjusting} venueId={venueId} canSetThreshold={isAdmin} onClose={() => setAdjusting(null)} />}
|
||||
{adjusting && <AdjustDialog product={adjusting} venueId={venueId} onClose={() => setAdjusting(null)} />}
|
||||
{editing && (
|
||||
<ProductDialog
|
||||
product={editing === 'new' ? null : editing}
|
||||
categories={categories}
|
||||
venueId={venueId}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
{deliveryOpen && (
|
||||
<DeliveryDialog products={products.filter((p) => p.stock !== null)} venueId={venueId} onClose={() => setDeliveryOpen(false)} />
|
||||
)}
|
||||
@@ -286,14 +294,12 @@ function useAdjustMutation(venueId: string, onDone: () => void) {
|
||||
});
|
||||
}
|
||||
|
||||
function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product: Product; venueId: string; canSetThreshold: boolean; onClose: () => void }) {
|
||||
const { t, te } = useLang();
|
||||
const queryClient = useQueryClient();
|
||||
function AdjustDialog({ product, venueId, onClose }: { product: Product; venueId: string; onClose: () => void }) {
|
||||
const { t } = useLang();
|
||||
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;
|
||||
@@ -302,16 +308,6 @@ function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product:
|
||||
? 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([{
|
||||
@@ -356,23 +352,6 @@ function AdjustDialog({ product, venueId, canSetThreshold, onClose }: { product:
|
||||
<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>
|
||||
@@ -443,3 +422,150 @@ function DeliveryDialog({ products, venueId, onClose }: { products: Product[]; v
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a product, or changes or archives one (admin). The terminals get it on their next sync; drinks already on a tab
|
||||
* keep the name and price they were rung up with.
|
||||
*/
|
||||
function ProductDialog({ product, categories, venueId, onClose }: {
|
||||
product: Product | null;
|
||||
categories: string[];
|
||||
venueId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, te } = useLang();
|
||||
const queryClient = useQueryClient();
|
||||
const isNew = product === null;
|
||||
const [name, setName] = useState(product?.name ?? '');
|
||||
const [category, setCategory] = useState(product?.category ?? '');
|
||||
const [price, setPrice] = useState(product ? centsToInput(product.priceInCents) : '');
|
||||
const [threshold, setThreshold] = useState(product?.lowStockThreshold?.toString() ?? '');
|
||||
const [trackStock, setTrackStock] = useState(true);
|
||||
const [stock, setStock] = useState('');
|
||||
|
||||
const priceInCents = parseEuros(price);
|
||||
const thresholdValue = threshold.trim() === '' ? null : /^\d+$/.test(threshold.trim()) ? parseInt(threshold, 10) : undefined;
|
||||
const stockValue = /^\d+$/.test(stock.trim()) ? parseInt(stock, 10) : null;
|
||||
const valid = name.trim() !== '' && priceInCents !== null && thresholdValue !== undefined
|
||||
&& (!isNew || !trackStock || stockValue !== null);
|
||||
|
||||
function done(title: string) {
|
||||
toast({ title, description: t('products.sync_desc'), variant: 'success' });
|
||||
queryClient.invalidateQueries({ queryKey: ['stock'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['stock-history'] });
|
||||
onClose();
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const fields = { name: name.trim(), category: category.trim() || null, priceInCents: priceInCents!, lowStockThreshold: thresholdValue! };
|
||||
return product === null
|
||||
? stockApi.createProduct(venueId, { ...fields, stock: trackStock ? stockValue : null })
|
||||
: stockApi.updateProduct(venueId, product.productId, fields);
|
||||
},
|
||||
onSuccess: (saved) => done(t(isNew ? 'products.created' : 'products.saved', { name: saved.name })),
|
||||
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: (archived: boolean) => stockApi.updateProduct(venueId, product!.productId, { archived }),
|
||||
onSuccess: (saved, archived) => done(t(archived ? 'products.archived' : 'products.restored', { name: saved.name })),
|
||||
onError: (err) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const busy = save.isPending || archive.isPending;
|
||||
|
||||
function submit() {
|
||||
if (valid && !busy) save.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isNew ? t('products.new') : t('products.edit_title', { name: product.name })}</DialogTitle>
|
||||
<DialogDescription>{t('products.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-name">{t('products.name')}</Label>
|
||||
<Input id="product-name" value={name} maxLength={200} onChange={(e) => setName(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-category">{t('products.category')}</Label>
|
||||
<Input
|
||||
id="product-category"
|
||||
list="product-categories"
|
||||
value={category}
|
||||
maxLength={100}
|
||||
placeholder={t('products.category_placeholder')}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
<datalist id="product-categories">
|
||||
{categories.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-price">{t('products.price')}</Label>
|
||||
<Input
|
||||
id="product-price"
|
||||
inputMode="decimal"
|
||||
value={price}
|
||||
placeholder="2,50"
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isNew && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={trackStock} onChange={(e) => setTrackStock(e.target.checked)} className="h-4 w-4" />
|
||||
{t('products.track_stock')}
|
||||
</label>
|
||||
{trackStock && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-stock">{t('products.starting_stock')}</Label>
|
||||
<Input id="product-stock" inputMode="numeric" value={stock} onChange={(e) => setStock(e.target.value)} className="max-w-[8rem]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!isNew ? product.stock !== null : trackStock) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="product-threshold">{t('stock.threshold')}</Label>
|
||||
<Input
|
||||
id="product-threshold"
|
||||
inputMode="numeric"
|
||||
value={threshold}
|
||||
placeholder={t('stock.threshold_none')}
|
||||
onChange={(e) => setThreshold(e.target.value)}
|
||||
className="max-w-[8rem]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isNew && (
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{product.archivedAt === null ? t('products.archive_desc') : t('products.restore_desc')}
|
||||
</p>
|
||||
<Button
|
||||
variant={product.archivedAt === null ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => archive.mutate(product.archivedAt === null)}
|
||||
>
|
||||
{product.archivedAt === null ? t('products.archive') : t('products.restore')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} disabled={!valid || busy}>{save.isPending ? t('products.saving') : t('products.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,13 +116,19 @@ export interface Product {
|
||||
name: string;
|
||||
category: string | null;
|
||||
priceInCents: number;
|
||||
/** Null when the terminal doesn't track stock for this product; can be negative */
|
||||
/** Null when stock is not tracked for this product; can be negative */
|
||||
stock: number | null;
|
||||
lowStockThreshold: number | null;
|
||||
/** The latest count, on the dashboard or a terminal; null when never counted */
|
||||
countedAt: string | null;
|
||||
/** When it was created or last changed */
|
||||
reportedAt: string;
|
||||
pendingAdjustments: StockAdjustment[];
|
||||
/** Archived (deleted): hidden on the terminals */
|
||||
archivedAt: string | null;
|
||||
}
|
||||
|
||||
export type ProductFields = { name: string; category: string | null; priceInCents: number; lowStockThreshold: number | null };
|
||||
|
||||
export type NewStockAdjustment = { productId: string; type: StockAdjustmentType; quantity: number; note?: string };
|
||||
|
||||
export function getCookie(name: string): string | null {
|
||||
@@ -346,12 +352,17 @@ export const stockApi = {
|
||||
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 }),
|
||||
/** [stock] is the starting count; null: not tracked */
|
||||
createProduct: (venueId: string, product: ProductFields & { stock: number | null }) =>
|
||||
api<Product>(`/venues/${venueId}/stock/products`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(product),
|
||||
}),
|
||||
/** Only the fields that are sent change; archived: true deletes (hides) the product */
|
||||
updateProduct: (venueId: string, productId: string, changes: Partial<ProductFields> & { archived?: boolean }) =>
|
||||
api<Product>(`/venues/${venueId}/stock/products/${encodeURIComponent(productId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(changes),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -269,31 +269,55 @@ 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',
|
||||
'nav.stock': 'Products',
|
||||
'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.title': 'Products & 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.reported': 'updated {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.empty_desc': 'Add the products you sell. The terminals get them on their next sync.',
|
||||
'stock.search_placeholder': 'Search products',
|
||||
'stock.filter_all': 'All',
|
||||
'stock.filter_low': 'Running low',
|
||||
'stock.filter_untracked': 'Not tracked',
|
||||
'stock.filter_archived': 'Archived',
|
||||
'stock.archived': 'Archived',
|
||||
'products.new': 'New product',
|
||||
'products.edit_title': 'Edit {name}',
|
||||
'products.desc': 'The terminals get it on their next sync. Drinks already on a tab keep their name and price.',
|
||||
'products.name': 'Name',
|
||||
'products.category': 'Category',
|
||||
'products.category_placeholder': 'e.g. Frisdrank',
|
||||
'products.price': 'Price (€)',
|
||||
'products.track_stock': 'Track stock',
|
||||
'products.starting_stock': 'In stock now',
|
||||
'products.save': 'Save',
|
||||
'products.saving': 'Saving…',
|
||||
'products.created': '{name} added',
|
||||
'products.saved': '{name} saved',
|
||||
'products.sync_desc': 'The terminals show it after their next sync.',
|
||||
'products.archive': 'Archive',
|
||||
'products.archive_desc': 'An archived product disappears from the terminals. Open tabs and the history keep it.',
|
||||
'products.archived': '{name} archived',
|
||||
'products.restore': 'Restore',
|
||||
'products.restore_desc': 'This product is archived. Restoring it puts it back on the terminals.',
|
||||
'products.restored': '{name} is back',
|
||||
'errors.product_name_exists': 'A product with this name already exists',
|
||||
'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.counted_at': 'Last counted',
|
||||
'stock.never_counted': 'Never',
|
||||
'stock.actions': 'Actions',
|
||||
'stock.not_tracked': 'Not tracked',
|
||||
'stock.low': 'Low',
|
||||
@@ -301,8 +325,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'stock.adjust': 'Adjust',
|
||||
'stock.start_tracking': 'Track stock',
|
||||
'stock.start_tracking_desc': "Stock isn't tracked 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.',
|
||||
'stock.mode_add': 'Add (delivery)',
|
||||
'stock.mode_remove': 'Take out (breakage, own use…)',
|
||||
@@ -315,8 +337,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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': 'Save',
|
||||
'stock.sending': 'Sending…',
|
||||
'stock.sent': 'Stock updated',
|
||||
@@ -602,31 +622,55 @@ 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',
|
||||
'nav.stock': 'Producten',
|
||||
'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.title': 'Producten & 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.reported': 'bijgewerkt {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.empty_desc': "Voeg de producten toe die je verkoopt. De kassa's krijgen ze bij hun volgende synchronisatie.",
|
||||
'stock.search_placeholder': 'Zoek producten',
|
||||
'stock.filter_all': 'Alle',
|
||||
'stock.filter_low': 'Bijna op',
|
||||
'stock.filter_untracked': 'Niet bijgehouden',
|
||||
'stock.filter_archived': 'Gearchiveerd',
|
||||
'stock.archived': 'Gearchiveerd',
|
||||
'products.new': 'Nieuw product',
|
||||
'products.edit_title': '{name} bewerken',
|
||||
'products.desc': "De kassa's krijgen het bij hun volgende synchronisatie. Wat al op een poef staat, houdt zijn naam en prijs.",
|
||||
'products.name': 'Naam',
|
||||
'products.category': 'Categorie',
|
||||
'products.category_placeholder': 'bv. Frisdrank',
|
||||
'products.price': 'Prijs (€)',
|
||||
'products.track_stock': 'Voorraad bijhouden',
|
||||
'products.starting_stock': 'Nu in voorraad',
|
||||
'products.save': 'Opslaan',
|
||||
'products.saving': 'Opslaan…',
|
||||
'products.created': '{name} toegevoegd',
|
||||
'products.saved': '{name} opgeslagen',
|
||||
'products.sync_desc': "De kassa's tonen het na hun volgende synchronisatie.",
|
||||
'products.archive': 'Archiveren',
|
||||
'products.archive_desc': "Een gearchiveerd product verdwijnt van de kassa's. Open poefs en de geschiedenis houden het.",
|
||||
'products.archived': '{name} gearchiveerd',
|
||||
'products.restore': 'Terugzetten',
|
||||
'products.restore_desc': "Dit product is gearchiveerd. Terugzetten zet het weer op de kassa's.",
|
||||
'products.restored': '{name} staat terug',
|
||||
'errors.product_name_exists': 'Er bestaat al een product met deze naam',
|
||||
'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.counted_at': 'Laatst geteld',
|
||||
'stock.never_counted': 'Nooit',
|
||||
'stock.actions': 'Acties',
|
||||
'stock.not_tracked': 'Niet bijgehouden',
|
||||
'stock.low': 'Bijna op',
|
||||
@@ -634,8 +678,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'stock.adjust': 'Aanpassen',
|
||||
'stock.start_tracking': 'Voorraad bijhouden',
|
||||
'stock.start_tracking_desc': 'Voor dit product wordt nog geen voorraad bijgehouden. 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.',
|
||||
'stock.mode_add': 'Bijvullen (levering)',
|
||||
'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)',
|
||||
@@ -648,8 +690,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
'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': 'Opslaan',
|
||||
'stock.sending': 'Versturen…',
|
||||
'stock.sent': 'Voorraad bijgewerkt',
|
||||
|
||||
@@ -6,6 +6,18 @@ export function formatCents(cents: number, currency: string = 'EUR'): string {
|
||||
}).format(euros);
|
||||
}
|
||||
|
||||
/** "2,50", "2.5" or "2" as cents; null when it isn't an amount. */
|
||||
export function parseEuros(text: string): number | null {
|
||||
const value = text.trim().replace(/^€\s*/, '');
|
||||
if (!/^\d+([.,]\d{1,2})?$/.test(value)) return null;
|
||||
return Math.round(parseFloat(value.replace(',', '.')) * 100);
|
||||
}
|
||||
|
||||
/** Cents as an amount to edit: 250 → "2,50". */
|
||||
export function centsToInput(cents: number): string {
|
||||
return (cents / 100).toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
// Relative times and dates are language-dependent: use relativeTime/dateTime from useLang()
|
||||
|
||||
export { cn } from './cn';
|
||||
+12
-5
@@ -62,9 +62,10 @@ and `docs/server-stock-source-of-truth.md`.
|
||||
first, at most 500; the response's `settlementsCursor` is sent back next time.
|
||||
- `openTabs` is only used once per venue, on the first sync after the server started holding the tabs: the terminal's
|
||||
own tabs (and catalog stock) are taken over and the events of that request are stored without applying them again.
|
||||
- `products` (optional, at most 2000) upserts names, categories and prices. Its `stock` is ignored (counts arrive as
|
||||
`stock_set`), a new product starts untracked, and products are never removed because one terminal doesn't report
|
||||
them. An invalid product is skipped and logged.
|
||||
- The dashboard manages the products. `products` in the request (at most 2000) is only used on that same first sync,
|
||||
to take over the terminal's catalog with its stock; after that it is ignored. The response's `catalog` lists every
|
||||
product of the venue: `[{ id, name, category, priceInCents, lowStockThreshold, archived }]`. The terminal adds and
|
||||
updates its products from it and hides archived products and products the catalog doesn't list.
|
||||
- Planned customers are opened as tabs by the server, once; `plannedTabNames` is always empty.
|
||||
- Dashboard stock adjustments are applied by the server right away; `stockAdjustments` is always empty.
|
||||
`stockAdjustmentResults` from older app versions are still recorded and acknowledged.
|
||||
@@ -91,12 +92,18 @@ Names match case-insensitively and keep their existing spelling; `replace` also
|
||||
`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` - The venue's products (archived ones too, with `archivedAt`) 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)
|
||||
`POST /venues/:venueId/stock/products` - Add a product (admin): `{ name, category?, priceInCents, stock?, lowStockThreshold? }`.
|
||||
`stock` is the starting count (recorded as an applied `set` adjustment); null or left out: not tracked.
|
||||
`product_name_exists` when an active product has the same name (ignoring case).
|
||||
`PATCH /venues/:venueId/stock/products/:productId` - Change a product (admin): any of `{ name, category, priceInCents,
|
||||
lowStockThreshold, archived }`. `archived: true` deletes it for the terminals (kept for tabs and history),
|
||||
`archived: false` brings it back. The stock itself changes through adjustments.
|
||||
|
||||
### Commands
|
||||
`POST /venues/:venueId/commands` - Settle a tab: `{ tabId, type: "close_tab", expectedVersion, paymentMethod }`. The
|
||||
|
||||
Reference in New Issue
Block a user