@@ -1,12 +1,12 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { DevicesService } from './devices.service';
|
||||
|
||||
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number };
|
||||
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown };
|
||||
|
||||
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
|
||||
const tx = {
|
||||
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn() },
|
||||
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn() },
|
||||
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
|
||||
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
|
||||
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
|
||||
device: { update: jest.fn() },
|
||||
};
|
||||
@@ -27,13 +27,13 @@ const device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date(
|
||||
const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 });
|
||||
|
||||
describe('DevicesService.sync planned customers', () => {
|
||||
it('marks planned customers delivered once the device reports a tab with their name', async () => {
|
||||
it('marks planned customers delivered once the device reports a tab with their name, ignoring case', async () => {
|
||||
const { service, tx } = createService();
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', 'Piet')] });
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', ' piet Peeters')] });
|
||||
|
||||
expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({
|
||||
where: { venueId: 'v1', deliveredAt: null, customerName: { in: ['Jan', 'Piet'] } },
|
||||
where: { venueId: 'v1', deliveredAt: null, customerNameKey: { in: ['jan', 'piet peeters'] } },
|
||||
data: { deliveredAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
@@ -125,3 +125,32 @@ describe('DevicesService.sync tab lines', () => {
|
||||
expect(tx.openTabSnapshot.upsert.mock.calls[0][0].update.items).toBe(Prisma.JsonNull);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync writes', () => {
|
||||
const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }];
|
||||
const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items };
|
||||
// JSONB doesn't keep key order
|
||||
const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items: [{ quantity: 10, productName: 'Cola', unitPriceInCents: 250 }] };
|
||||
|
||||
it('only bumps the report time of unchanged tabs', async () => {
|
||||
const { service, tx } = createService({ previous: [janSnapshot] });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
|
||||
|
||||
expect(tx.openTabSnapshot.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.tabOwnership.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.openTabSnapshot.updateMany).toHaveBeenCalledWith({
|
||||
where: { deviceId: 'd1', tabId: { in: ['t1'] } },
|
||||
data: { reportedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a tab whose lines changed', async () => {
|
||||
const { service, tx } = createService({ previous: [janSnapshot] });
|
||||
|
||||
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, items: [{ ...items[0], quantity: 11 }] }] });
|
||||
|
||||
expect(tx.openTabSnapshot.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(tx.openTabSnapshot.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,8 @@ import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
|
||||
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { OpenTabDto, SyncDto } from './dto/sync.dto';
|
||||
import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
import { AckCommandDto } from './dto/ack-command.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -36,22 +37,28 @@ export class DevicesService {
|
||||
// Devices sync often, so dashboards are only told about what actually changed
|
||||
const previous = await this.prisma.openTabSnapshot.findMany({
|
||||
where: { deviceId: device.id },
|
||||
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true },
|
||||
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true, items: true },
|
||||
});
|
||||
const previousById = new Map(previous.map((s) => [s.tabId, s]));
|
||||
const tabsChanged =
|
||||
previous.length !== openTabs.length ||
|
||||
openTabs.some((t) => {
|
||||
const p = previousById.get(t.id);
|
||||
return !p || p.customerName !== t.customerName || p.version !== t.version
|
||||
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount;
|
||||
});
|
||||
let plannedDelivered = 0;
|
||||
|
||||
// Ownership changes are detected up front so audit logging stays outside the transaction
|
||||
const takenOver = await this.prisma.tabOwnership.findMany({
|
||||
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
|
||||
});
|
||||
const takenOverIds = new Set(takenOver.map((o) => o.tabId));
|
||||
|
||||
// Only new or changed tabs are written; unchanged ones just get their report time bumped
|
||||
const changedTabs = openTabs.filter((t) => {
|
||||
const p = previousById.get(t.id);
|
||||
return !p || takenOverIds.has(t.id) || p.customerName !== t.customerName || p.version !== t.version
|
||||
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount
|
||||
|| !sameItems(p.items as StoredItem[] | null, t.items);
|
||||
});
|
||||
const changedIds = new Set(changedTabs.map((t) => t.id));
|
||||
const unchangedIds = openTabIds.filter((id) => !changedIds.has(id));
|
||||
const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
|
||||
let plannedDelivered = 0;
|
||||
|
||||
for (const ownership of takenOver) {
|
||||
await this.auditService.log(
|
||||
device.venueId,
|
||||
@@ -71,7 +78,18 @@ export class DevicesService {
|
||||
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
|
||||
});
|
||||
|
||||
for (const tab of openTabs) {
|
||||
if (unchangedIds.length > 0) {
|
||||
await tx.openTabSnapshot.updateMany({
|
||||
where: { deviceId: device.id, tabId: { in: unchangedIds } },
|
||||
data: { reportedAt: now },
|
||||
});
|
||||
await tx.tabOwnership.updateMany({
|
||||
where: { venueId: device.venueId, deviceId: device.id, tabId: { in: unchangedIds } },
|
||||
data: { lastReportedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
for (const tab of changedTabs) {
|
||||
await tx.openTabSnapshot.upsert({
|
||||
where: {
|
||||
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
|
||||
@@ -111,14 +129,12 @@ export class DevicesService {
|
||||
});
|
||||
}
|
||||
|
||||
// A planned customer counts as delivered once the device reports a tab with that exact name
|
||||
// (the terminal matches names exactly when creating tabs)
|
||||
if (openTabs.length > 0) {
|
||||
({ count: plannedDelivered } = await tx.plannedTab.updateMany({
|
||||
where: {
|
||||
venueId: device.venueId,
|
||||
deliveredAt: null,
|
||||
customerName: { in: openTabs.map((t) => t.customerName) },
|
||||
customerNameKey: { in: openTabs.map((t) => customerNameKey(t.customerName)) },
|
||||
},
|
||||
data: { deliveredAt: now },
|
||||
}));
|
||||
@@ -279,8 +295,21 @@ export class DevicesService {
|
||||
}
|
||||
}
|
||||
|
||||
type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
|
||||
|
||||
function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
|
||||
if (!stored || !reported) return !stored && !reported;
|
||||
return stored.length === reported.length && stored.every((s, i) =>
|
||||
s.productName === reported[i].productName
|
||||
&& s.quantity === reported[i].quantity
|
||||
&& s.unitPriceInCents === reported[i].unitPriceInCents);
|
||||
}
|
||||
|
||||
function storedItems(items: OpenTabItemDto[]): StoredItem[] {
|
||||
return items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
|
||||
}
|
||||
|
||||
/** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */
|
||||
function snapshotItems(tab: OpenTabDto) {
|
||||
if (!tab.items) return Prisma.JsonNull;
|
||||
return tab.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
|
||||
return tab.items ? storedItems(tab.items) : Prisma.JsonNull;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user