feat: make products server managed
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 23:55:40 +02:00
parent 0ca9f4d16a
commit e7f59f7583
14 changed files with 697 additions and 181 deletions
+58 -19
View File
@@ -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');
});
+22 -5
View File
@@ -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({