@@ -0,0 +1,90 @@
|
||||
import { TabEventDto } from '../devices/dto/tab-event.dto';
|
||||
import { tabSummary, VenueTabs } from './tab-state';
|
||||
import { tabOrders } from './tab-orders';
|
||||
|
||||
const at = (minutes: number) => new Date(Date.UTC(2026, 9, 28, 12, 30 + minutes));
|
||||
let counter = 0;
|
||||
|
||||
function event(fields: Partial<TabEventDto> & { type: TabEventDto['type'] }): TabEventDto {
|
||||
return { id: `e${++counter}`, tabId: 't1', customerName: 'Jan', occurredAt: at(0).toISOString(), ...fields } as TabEventDto;
|
||||
}
|
||||
|
||||
const duvel = { lineId: 'duvel', productId: 'duvel', productName: 'Duvel', unitPriceInCents: 400 };
|
||||
const cola = { lineId: 'cola', productId: 'cola', productName: 'Cola', unitPriceInCents: 250 };
|
||||
const beer = { lineId: 'beer', productId: 'beer', productName: 'Beer', unitPriceInCents: 300 };
|
||||
|
||||
function tabWith(changes: [minutes: number, fields: Partial<TabEventDto> & { type: TabEventDto['type'] }][]) {
|
||||
const tabs = new VenueTabs([]);
|
||||
tabs.apply(event({ type: 'tab_opened' }), at(0));
|
||||
for (const [minutes, fields] of changes) tabs.apply(event(fields), at(minutes));
|
||||
return tabs;
|
||||
}
|
||||
|
||||
describe('tabOrders', () => {
|
||||
it('shows the drinks of each order under the time it started', () => {
|
||||
const tabs = tabWith([
|
||||
[0, { type: 'line_added', quantity: 2, orderId: 'o1', ...duvel }],
|
||||
[1, { type: 'line_added', quantity: 1, orderId: 'o1', ...cola }],
|
||||
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
|
||||
[39, { type: 'line_added', quantity: 1, orderId: 'o2', ...beer }],
|
||||
]);
|
||||
|
||||
expect(tabOrders(tabs.get('t1')!.lines)).toEqual([
|
||||
{
|
||||
orderId: 'o1', startedAt: at(0).toISOString(),
|
||||
items: [
|
||||
{ productName: 'Duvel', quantity: 2, unitPriceInCents: 400, removed: 0 },
|
||||
{ productName: 'Cola', quantity: 1, unitPriceInCents: 250, removed: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
orderId: 'o2', startedAt: at(38).toISOString(),
|
||||
items: [
|
||||
{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400, removed: 0 },
|
||||
{ productName: 'Beer', quantity: 1, unitPriceInCents: 300, removed: 0 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups drinks from older app versions by time: a gap of over 5 minutes starts a new order', () => {
|
||||
const tabs = tabWith([
|
||||
[0, { type: 'line_added', quantity: 1, ...duvel }],
|
||||
[4, { type: 'line_added', quantity: 1, ...cola }],
|
||||
[20, { type: 'line_added', quantity: 1, ...beer }],
|
||||
]);
|
||||
|
||||
const orders = tabOrders(tabs.get('t1')!.lines);
|
||||
|
||||
expect(orders.map((order) => [order.orderId, order.startedAt, order.items.map((item) => item.productName)])).toEqual([
|
||||
[null, at(0).toISOString(), ['Duvel', 'Cola']],
|
||||
[null, at(20).toISOString(), ['Beer']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes a drink off the order it is removed from, and leaves the others', () => {
|
||||
const tabs = tabWith([
|
||||
[0, { type: 'line_added', quantity: 2, orderId: 'o1', ...duvel }],
|
||||
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
|
||||
[40, { type: 'line_removed', quantity: 1, orderId: 'o1', ...duvel }],
|
||||
]);
|
||||
|
||||
expect(tabOrders(tabs.get('t1')!.lines).map((order) => [order.orderId, order.items[0].quantity, order.items[0].removed]))
|
||||
.toEqual([['o1', 1, 1], ['o2', 1, 0]]);
|
||||
// The open tab only shows what is still on it
|
||||
expect(tabSummary({ ...tabs.get('t1')!, updatedAt: at(40) }).orders.map((order) => order.items))
|
||||
.toEqual([[{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400 }], [{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400 }]]);
|
||||
});
|
||||
|
||||
it('still takes the latest drinks off for a removal without an order (older app versions)', () => {
|
||||
const tabs = tabWith([
|
||||
[0, { type: 'line_added', quantity: 1, orderId: 'o1', ...duvel }],
|
||||
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
|
||||
]);
|
||||
const removal = event({ type: 'line_removed', quantity: 5, ...duvel });
|
||||
|
||||
expect(tabs.removable(removal)).toBe(2);
|
||||
expect(tabs.removable({ ...removal, orderId: 'o1' })).toBe(1);
|
||||
expect(tabs.removable({ ...removal, orderId: null })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { isActive, Line, Unit } from './tab-state';
|
||||
|
||||
/** Drinks rung up without an order (older app versions) that follow each other this closely form one order. */
|
||||
export const ORDER_GAP_MS = 5 * 60 * 1000;
|
||||
|
||||
export type OrderLine = { productName: string; quantity: number; unitPriceInCents: number; removed: number };
|
||||
|
||||
/** Drinks ordered together, as the terminal shows them under one "12:32 today" divider. */
|
||||
export type TabOrder = {
|
||||
// Null for drinks from older app versions, grouped by time instead
|
||||
orderId: string | null;
|
||||
// When its first drink was rung up
|
||||
startedAt: string;
|
||||
items: OrderLine[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits a tab's (or settlement's) lines into its orders, oldest first. Within an order every line shows how many of
|
||||
* its drinks are still on it and how many were taken off again.
|
||||
*/
|
||||
export function tabOrders(lines: Line[]): TabOrder[] {
|
||||
const drinks = lines
|
||||
.flatMap((line) => line.units.map((unit) => ({ line, unit })))
|
||||
.sort((a, b) => a.unit.addedAt.localeCompare(b.unit.addedAt) || a.unit.id.localeCompare(b.unit.id));
|
||||
|
||||
const orders: { orderId: string | null; startedAt: string; units: Map<Line, Unit[]> }[] = [];
|
||||
const byId = new Map<string, (typeof orders)[number]>();
|
||||
let unordered: { order: (typeof orders)[number]; lastAt: number } | null = null;
|
||||
|
||||
for (const { line, unit } of drinks) {
|
||||
let order: (typeof orders)[number];
|
||||
if (unit.orderId) {
|
||||
order = byId.get(unit.orderId) ?? { orderId: unit.orderId, startedAt: unit.addedAt, units: new Map() };
|
||||
if (!byId.has(unit.orderId)) {
|
||||
byId.set(unit.orderId, order);
|
||||
orders.push(order);
|
||||
}
|
||||
} else {
|
||||
const at = Date.parse(unit.addedAt);
|
||||
if (!unordered || at - unordered.lastAt > ORDER_GAP_MS) {
|
||||
unordered = { order: { orderId: null, startedAt: unit.addedAt, units: new Map() }, lastAt: at };
|
||||
orders.push(unordered.order);
|
||||
}
|
||||
unordered.lastAt = at;
|
||||
order = unordered.order;
|
||||
}
|
||||
order.units.set(line, [...(order.units.get(line) ?? []), unit]);
|
||||
}
|
||||
|
||||
return orders
|
||||
.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
|
||||
.map(({ orderId, startedAt, units }) => ({
|
||||
orderId,
|
||||
startedAt,
|
||||
items: [...units].map(([line, lineUnits]) => ({
|
||||
productName: line.productName,
|
||||
quantity: lineUnits.filter(isActive).length,
|
||||
unitPriceInCents: line.unitPriceInCents,
|
||||
removed: lineUnits.filter((unit) => !isActive(unit)).length,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TabEventDto } from '../devices/dto/tab-event.dto';
|
||||
import { OpenTabDto } from '../devices/dto/sync.dto';
|
||||
import { tabOrders } from './tab-orders';
|
||||
|
||||
/**
|
||||
* The venue's open tabs and the rules that change them. Pure: the ledger loads the state, applies events here and
|
||||
@@ -7,8 +8,11 @@ import { OpenTabDto } from '../devices/dto/sync.dto';
|
||||
* derived the same way on both sides (`<event id>:<index>`).
|
||||
*/
|
||||
|
||||
/** One piece ever put on a line. */
|
||||
export type Unit = { id: string; addedAt: string; removedAt: string | null };
|
||||
/**
|
||||
* One piece ever put on a line. [orderId] is the order it was rung up in on the terminal: drinks ordered together are
|
||||
* shown together. Missing for drinks from older app versions.
|
||||
*/
|
||||
export type Unit = { id: string; addedAt: string; removedAt: string | null; orderId?: string | null };
|
||||
|
||||
export type Line = {
|
||||
id: string;
|
||||
@@ -46,6 +50,16 @@ export const isActive = (unit: Unit) => unit.removedAt === null;
|
||||
|
||||
export const lineQuantity = (line: Line) => line.units.filter(isActive).length;
|
||||
|
||||
/**
|
||||
* The drinks of [line] a line_removed event may take off: those of its order (null: drinks without one), or any when
|
||||
* the event has no order (older app versions).
|
||||
*/
|
||||
function removableUnits(line: Line, event: { orderId?: string | null }): Unit[] {
|
||||
const active = line.units.filter(isActive);
|
||||
if (event.orderId === undefined) return active;
|
||||
return active.filter((unit) => (unit.orderId ?? null) === event.orderId);
|
||||
}
|
||||
|
||||
export class VenueTabs {
|
||||
private readonly tabs: Map<string, TabState>;
|
||||
/** Tabs to write back, and tabs to delete. */
|
||||
@@ -110,8 +124,9 @@ export class VenueTabs {
|
||||
tab.lines.push(line);
|
||||
}
|
||||
const addedAt = occurredAt.toISOString();
|
||||
const orderId = event.orderId ?? null;
|
||||
for (let index = 0; index < event.quantity!; index++) {
|
||||
line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null });
|
||||
line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null, orderId });
|
||||
}
|
||||
this.touch(tabId);
|
||||
return null;
|
||||
@@ -121,8 +136,8 @@ export class VenueTabs {
|
||||
const tab = this.tabs.get(tabId);
|
||||
const line = tab && findLine(tab, event);
|
||||
if (!line) return null;
|
||||
// The latest ones: the drinks that were just rung up by mistake
|
||||
const active = line.units.filter(isActive);
|
||||
// The latest ones of the order: the drinks that were just rung up by mistake
|
||||
const active = removableUnits(line, event);
|
||||
for (const unit of active.slice(Math.max(0, active.length - event.quantity!))) {
|
||||
unit.removedAt = occurredAt.toISOString();
|
||||
}
|
||||
@@ -178,7 +193,7 @@ export class VenueTabs {
|
||||
removable(event: TabEventDto): number {
|
||||
const tab = event.tabId ? this.tabs.get(event.tabId) : undefined;
|
||||
const line = tab && findLine(tab, event);
|
||||
return line ? Math.min(event.quantity ?? 0, lineQuantity(line)) : 0;
|
||||
return line ? Math.min(event.quantity ?? 0, removableUnits(line, event).length) : 0;
|
||||
}
|
||||
|
||||
/** Settles everything still on the tab, as the dashboard does. Null when there is nothing to settle. */
|
||||
@@ -311,6 +326,10 @@ export function tabSummary(tab: { id: string; customerName: string; version: num
|
||||
totalInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0),
|
||||
itemCount: lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||
items: lines,
|
||||
// Only what is still on the tab: drinks taken off again are left out
|
||||
orders: tabOrders(tab.lines as Line[])
|
||||
.map((order) => ({ ...order, items: order.items.filter((item) => item.quantity > 0).map(({ removed, ...item }) => item) }))
|
||||
.filter((order) => order.items.length > 0),
|
||||
updatedAt: tab.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
import { SettledLine, tabSummary } from './tab-state';
|
||||
import { tabOrders } from './tab-orders';
|
||||
import { VenueLedger } from './venue-ledger';
|
||||
|
||||
@Injectable()
|
||||
@@ -72,7 +73,8 @@ export class TabsService {
|
||||
|
||||
return {
|
||||
settlements: rows.map((row) => {
|
||||
const items = (row.lines as unknown as SettledLine[]).map((line) => ({
|
||||
const lines = row.lines as unknown as SettledLine[];
|
||||
const items = lines.map((line) => ({
|
||||
productName: line.productName,
|
||||
quantity: line.quantity,
|
||||
unitPriceInCents: line.unitPriceInCents,
|
||||
@@ -88,6 +90,7 @@ export class TabsService {
|
||||
amountInCents: row.amountInCents,
|
||||
itemCount: items.reduce((count, item) => count + item.quantity, 0),
|
||||
items,
|
||||
orders: tabOrders(lines),
|
||||
settledBy: (row.deviceId ? deviceNames.get(row.deviceId) : row.requestedBy) ?? null,
|
||||
onDashboard: row.deviceId === null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user