@@ -153,7 +153,10 @@ export class AdminService {
|
||||
if (copies.length > 0) {
|
||||
await tx.product.createMany({
|
||||
data: copies.map((p) => {
|
||||
const fields = { name: p.name, category: p.category, priceInCents: p.priceInCents, lowStockThreshold: p.lowStockThreshold };
|
||||
const fields = {
|
||||
name: p.name, category: p.category, priceInCents: p.priceInCents, buyPriceInCents: p.buyPriceInCents,
|
||||
lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock,
|
||||
};
|
||||
return {
|
||||
venueId,
|
||||
productId: uuidv4(),
|
||||
|
||||
@@ -7,8 +7,12 @@ import { StockService } from '../stock/stock.service';
|
||||
type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date };
|
||||
type SyncResult = Awaited<ReturnType<DevicesService['sync']>>;
|
||||
|
||||
// In the past: a time ahead of the server clock would be capped
|
||||
const at = (minutes: number) => new Date(Date.UTC(2026, 8, 24, 20, minutes)).toISOString();
|
||||
// Yesterday at 20:00 UTC. In the past, as a time ahead of the server clock would be capped, and recent, as a new
|
||||
// tablet's first settlements feed only goes back a few days
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const at = (minutes: number) => new Date(Date.UTC(
|
||||
yesterday.getUTCFullYear(), yesterday.getUTCMonth(), yesterday.getUTCDate(), 20, minutes,
|
||||
)).toISOString();
|
||||
|
||||
function setup({ seeded = true } = {}) {
|
||||
const prisma = createFakePrisma();
|
||||
@@ -509,6 +513,16 @@ describe('DevicesService.sync: website product changes reach the tablets', () =>
|
||||
expect(result.stock).toEqual([expect.objectContaining({ productId: created.productId, stock: 24 })]);
|
||||
});
|
||||
|
||||
it('never sends the buy price or desired stock to the tablets', async () => {
|
||||
const { sync, tabletA, stock } = setupWithWebsite();
|
||||
|
||||
await stock.createProduct('v1', { name: 'Ice Tea', priceInCents: 280, buyPriceInCents: 110, stock: 4, desiredStock: 24 }, admin);
|
||||
const result = await sync(tabletA);
|
||||
|
||||
expect(result.catalog![0]).not.toHaveProperty('buyPriceInCents');
|
||||
expect(result.catalog![0]).not.toHaveProperty('desiredStock');
|
||||
});
|
||||
|
||||
it('marks an archived product, and still counts a sale from a tablet that had not synced yet', async () => {
|
||||
const { sync, tabletA, stock, prisma } = setupWithWebsite();
|
||||
addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') });
|
||||
|
||||
@@ -21,12 +21,20 @@ export class CreateProductDto {
|
||||
@MaxLength(100)
|
||||
category?: string | null;
|
||||
|
||||
@ApiProperty({ example: 250 })
|
||||
@ApiProperty({ example: 250, description: 'The sell price, charged on the terminals' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1_000_000)
|
||||
priceInCents: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 120, nullable: true, description: 'What the bar pays for one; null for not entered. Not sent to the terminals' })
|
||||
@IsOptional()
|
||||
@ValidateIf((dto: CreateProductDto) => dto.buyPriceInCents !== null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1_000_000)
|
||||
buyPriceInCents?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 24, nullable: true, description: 'The starting count; null or left out: stock is not tracked' })
|
||||
@IsOptional()
|
||||
@ValidateIf((dto: CreateProductDto) => dto.stock !== null)
|
||||
@@ -42,6 +50,14 @@ export class CreateProductDto {
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
lowStockThreshold?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 100, nullable: true, description: 'How many to have in stock; null for none. Not sent to the terminals' })
|
||||
@IsOptional()
|
||||
@ValidateIf((dto: CreateProductDto) => dto.desiredStock !== null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
desiredStock?: number | null;
|
||||
}
|
||||
|
||||
/** Only the fields that are sent change. The stock itself changes through stock adjustments. */
|
||||
@@ -69,6 +85,14 @@ export class UpdateProductDto {
|
||||
@Max(1_000_000)
|
||||
priceInCents?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 120, nullable: true, description: 'What the bar pays for one; null for not entered. Not sent to the terminals' })
|
||||
@IsOptional()
|
||||
@ValidateIf((dto: UpdateProductDto) => dto.buyPriceInCents !== null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1_000_000)
|
||||
buyPriceInCents?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' })
|
||||
@IsOptional()
|
||||
@ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null)
|
||||
@@ -77,6 +101,14 @@ export class UpdateProductDto {
|
||||
@Max(100000)
|
||||
lowStockThreshold?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 100, nullable: true, description: 'How many to have in stock; null for none. Not sent to the terminals' })
|
||||
@IsOptional()
|
||||
@ValidateIf((dto: UpdateProductDto) => dto.desiredStock !== null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
desiredStock?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'true hides the product on the terminals (deleting it); false brings it back' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/** The fields of a product that the dashboard and the terminals change; stock changes through its own rules. */
|
||||
export const PRODUCT_FIELDS = ['name', 'category', 'priceInCents', 'lowStockThreshold', 'archived', 'imageId'] as const;
|
||||
export const PRODUCT_FIELDS = ['name', 'category', 'priceInCents', 'buyPriceInCents', 'lowStockThreshold', 'desiredStock', 'archived', 'imageId'] as const;
|
||||
export type ProductField = (typeof PRODUCT_FIELDS)[number];
|
||||
|
||||
export type ProductChanges = {
|
||||
name?: string;
|
||||
category?: string | null;
|
||||
priceInCents?: number;
|
||||
buyPriceInCents?: number | null;
|
||||
lowStockThreshold?: number | null;
|
||||
desiredStock?: number | null;
|
||||
archived?: boolean;
|
||||
imageId?: string | null;
|
||||
};
|
||||
|
||||
@@ -145,6 +145,32 @@ describe('StockService products', () => {
|
||||
expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola', priceInCents: 300, category: null, stock: 10, archivedAt: null });
|
||||
});
|
||||
|
||||
it('keeps a buy price next to the sell price, and clears it with null', async () => {
|
||||
const { service, prisma } = createWithVenue([]);
|
||||
|
||||
const product = await service.createProduct('v1', { name: 'Water', priceInCents: 200, buyPriceInCents: 80 }, admin);
|
||||
expect(product).toMatchObject({ priceInCents: 200, buyPriceInCents: 80 });
|
||||
|
||||
await service.updateProduct('v1', product.productId, { buyPriceInCents: 90 }, admin);
|
||||
expect(prisma.product.rows[0]).toMatchObject({ priceInCents: 200, buyPriceInCents: 90 });
|
||||
|
||||
const cleared = await service.updateProduct('v1', product.productId, { buyPriceInCents: null }, admin);
|
||||
expect(cleared.buyPriceInCents).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a desired stock, and clears it with null', async () => {
|
||||
const { service, prisma } = createWithVenue([]);
|
||||
|
||||
const product = await service.createProduct('v1', { name: 'Cola', priceInCents: 250, stock: 43, desiredStock: 100 }, admin);
|
||||
expect(product).toMatchObject({ stock: 43, desiredStock: 100 });
|
||||
|
||||
await service.updateProduct('v1', product.productId, { desiredStock: 120 }, admin);
|
||||
expect(prisma.product.rows[0]).toMatchObject({ stock: 43, desiredStock: 120 });
|
||||
|
||||
const cleared = await service.updateProduct('v1', product.productId, { desiredStock: null }, admin);
|
||||
expect(cleared.desiredStock).toBeNull();
|
||||
});
|
||||
|
||||
it('archives a product and brings it back, unless its name was taken meanwhile', async () => {
|
||||
const { service, prisma } = createWithVenue([cola]);
|
||||
|
||||
@@ -212,6 +238,6 @@ describe('StockService pictures and field times', () => {
|
||||
const created = await service.createProduct('v1', { name: 'Water', priceInCents: 200 }, admin);
|
||||
const times = prisma.product.rows.find((p) => p.productId === created.productId)!.fieldUpdatedAt;
|
||||
|
||||
expect(Object.keys(times).sort()).toEqual(['archived', 'category', 'lowStockThreshold', 'name', 'priceInCents']);
|
||||
expect(Object.keys(times).sort()).toEqual(['archived', 'buyPriceInCents', 'category', 'desiredStock', 'lowStockThreshold', 'name', 'priceInCents']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,7 +172,9 @@ export class StockService {
|
||||
name: dto.name,
|
||||
category: dto.category ?? null,
|
||||
priceInCents: dto.priceInCents,
|
||||
buyPriceInCents: dto.buyPriceInCents ?? null,
|
||||
lowStockThreshold: dto.lowStockThreshold ?? null,
|
||||
desiredStock: dto.desiredStock ?? null,
|
||||
};
|
||||
const created = await tx.product.create({
|
||||
data: {
|
||||
@@ -214,7 +216,7 @@ export class StockService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a product's name, category, price, warning level, or archives it (a deleted product stays, hidden, because
|
||||
* Changes a product's name, category, sell or buy price, warning level, desired stock, or archives it (a deleted product stays, hidden, because
|
||||
* tabs and history refer to it). Lines already on a tab keep the name and price they were rung up with.
|
||||
*/
|
||||
async updateProduct(venueId: string, productId: string, dto: UpdateProductDto, user: { id: string }) {
|
||||
@@ -238,7 +240,9 @@ export class StockService {
|
||||
...(dto.name != null ? { name: dto.name } : {}),
|
||||
...(dto.category !== undefined ? { category: dto.category } : {}),
|
||||
...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}),
|
||||
...(dto.buyPriceInCents !== undefined ? { buyPriceInCents: dto.buyPriceInCents } : {}),
|
||||
...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}),
|
||||
...(dto.desiredStock !== undefined ? { desiredStock: dto.desiredStock } : {}),
|
||||
...(dto.archived !== undefined ? { archived: dto.archived } : {}),
|
||||
}, maxDate(now, existing.fieldUpdatedAt), existing);
|
||||
|
||||
@@ -306,8 +310,8 @@ function maxDate(now: Date, times: unknown): Date {
|
||||
}
|
||||
|
||||
function productView(p: {
|
||||
productId: string; name: string; category: string | null; priceInCents: number; stock: number | null;
|
||||
lowStockThreshold: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null;
|
||||
productId: string; name: string; category: string | null; priceInCents: number; buyPriceInCents?: number | null; stock: number | null;
|
||||
lowStockThreshold: number | null; desiredStock?: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null;
|
||||
imageId?: string | null;
|
||||
}) {
|
||||
return {
|
||||
@@ -315,8 +319,10 @@ function productView(p: {
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
priceInCents: p.priceInCents,
|
||||
buyPriceInCents: p.buyPriceInCents ?? null,
|
||||
stock: p.stock,
|
||||
lowStockThreshold: p.lowStockThreshold,
|
||||
desiredStock: p.desiredStock ?? null,
|
||||
// The latest count, on the dashboard or a terminal: how fresh the stock is
|
||||
countedAt: p.countedAt,
|
||||
reportedAt: p.reportedAt,
|
||||
|
||||
@@ -19,6 +19,27 @@ export class TabsController {
|
||||
return this.tabsService.findAll(venueId);
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
@ApiOperation({ summary: 'List paid tabs, newest first' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Part of the customer name' })
|
||||
@ApiQuery({ name: 'paymentMethod', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
@ApiQuery({ name: 'offset', required: false })
|
||||
async history(
|
||||
@Param('venueId') venueId: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('paymentMethod') paymentMethod?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
return this.tabsService.history(venueId, {
|
||||
search,
|
||||
paymentMethod,
|
||||
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
|
||||
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':tabId')
|
||||
@Roles('admin', 'manager')
|
||||
@ApiOperation({ summary: 'Delete an open tab without settling it' })
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { TabsService } from './tabs.service';
|
||||
import { createFakePrisma } from '../testing/fake-prisma';
|
||||
|
||||
const at = (minutes: number) => new Date(Date.UTC(2026, 9, 27, 20, minutes));
|
||||
|
||||
function setup() {
|
||||
const prisma = createFakePrisma();
|
||||
prisma.device.rows.push({ id: 'd1', venueId: 'v1', name: 'Bar tablet' });
|
||||
const service = new TabsService(prisma as any, { notify: jest.fn() } as any);
|
||||
|
||||
const settle = (id: string, customerName: string, minutes: number, extra: Record<string, unknown> = {}) =>
|
||||
prisma.settlement.create({
|
||||
data: {
|
||||
id, venueId: 'v1', tabId: `tab-${id}`, customerName, settledAt: at(minutes), paymentMethod: 'cash',
|
||||
amountInCents: 500, deviceId: 'd1', requestedBy: null,
|
||||
lines: [{
|
||||
id: 'l1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, quantity: 2,
|
||||
units: [
|
||||
{ id: 'u1', addedAt: at(0).toISOString(), removedAt: null },
|
||||
{ id: 'u2', addedAt: at(0).toISOString(), removedAt: null },
|
||||
{ id: 'u3', addedAt: at(0).toISOString(), removedAt: at(1).toISOString() },
|
||||
],
|
||||
}],
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
|
||||
return { prisma, service, settle };
|
||||
}
|
||||
|
||||
describe('TabsService.history', () => {
|
||||
it('lists paid tabs newest first, with their lines and who settled them', async () => {
|
||||
const { service, settle } = setup();
|
||||
await settle('s1', 'Alice', 1);
|
||||
await settle('s2', 'Bob', 5, { deviceId: null, requestedBy: 'manager@bar.be', paymentMethod: 'payconiq' });
|
||||
await settle('other', 'Eve', 9, { venueId: 'v2' });
|
||||
|
||||
const result = await service.history('v1', { limit: 50, offset: 0 });
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.totalAmountInCents).toBe(1000);
|
||||
expect(result.settlements.map((s) => s.id)).toEqual(['s2', 's1']);
|
||||
expect(result.settlements[0]).toMatchObject({ settledBy: 'manager@bar.be', onDashboard: true, paymentMethod: 'payconiq' });
|
||||
expect(result.settlements[1]).toMatchObject({ settledBy: 'Bar tablet', onDashboard: false, itemCount: 2 });
|
||||
expect(result.settlements[1].items).toEqual([{ productName: 'Cola', quantity: 2, unitPriceInCents: 250, removed: 1 }]);
|
||||
});
|
||||
|
||||
it('filters by part of the name and payment method, and pages', async () => {
|
||||
const { service, settle } = setup();
|
||||
await settle('s1', 'Alice', 1);
|
||||
await settle('s2', 'alicia', 2, { paymentMethod: 'payconiq' });
|
||||
await settle('s3', 'Bob', 3);
|
||||
|
||||
const byName = await service.history('v1', { search: ' ALI ', limit: 50, offset: 0 });
|
||||
expect(byName.settlements.map((s) => s.id)).toEqual(['s2', 's1']);
|
||||
|
||||
const byMethod = await service.history('v1', { search: 'ali', paymentMethod: 'cash', limit: 50, offset: 0 });
|
||||
expect(byMethod.settlements.map((s) => s.id)).toEqual(['s1']);
|
||||
|
||||
const secondPage = await service.history('v1', { limit: 2, offset: 2 });
|
||||
expect(secondPage.total).toBe(3);
|
||||
expect(secondPage.settlements.map((s) => s.id)).toEqual(['s1']);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { tabSummary } from './tab-state';
|
||||
import { SettledLine, tabSummary } from './tab-state';
|
||||
import { VenueLedger } from './venue-ledger';
|
||||
|
||||
@Injectable()
|
||||
@@ -37,6 +38,65 @@ export class TabsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 items = (row.lines as unknown as SettledLine[]).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,
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* An in-memory stand-in for the Prisma client, for tests that follow a venue through several syncs. It supports the
|
||||
* queries the tab, stock and device services use: equality, `in`, `notIn`, `not`, `gt`, `gte`, `lte` and compound
|
||||
* unique keys, `orderBy` on one or more fields, `take` and `select`. Transactions run directly on the same data.
|
||||
* queries the tab, stock and device services use: equality, `in`, `notIn`, `not`, `gt`, `gte`, `lte`, `contains` and
|
||||
* compound unique keys, `orderBy` on one or more fields, `skip`, `take`, `select`, `count` and `_max`/`_sum`. Transactions run directly on the same data.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
@@ -28,6 +28,10 @@ function matches(row: Row, where: Row = {}): boolean {
|
||||
case 'gt': return value !== null && value > operand;
|
||||
case 'gte': return value !== null && value >= operand;
|
||||
case 'lte': return value !== null && value <= operand;
|
||||
case 'contains': return typeof value === 'string' && (condition.mode === 'insensitive'
|
||||
? value.toLowerCase().includes(operand.toLowerCase())
|
||||
: value.includes(operand));
|
||||
case 'mode': return true;
|
||||
default: throw new Error(`fake-prisma: unsupported operator ${op}`);
|
||||
}
|
||||
});
|
||||
@@ -88,7 +92,12 @@ class FakeTable {
|
||||
|
||||
async findMany(args: Row = {}) {
|
||||
const found = sort(this.rows.filter((row) => matches(row, args.where)), args.orderBy);
|
||||
return found.slice(0, args.take ?? found.length).map((row) => pick(row, args.select));
|
||||
const skip = args.skip ?? 0;
|
||||
return found.slice(skip, skip + (args.take ?? found.length)).map((row) => pick(row, args.select));
|
||||
}
|
||||
|
||||
async count(args: Row = {}) {
|
||||
return this.rows.filter((row) => matches(row, args.where)).length;
|
||||
}
|
||||
|
||||
async findUnique(args: Row) {
|
||||
@@ -148,7 +157,11 @@ class FakeTable {
|
||||
field,
|
||||
found.length === 0 ? null : Math.max(...found.map((row) => row[field])),
|
||||
]));
|
||||
return { _max: max };
|
||||
const sum = Object.fromEntries(Object.keys(args._sum ?? {}).map((field) => [
|
||||
field,
|
||||
found.length === 0 ? null : found.reduce((total, row) => total + row[field], 0),
|
||||
]));
|
||||
return { _max: max, _sum: sum };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user