Files
kooltab-backoffice/apps/api/src/tabs/tabs.service.ts
T
brammie15 ca3af671cf
CI / build-and-test (push) Canceled after 0s
feat: add better tab view
2026-10-28 00:37:16 +01:00

145 lines
5.4 KiB
TypeScript

import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
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()
export class TabsService {
constructor(
private prisma: PrismaService,
private realtime: RealtimeService,
) {}
/** The venue's open tabs as the server holds them; every terminal shows the same. */
async findAll(venueId: string) {
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({
where: { venueId },
select: { id: true, customerNameKey: true, email: true },
});
const plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p]));
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,
};
}),
};
}
/**
* The venue's paid tabs, newest first, as the terminals' history shows them: every settlement made on a terminal
* or on the dashboard. [search] matches the customer name.
*/
async history(
venueId: string,
options: { search?: string; paymentMethod?: string; limit: number; offset: number },
) {
const search = options.search?.trim();
const where: Prisma.SettlementWhereInput = {
venueId,
...(search ? { customerName: { contains: search, mode: 'insensitive' as const } } : {}),
...(options.paymentMethod ? { paymentMethod: options.paymentMethod } : {}),
};
const [rows, total, sum] = await Promise.all([
this.prisma.settlement.findMany({
where,
orderBy: [{ settledAt: 'desc' }, { sequence: 'desc' }],
skip: options.offset,
take: options.limit,
}),
this.prisma.settlement.count({ where }),
this.prisma.settlement.aggregate({ where, _sum: { amountInCents: true } }),
]);
const deviceIds = [...new Set(rows.map((row) => row.deviceId).filter((id): id is string => id !== null))];
const devices = deviceIds.length > 0
? await this.prisma.device.findMany({ where: { id: { in: deviceIds } }, select: { id: true, name: true } })
: [];
const deviceNames = new Map(devices.map((device) => [device.id, device.name]));
return {
settlements: rows.map((row) => {
const lines = row.lines as unknown as SettledLine[];
const items = lines.map((line) => ({
productName: line.productName,
quantity: line.quantity,
unitPriceInCents: line.unitPriceInCents,
// Rung up and taken off again before paying
removed: line.units.filter((unit) => unit.removedAt !== null).length,
}));
return {
id: row.id,
tabId: row.tabId,
customerName: row.customerName,
settledAt: row.settledAt,
paymentMethod: row.paymentMethod,
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,
};
}),
total,
totalAmountInCents: sum._sum.amountInCents ?? 0,
};
}
/**
* Deletes a tab without settling it, as tab_deleted does on a terminal: what is still on it is not paid and stays
* out of the stock. The terminals drop it on their next sync; one that still has unsent drinks for it brings it
* back, so no sale is lost. [expectedVersion] refuses a tab that changed since it was shown.
*/
async remove(venueId: string, tabId: string, expectedVersion: number | undefined, user: { id: string }) {
await this.prisma.$transaction(async (tx) => {
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
const tab = ledger.tabs.get(tabId);
if (!tab) {
throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
}
if (expectedVersion !== undefined && tab.version !== expectedVersion) {
throw new ConflictException({
code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.',
});
}
const { customerName, totalInCents, items } = tabSummary({ ...tab, updatedAt: now });
ledger.tabs.remove(tabId);
await ledger.save(tx, now);
await tx.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType: 'client_user',
actorId: user.id,
action: 'tab_deleted',
details: JSON.stringify({ tabId, customerName, amountInCents: totalInCents, items }),
},
});
});
this.realtime.notify(venueId, 'tabs');
return { ok: true };
}
}