This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { TabEventDto } from '../devices/dto/tab-event.dto';
|
||||
import { findLine, seedTabs, tabJson, tabSummary, VenueTabs } from './tab-state';
|
||||
|
||||
const time = new Date('2026-09-24T20:00:00.000Z');
|
||||
let counter = 0;
|
||||
|
||||
function event(fields: Partial<TabEventDto> & { type: TabEventDto['type'] }): TabEventDto {
|
||||
return { id: `e${++counter}`, tabId: 't1', customerName: 'Jan', occurredAt: time.toISOString(), ...fields } as TabEventDto;
|
||||
}
|
||||
|
||||
const cola = { productId: 'cola', productName: 'Cola', unitPriceInCents: 250 };
|
||||
|
||||
function withCola(quantity: number) {
|
||||
const tabs = new VenueTabs([]);
|
||||
tabs.apply(event({ type: 'tab_opened' }), time);
|
||||
tabs.apply(event({ type: 'line_added', lineId: 'line-1', quantity, ...cola }), time);
|
||||
return tabs;
|
||||
}
|
||||
|
||||
describe('VenueTabs.apply', () => {
|
||||
it('ignores a second tab_opened for a tab that exists', () => {
|
||||
const tabs = withCola(1);
|
||||
|
||||
tabs.apply(event({ type: 'tab_opened', customerName: 'Other' }), time);
|
||||
|
||||
expect(tabs.get('t1')).toMatchObject({ customerName: 'Jan', version: 2 });
|
||||
});
|
||||
|
||||
it('gives a line a new id when its id is taken on another tab', () => {
|
||||
const tabs = withCola(1);
|
||||
const added = event({ type: 'line_added', tabId: 't2', lineId: 'line-1', quantity: 1, ...cola });
|
||||
|
||||
tabs.apply(added, time);
|
||||
|
||||
expect(tabs.get('t2')!.lines[0].id).toBe(`${added.id}:line`);
|
||||
});
|
||||
|
||||
it('removes no more drinks than are on the line', () => {
|
||||
const tabs = withCola(2);
|
||||
|
||||
tabs.apply(event({ type: 'line_removed', lineId: 'line-1', quantity: 5, ...cola }), time);
|
||||
|
||||
expect(tabs.get('t1')!.lines[0].units.every((unit) => unit.removedAt !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps settled lines the server never saw in the settlement, with made-up units', () => {
|
||||
const tabs = withCola(1);
|
||||
const settled = event({
|
||||
type: 'settled', amountInCents: 650, paymentMethod: 'cash',
|
||||
items: [
|
||||
{ productId: 'cola', productName: 'Cola', quantity: 1, unitPriceInCents: 250 },
|
||||
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
|
||||
],
|
||||
});
|
||||
|
||||
const settlement = tabs.apply(settled, time)!;
|
||||
|
||||
expect(settlement.amountInCents).toBe(650);
|
||||
expect(settlement.lines).toMatchObject([
|
||||
{ id: 'line-1', quantity: 1 },
|
||||
{ productId: null, productName: 'Chips', quantity: 2, units: [{ id: `${settled.id}:item:1:0` }, { id: `${settled.id}:item:1:1` }] },
|
||||
]);
|
||||
expect(tabs.get('t1')!.lines).toEqual([]);
|
||||
});
|
||||
|
||||
it('records a settlement of a tab that is gone', () => {
|
||||
const tabs = new VenueTabs([]);
|
||||
|
||||
const settlement = tabs.apply(event({
|
||||
type: 'settled', amountInCents: 250, paymentMethod: 'cash',
|
||||
items: [{ productName: 'Cola', quantity: 1, unitPriceInCents: 250 }],
|
||||
}), time);
|
||||
|
||||
expect(settlement).toMatchObject({ tabId: 't1', customerName: 'Jan', lines: [{ quantity: 1 }] });
|
||||
expect(tabs.all()).toEqual([]);
|
||||
});
|
||||
|
||||
it('changes nothing for events about tabs that are gone', () => {
|
||||
const tabs = new VenueTabs([]);
|
||||
|
||||
tabs.apply(event({ type: 'tab_renamed' }), time);
|
||||
tabs.apply(event({ type: 'tab_deleted' }), time);
|
||||
tabs.apply(event({ type: 'line_removed', quantity: 1, ...cola }), time);
|
||||
|
||||
expect(tabs.changed.size + tabs.deleted.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VenueTabs.settleAll', () => {
|
||||
it('settles every drink, keeping the tab open and empty', () => {
|
||||
const tabs = withCola(3);
|
||||
tabs.apply(event({ type: 'line_removed', lineId: 'line-1', quantity: 1, ...cola }), time);
|
||||
|
||||
const settlement = tabs.settleAll('t1', { id: 's1', settledAt: time, paymentMethod: 'cash' })!;
|
||||
|
||||
expect(settlement).toMatchObject({ amountInCents: 500, lines: [{ quantity: 2 }] });
|
||||
expect(settlement.lines[0].units).toHaveLength(3);
|
||||
expect(tabJson(tabs.get('t1')!)).toMatchObject({ version: 4, lines: [] });
|
||||
});
|
||||
|
||||
it('returns null for an empty tab', () => {
|
||||
const tabs = new VenueTabs([]);
|
||||
tabs.apply(event({ type: 'tab_opened' }), time);
|
||||
|
||||
expect(tabs.settleAll('t1', { id: 's1', settledAt: time, paymentMethod: 'cash' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLine', () => {
|
||||
it('prefers the line id, then the product, then the name, at the same price', () => {
|
||||
const tab = {
|
||||
id: 't1', customerName: 'Jan', openedAt: time, version: 0,
|
||||
lines: [
|
||||
{ id: 'a', productId: null, productName: 'Cola', unitPriceInCents: 250, units: [] },
|
||||
{ id: 'b', productId: 'cola', productName: 'Coca-Cola', unitPriceInCents: 250, units: [] },
|
||||
{ id: 'c', productId: 'cola', productName: 'Cola', unitPriceInCents: 300, units: [] },
|
||||
],
|
||||
};
|
||||
|
||||
expect(findLine(tab, { lineId: 'c', productId: 'cola', productName: 'Cola', unitPriceInCents: 250 })!.id).toBe('c');
|
||||
expect(findLine(tab, { productId: 'cola', productName: 'Cola', unitPriceInCents: 250 })!.id).toBe('b');
|
||||
expect(findLine(tab, { productName: 'Cola', unitPriceInCents: 250 })!.id).toBe('a');
|
||||
expect(findLine(tab, { productName: 'Cola', unitPriceInCents: 100 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedTabs and tabSummary', () => {
|
||||
it("turns a terminal's snapshot into tabs the dashboard sums up the same", () => {
|
||||
const [seeded] = seedTabs([{
|
||||
id: 't1', customerName: 'Jan', version: 7, totalInCents: 900, itemCount: 4,
|
||||
items: [
|
||||
{ productName: 'Cola', quantity: 2, unitPriceInCents: 250 },
|
||||
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
|
||||
{ productName: 'Gone', quantity: 0, unitPriceInCents: 100 },
|
||||
],
|
||||
}], time);
|
||||
|
||||
expect(seeded.lines.map((line) => line.units.length)).toEqual([2, 2]);
|
||||
expect(tabSummary({ ...seeded, updatedAt: time })).toMatchObject({ totalInCents: 900, itemCount: 4, version: 7 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { TabEventDto } from '../devices/dto/tab-event.dto';
|
||||
import { OpenTabDto } from '../devices/dto/sync.dto';
|
||||
|
||||
/**
|
||||
* The venue's open tabs and the rules that change them. Pure: the ledger loads the state, applies events here and
|
||||
* stores what changed. The terminals apply the same rules to replay their unsent events, so a unit's id must be
|
||||
* 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 };
|
||||
|
||||
export type Line = {
|
||||
id: string;
|
||||
productId: string | null;
|
||||
productName: string;
|
||||
unitPriceInCents: number;
|
||||
units: Unit[];
|
||||
};
|
||||
|
||||
export type TabState = {
|
||||
id: string;
|
||||
customerName: string;
|
||||
openedAt: Date;
|
||||
version: number;
|
||||
lines: Line[];
|
||||
};
|
||||
|
||||
export type SettledLine = Line & { quantity: number };
|
||||
|
||||
export type SettlementDraft = {
|
||||
id: string;
|
||||
tabId: string;
|
||||
customerName: string;
|
||||
settledAt: Date;
|
||||
paymentMethod: string;
|
||||
amountInCents: number;
|
||||
lines: SettledLine[];
|
||||
commandId: string | null;
|
||||
};
|
||||
|
||||
/** What an event needs to find its line. */
|
||||
type LineRef = { lineId?: string; productId?: string | null; productName?: string; unitPriceInCents?: number };
|
||||
|
||||
export const isActive = (unit: Unit) => unit.removedAt === null;
|
||||
|
||||
export const lineQuantity = (line: Line) => line.units.filter(isActive).length;
|
||||
|
||||
export class VenueTabs {
|
||||
private readonly tabs: Map<string, TabState>;
|
||||
/** Tabs to write back, and tabs to delete. */
|
||||
readonly changed = new Set<string>();
|
||||
readonly deleted = new Set<string>();
|
||||
|
||||
constructor(tabs: TabState[]) {
|
||||
this.tabs = new Map(tabs.map((tab) => [tab.id, tab]));
|
||||
}
|
||||
|
||||
get(tabId: string): TabState | undefined {
|
||||
return this.tabs.get(tabId);
|
||||
}
|
||||
|
||||
all(): TabState[] {
|
||||
return [...this.tabs.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one event that happened at [occurredAt]. Returns the settlement a `settled` event made. Events about a
|
||||
* tab or line that is gone change nothing (they are acknowledged all the same).
|
||||
*/
|
||||
apply(event: TabEventDto, occurredAt: Date): SettlementDraft | null {
|
||||
const tabId = event.tabId;
|
||||
if (!tabId) return null;
|
||||
const customerName = event.customerName ?? '';
|
||||
|
||||
switch (event.type) {
|
||||
case 'tab_opened':
|
||||
if (!this.tabs.has(tabId)) {
|
||||
this.open(tabId, customerName, event.openedAt ? new Date(event.openedAt) : occurredAt);
|
||||
this.touch(tabId);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'tab_renamed': {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (tab) {
|
||||
tab.customerName = customerName;
|
||||
this.touch(tabId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tab_deleted':
|
||||
if (this.tabs.delete(tabId)) {
|
||||
this.changed.delete(tabId);
|
||||
this.deleted.add(tabId);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'line_added': {
|
||||
// Deleted elsewhere: bring it back, so no sale is lost
|
||||
const tab = this.tabs.get(tabId) ?? this.open(tabId, customerName, occurredAt);
|
||||
let line = findLine(tab, event);
|
||||
if (!line) {
|
||||
const lineIdTaken = event.lineId && this.all().some((t) => t.lines.some((l) => l.id === event.lineId));
|
||||
line = {
|
||||
id: event.lineId && !lineIdTaken ? event.lineId : `${event.id}:line`,
|
||||
productId: event.productId ?? null,
|
||||
productName: event.productName!,
|
||||
unitPriceInCents: event.unitPriceInCents!,
|
||||
units: [],
|
||||
};
|
||||
tab.lines.push(line);
|
||||
}
|
||||
const addedAt = occurredAt.toISOString();
|
||||
for (let index = 0; index < event.quantity!; index++) {
|
||||
line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null });
|
||||
}
|
||||
this.touch(tabId);
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'line_removed': {
|
||||
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);
|
||||
for (const unit of active.slice(Math.max(0, active.length - event.quantity!))) {
|
||||
unit.removedAt = occurredAt.toISOString();
|
||||
}
|
||||
this.touch(tabId);
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'settled': {
|
||||
const tab = this.tabs.get(tabId);
|
||||
const settledAt = occurredAt;
|
||||
const lines: SettledLine[] = [];
|
||||
(event.items ?? []).forEach((item, index) => {
|
||||
const line = tab && findLine(tab, item);
|
||||
if (line) {
|
||||
const settled = settleLine(tab, line, item.quantity);
|
||||
if (settled.units.length > 0) lines.push(settled);
|
||||
} else if (item.quantity > 0) {
|
||||
// Settled on the terminal but unknown here: keep it in the history all the same
|
||||
lines.push({
|
||||
id: `${event.id}:item:${index}`,
|
||||
productId: item.productId ?? null,
|
||||
productName: item.productName,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
quantity: item.quantity,
|
||||
units: Array.from({ length: item.quantity }, (_, n) => (
|
||||
{ id: `${event.id}:item:${index}:${n}`, addedAt: settledAt.toISOString(), removedAt: null })),
|
||||
});
|
||||
}
|
||||
});
|
||||
if (tab) this.touch(tabId);
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
tabId,
|
||||
customerName: tab?.customerName ?? customerName,
|
||||
settledAt,
|
||||
paymentMethod: event.paymentMethod!,
|
||||
amountInCents: event.amountInCents!,
|
||||
lines,
|
||||
commandId: event.commandId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Settles everything still on the tab, as the dashboard does. Null when there is nothing to settle. */
|
||||
settleAll(tabId: string, settlement: { id: string; settledAt: Date; paymentMethod: string }): SettlementDraft | null {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab) return null;
|
||||
|
||||
const lines = tab.lines
|
||||
.filter((line) => lineQuantity(line) > 0)
|
||||
.map((line) => settleLine(tab, line, lineQuantity(line)));
|
||||
if (lines.length === 0) return null;
|
||||
this.touch(tabId);
|
||||
|
||||
return {
|
||||
...settlement,
|
||||
tabId,
|
||||
customerName: tab.customerName,
|
||||
amountInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0),
|
||||
lines,
|
||||
commandId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens a tab without an event: planned customers, and taking over a terminal's tabs. */
|
||||
open(tabId: string, customerName: string, openedAt: Date): TabState {
|
||||
const tab: TabState = { id: tabId, customerName, openedAt, version: 0, lines: [] };
|
||||
this.tabs.set(tabId, tab);
|
||||
this.deleted.delete(tabId);
|
||||
this.changed.add(tabId);
|
||||
return tab;
|
||||
}
|
||||
|
||||
private touch(tabId: string) {
|
||||
this.tabs.get(tabId)!.version++;
|
||||
this.changed.add(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The line with the event's line id; else the oldest line with the same product and price; else the oldest with the
|
||||
* same name and price (events from app versions without line or product ids).
|
||||
*/
|
||||
export function findLine(tab: TabState, ref: LineRef): Line | undefined {
|
||||
const samePrice = (line: Line) => line.unitPriceInCents === ref.unitPriceInCents;
|
||||
return (ref.lineId ? tab.lines.find((line) => line.id === ref.lineId) : undefined)
|
||||
?? (ref.productId ? tab.lines.find((line) => line.productId === ref.productId && samePrice(line)) : undefined)
|
||||
?? tab.lines.find((line) => line.productName === ref.productName && samePrice(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the [quantity] oldest active units off [line], together with its removed units, and drops the line when
|
||||
* nothing is left. Drinks added after the settlement (by another terminal) stay on the tab.
|
||||
*/
|
||||
function settleLine(tab: TabState, line: Line, quantity: number): SettledLine {
|
||||
const settled = line.units.filter(isActive).slice(0, quantity);
|
||||
const removed = line.units.filter((unit) => !isActive(unit));
|
||||
const taken = new Set([...settled, ...removed]);
|
||||
|
||||
line.units = line.units.filter((unit) => !taken.has(unit));
|
||||
if (line.units.length === 0) tab.lines = tab.lines.filter((l) => l !== line);
|
||||
|
||||
return {
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
productName: line.productName,
|
||||
unitPriceInCents: line.unitPriceInCents,
|
||||
quantity: settled.length,
|
||||
units: [...settled, ...removed].sort(byAddedAt),
|
||||
};
|
||||
}
|
||||
|
||||
const byAddedAt = (a: Unit, b: Unit) => a.addedAt.localeCompare(b.addedAt);
|
||||
|
||||
/**
|
||||
* The terminal's own open tabs, taken over once when the server starts holding the tabs. The snapshot has no line or
|
||||
* unit ids, so they are made up from the tab id.
|
||||
*/
|
||||
export function seedTabs(openTabs: OpenTabDto[], now: Date): TabState[] {
|
||||
return openTabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
customerName: tab.customerName,
|
||||
openedAt: now,
|
||||
version: tab.version,
|
||||
lines: (tab.items ?? [])
|
||||
.filter((item) => item.quantity > 0)
|
||||
.map((item, index) => ({
|
||||
id: `seed:${tab.id}:${index}`,
|
||||
productId: null,
|
||||
productName: item.productName,
|
||||
unitPriceInCents: item.unitPriceInCents,
|
||||
units: Array.from({ length: item.quantity }, (_, n) => (
|
||||
{ id: `seed:${tab.id}:${index}:${n}`, addedAt: now.toISOString(), removedAt: null })),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/** A tab as sent to terminals and stored: lines carry their quantity. */
|
||||
export function tabJson(tab: TabState) {
|
||||
return {
|
||||
id: tab.id,
|
||||
customerName: tab.customerName,
|
||||
openedAt: tab.openedAt.toISOString(),
|
||||
version: tab.version,
|
||||
lines: tab.lines.map(lineJson),
|
||||
};
|
||||
}
|
||||
|
||||
export function lineJson(line: Line) {
|
||||
return { ...line, quantity: lineQuantity(line) };
|
||||
}
|
||||
|
||||
/** A tab as the dashboard shows it: totals, and the lines that are still on it. */
|
||||
export function tabSummary(tab: { id: string; customerName: string; version: number; lines: unknown; updatedAt: Date }) {
|
||||
const lines = (tab.lines as Line[])
|
||||
.map((line) => ({ productName: line.productName, quantity: lineQuantity(line), unitPriceInCents: line.unitPriceInCents }))
|
||||
.filter((line) => line.quantity > 0);
|
||||
|
||||
return {
|
||||
tabId: tab.id,
|
||||
customerName: tab.customerName,
|
||||
version: tab.version,
|
||||
totalInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0),
|
||||
itemCount: lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||
items: lines,
|
||||
updatedAt: tab.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
import { VenueLedger } from './venue-ledger';
|
||||
import { SettledLine } from './tab-state';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/** Most settlements sent per sync; a longer feed continues in the next sync. */
|
||||
export const MAX_SETTLEMENTS_PER_SYNC = 500;
|
||||
|
||||
/** What a terminal without a cursor gets: enough to fill a new terminal's history. */
|
||||
const FIRST_FEED_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Opens a tab for every planned customer that has none yet, and marks them delivered. Returns how many were
|
||||
* delivered.
|
||||
*/
|
||||
export async function openPlannedTabs(tx: Tx, ledger: VenueLedger, now: Date): Promise<number> {
|
||||
const planned = await tx.plannedTab.findMany({
|
||||
where: { venueId: ledger.venueId, deliveredAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
select: { id: true, customerName: true, customerNameKey: true },
|
||||
});
|
||||
if (planned.length === 0) return 0;
|
||||
|
||||
const open = new Set(ledger.tabs.all().map((tab) => customerNameKey(tab.customerName)));
|
||||
for (const customer of planned) {
|
||||
if (open.has(customer.customerNameKey)) continue;
|
||||
ledger.tabs.open(uuidv4(), customer.customerName, now);
|
||||
open.add(customer.customerNameKey);
|
||||
}
|
||||
|
||||
const { count } = await tx.plannedTab.updateMany({
|
||||
where: { id: { in: planned.map((p) => p.id) } },
|
||||
data: { deliveredAt: now },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* The venue's settlements after [cursor] (oldest first), or of the last days when there is none. The cursor is the
|
||||
* last sequence number sent. Settlements are written while the venue is locked, so sequence numbers of one venue
|
||||
* become visible in order and the feed never skips one.
|
||||
*/
|
||||
export async function settlementsAfter(tx: Tx, venueId: string, cursor: string | undefined, now: Date) {
|
||||
const after = cursor && /^\d+$/.test(cursor) ? Number(cursor) : null;
|
||||
const rows = await tx.settlement.findMany({
|
||||
where: after !== null
|
||||
? { venueId, sequence: { gt: after } }
|
||||
: { venueId, settledAt: { gte: new Date(now.getTime() - FIRST_FEED_DAYS * 24 * 60 * 60 * 1000) } },
|
||||
orderBy: { sequence: 'asc' },
|
||||
take: MAX_SETTLEMENTS_PER_SYNC,
|
||||
});
|
||||
|
||||
let next = rows.at(-1)?.sequence ?? after;
|
||||
if (next === null) {
|
||||
// Nothing yet: start after the venue's latest settlement, so the next sync only asks for new ones
|
||||
const latest = await tx.settlement.aggregate({ where: { venueId }, _max: { sequence: true } });
|
||||
next = latest._max.sequence ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
settlements: rows.map((row) => ({
|
||||
id: row.id,
|
||||
tabId: row.tabId,
|
||||
customerName: row.customerName,
|
||||
settledAt: row.settledAt.toISOString(),
|
||||
paymentMethod: row.paymentMethod,
|
||||
amountInCents: row.amountInCents,
|
||||
lines: row.lines as unknown as SettledLine[],
|
||||
})),
|
||||
settlementsCursor: String(next),
|
||||
};
|
||||
}
|
||||
@@ -1,27 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
import { tabSummary } from './tab-state';
|
||||
|
||||
@Injectable()
|
||||
export class TabsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** The venue's open tabs as the server holds them; every terminal shows the same. */
|
||||
async findAll(venueId: string) {
|
||||
const ownerships = await this.prisma.tabOwnership.findMany({
|
||||
where: { venueId },
|
||||
include: {
|
||||
device: {
|
||||
select: { id: true, name: true, lastSeenAt: true, revokedAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({
|
||||
where: {
|
||||
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
|
||||
},
|
||||
});
|
||||
const snapshotByKey = new Map(snapshots.map((s) => [`${s.deviceId}:${s.tabId}`, s]));
|
||||
const tabs = await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } });
|
||||
|
||||
// Customer details (email for reminders) live on the planned customer with the same name
|
||||
const planned = await this.prisma.plannedTab.findMany({
|
||||
@@ -30,31 +18,16 @@ export class TabsService {
|
||||
});
|
||||
const plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p]));
|
||||
|
||||
const tabs = [];
|
||||
for (const ownership of ownerships) {
|
||||
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
|
||||
|
||||
if (snapshot) {
|
||||
const plannedTab = plannedByKey.get(customerNameKey(snapshot.customerName));
|
||||
tabs.push({
|
||||
tabId: snapshot.tabId,
|
||||
customerName: snapshot.customerName,
|
||||
version: snapshot.version,
|
||||
totalInCents: snapshot.totalInCents,
|
||||
itemCount: snapshot.itemCount,
|
||||
// null: the terminal's app version doesn't report lines yet
|
||||
items: snapshot.items ?? null,
|
||||
deviceId: ownership.device.id,
|
||||
deviceName: ownership.device.name,
|
||||
deviceLastSeenAt: ownership.device.lastSeenAt,
|
||||
deviceRevokedAt: ownership.device.revokedAt,
|
||||
lastReportedAt: snapshot.reportedAt,
|
||||
return {
|
||||
tabs: tabs.map(tabSummary).map(({ updatedAt, ...tab }) => {
|
||||
const plannedTab = plannedByKey.get(customerNameKey(tab.customerName));
|
||||
return {
|
||||
...tab,
|
||||
lastChangedAt: updatedAt,
|
||||
plannedTabId: plannedTab?.id ?? null,
|
||||
email: plannedTab?.email ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs };
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Line, SettlementDraft, TabState, VenueTabs } from './tab-state';
|
||||
import { ProductStock, VenueStock } from '../stock/stock-rules';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
export type RecordedSettlement = SettlementDraft & { deviceId?: string | null; requestedBy?: string | null };
|
||||
|
||||
/**
|
||||
* A venue's open tabs, stock and new settlements inside one transaction. Opening it locks the venue, so terminals
|
||||
* syncing at the same time and the dashboard apply their changes one after the other instead of overwriting each
|
||||
* other. Everything is loaded at once: a venue has tens of open tabs and products, not thousands.
|
||||
*/
|
||||
export class VenueLedger {
|
||||
readonly settlements: RecordedSettlement[] = [];
|
||||
private seededNow = false;
|
||||
|
||||
private constructor(
|
||||
readonly venueId: string,
|
||||
readonly tabs: VenueTabs,
|
||||
readonly stock: VenueStock,
|
||||
/** False until the server took over the terminal's open tabs. */
|
||||
readonly seeded: boolean,
|
||||
) {}
|
||||
|
||||
static async open(tx: Tx, venueId: string): Promise<VenueLedger> {
|
||||
const [venue] = await tx.$queryRaw<{ tabsSeededAt: Date | null }[]>`
|
||||
SELECT "tabsSeededAt" FROM "Venue" WHERE "id" = ${venueId} FOR UPDATE`;
|
||||
|
||||
const [tabs, products] = await Promise.all([
|
||||
tx.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }),
|
||||
tx.product.findMany({ where: { venueId }, select: { productId: true, stock: true, countedAt: true } }),
|
||||
]);
|
||||
|
||||
return new VenueLedger(
|
||||
venueId,
|
||||
new VenueTabs(tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
customerName: tab.customerName,
|
||||
openedAt: tab.openedAt,
|
||||
version: tab.version,
|
||||
lines: tab.lines as unknown as Line[],
|
||||
}))),
|
||||
new VenueStock(new Map(products.map((p): [string, ProductStock] => (
|
||||
[p.productId, { stock: p.stock, countedAt: p.countedAt }])))),
|
||||
venue?.tabsSeededAt != null,
|
||||
);
|
||||
}
|
||||
|
||||
/** Writes what changed. Call once, at the end of the transaction. */
|
||||
async save(tx: Tx, now: Date) {
|
||||
const { venueId } = this;
|
||||
|
||||
if (this.tabs.deleted.size > 0) {
|
||||
await tx.tab.deleteMany({ where: { venueId, id: { in: [...this.tabs.deleted] } } });
|
||||
}
|
||||
for (const tabId of this.tabs.changed) {
|
||||
const tab = this.tabs.get(tabId)!;
|
||||
const data = {
|
||||
customerName: tab.customerName,
|
||||
openedAt: tab.openedAt,
|
||||
version: tab.version,
|
||||
lines: tab.lines as unknown as Prisma.InputJsonValue,
|
||||
};
|
||||
await tx.tab.upsert({
|
||||
where: { venueId_id: { venueId, id: tabId } },
|
||||
create: { venueId, id: tabId, ...data },
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
|
||||
for (const productId of this.stock.changed) {
|
||||
const { stock, countedAt } = this.stock.get(productId)!;
|
||||
await tx.product.update({
|
||||
where: { venueId_productId: { venueId, productId } },
|
||||
data: { stock, countedAt },
|
||||
});
|
||||
}
|
||||
|
||||
if (this.settlements.length > 0) {
|
||||
await tx.settlement.createMany({
|
||||
data: this.settlements.map((s) => ({
|
||||
id: s.id,
|
||||
venueId,
|
||||
tabId: s.tabId,
|
||||
customerName: s.customerName,
|
||||
settledAt: s.settledAt,
|
||||
paymentMethod: s.paymentMethod,
|
||||
amountInCents: s.amountInCents,
|
||||
lines: s.lines as unknown as Prisma.InputJsonValue,
|
||||
deviceId: s.deviceId ?? null,
|
||||
requestedBy: s.requestedBy ?? null,
|
||||
commandId: s.commandId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.seededNow) {
|
||||
await tx.venue.update({ where: { id: venueId }, data: { tabsSeededAt: now } });
|
||||
}
|
||||
}
|
||||
|
||||
get changedTabs(): boolean {
|
||||
return this.tabs.changed.size > 0 || this.tabs.deleted.size > 0;
|
||||
}
|
||||
|
||||
/** Takes over a terminal's own open tabs, once. */
|
||||
seed(tabs: TabState[]) {
|
||||
this.seededNow = true;
|
||||
for (const tab of tabs) {
|
||||
if (this.tabs.get(tab.id)) continue;
|
||||
const opened = this.tabs.open(tab.id, tab.customerName, tab.openedAt);
|
||||
opened.version = tab.version;
|
||||
opened.lines = tab.lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user