Skip to content

Commit

Permalink
Merge pull request #528 from Pinelab-studio/fix/metrics-sales
Browse files Browse the repository at this point in the history
feat(metrics): Updates, fixes and Revenue metric
  • Loading branch information
martijnvdbrug authored Oct 31, 2024
2 parents 812067a + f36afc2 commit 9b41784
Show file tree
Hide file tree
Showing 14 changed files with 39,066 additions and 1,940 deletions.
36,671 changes: 36,671 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions packages/vendure-plugin-metrics/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# 1.4.0 (2024-10-31)

- Added revenue (per variant) metric
- Calculate with or without tax based on channel settings
- Use number instead of currency formatting for Units Sold metric
- Added a max cache age of 12 hours for metrics

# 1.3.2 (2024-10-29)

- Fix typescript build errors (#525)
Expand Down
32 changes: 24 additions & 8 deletions packages/vendure-plugin-metrics/src/api/cache.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
interface EntryDate {
createdAt: Date;
}

/**
* Very basic in memory cache for storing metric results
*/
export class Cache<T> {
private cache = new Map<string, T>();
private key(key: any): string {
return JSON.stringify(key);
}
// Default max age is 12 hours
constructor(private maxAgeInSeconds: number = 60 * 60 * 12) {}

private cache = new Map<string, T & EntryDate>();

set(key: any, value: T): void {
this.cache.set(this.key(key), value);
set(key: string, value: T): void {
this.cache.set(key, {
...value,
createdAt: new Date(),
});
}
get(key: any): T | undefined {
return this.cache.get(this.key(key));
get(key: string): T | undefined {
const res = this.cache.get(key);
if (!res) {
return undefined;
}
const now = new Date();
if (now.getTime() - res.createdAt.getTime() > this.maxAgeInSeconds * 1000) {
this.cache.delete(key);
return undefined;
}
return res;
}
}
4 changes: 2 additions & 2 deletions packages/vendure-plugin-metrics/src/api/metrics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ export class MetricsService {
// For each metric strategy
return Promise.all(
this.metricStrategies.map(async (metricStrategy) => {
const cacheKey = {
const cacheKey = JSON.stringify({
code: metricStrategy.code,
from: today,
to: oneYearAgo,
channel: ctx.channel.token,
variantIds: input?.variantIds?.sort() ?? [],
};
});
// Return cached result if exists
const cachedMetricSummary = this.cache.get(cacheKey);
if (cachedMetricSummary) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {
AdvancedMetricType,
} from '../../ui/generated/graphql';
import { MetricStrategy, NamedDatapoint } from '../metric-strategy';
import { loggerCtx } from '../../constants';

const loggerCtx = 'AverageOrderValueMetric';
/**
* Calculates the average order value per month
*/
Expand All @@ -21,7 +21,7 @@ export class AverageOrderValueMetric implements MetricStrategy<Order> {
readonly code = 'aov';

getTitle(ctx: RequestContext): string {
return `Average order value in ${ctx.channel.defaultCurrencyCode}`;
return `Average order value`;
}

getSortableField(entity: Order): Date {
Expand Down Expand Up @@ -54,8 +54,8 @@ export class AverageOrderValueMetric implements MetricStrategy<Order> {
.andWhere(`order.orderPlacedAt <= :to`, {
to: to.toISOString(),
})
.skip(skip)
.take(take);
.offset(skip)
.limit(take);

if (variants.length) {
query = query.andWhere(`productVariant.id IN(:...variantIds)`, {
Expand All @@ -65,9 +65,9 @@ export class AverageOrderValueMetric implements MetricStrategy<Order> {
const [items, totalOrders] = await query.getManyAndCount();
orders.push(...items);
Logger.info(
`Fetched orders ${skip}-${skip + take} for channel ${
ctx.channel.token
}`,
`Fetched orders ${skip}-${skip + take} for metric ${
this.code
} for channel${ctx.channel.token}`,
loggerCtx
);
skip += items.length;
Expand All @@ -83,9 +83,16 @@ export class AverageOrderValueMetric implements MetricStrategy<Order> {
entities: Order[],
variants: ProductVariant[]
): NamedDatapoint[] {
const legendLabel = variants.length
? `Orders with ${variants.map((v) => v.name).join(', ')}`
let legendLabel = variants.length
? `Average order value of orders with ${variants
.map((v) => v.name)
.join(', ')}`
: 'Average order value';
if (ctx.channel.pricesIncludeTax) {
legendLabel += ' (incl. tax)';
} else {
legendLabel += ' (excl. tax)';
}
if (!entities.length) {
// Return 0 as average if no orders
return [
Expand All @@ -95,8 +102,11 @@ export class AverageOrderValueMetric implements MetricStrategy<Order> {
},
];
}
const totalFieldName = ctx.channel.pricesIncludeTax
? 'totalWithTax'
: 'total';
const total = entities
.map((o) => o.totalWithTax)
.map((o) => o[totalFieldName])
.reduce((total, current) => total + current, 0);
const average = Math.round(total / entities.length) / 100;
return [
Expand Down
133 changes: 133 additions & 0 deletions packages/vendure-plugin-metrics/src/api/metrics/revenue-per-product.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
Injector,
Logger,
OrderLine,
ProductVariant,
RequestContext,
TransactionalConnection,
} from '@vendure/core';
import { AdvancedMetricType } from '../../ui/generated/graphql';
import { MetricStrategy, NamedDatapoint } from '../metric-strategy';
import { loggerCtx } from '../../constants';

/**
* Calculates the revenue generated by each product variant.
* calculates the total revenue if no variants are provided.
*/
export class RevenuePerProduct implements MetricStrategy<OrderLine> {
readonly metricType: AdvancedMetricType = AdvancedMetricType.Currency;
readonly code = 'revenue-per-product';

getTitle(ctx: RequestContext): string {
return `Revenue`;
}

getSortableField(entity: OrderLine): Date {
return entity.order.orderPlacedAt ?? entity.order.updatedAt;
}

async loadEntities(
ctx: RequestContext,
injector: Injector,
from: Date,
to: Date,
variants: ProductVariant[]
): Promise<OrderLine[]> {
let skip = 0;
const take = 1000;
let hasMoreOrderLines = true;
const lines: OrderLine[] = [];
while (hasMoreOrderLines) {
let query = injector
.get(TransactionalConnection)
.getRepository(ctx, OrderLine)
.createQueryBuilder('orderLine')
.leftJoin('orderLine.productVariant', 'productVariant')
.addSelect(['productVariant.sku', 'productVariant.id'])
.leftJoinAndSelect('orderLine.order', 'order')
.leftJoin('order.channels', 'channel')
.where(`channel.id=:channelId`, { channelId: ctx.channelId })
.andWhere(`order.orderPlacedAt >= :from`, {
from: from.toISOString(),
})
.andWhere(`order.orderPlacedAt <= :to`, {
to: to.toISOString(),
})
.offset(skip)
.limit(take);
if (variants.length) {
query = query.andWhere(`productVariant.id IN(:...variantIds)`, {
variantIds: variants.map((v) => v.id),
});
}
const [items, totalItems] = await query.getManyAndCount();
lines.push(...items);
Logger.info(
`Fetched order lines ${skip}-${skip + take} for metric ${
this.code
} for channel${ctx.channel.token}`,
loggerCtx
);
skip += items.length;
if (lines.length >= totalItems) {
hasMoreOrderLines = false;
}
}
return lines;
}

calculateDataPoints(
ctx: RequestContext,
lines: OrderLine[],
variants: ProductVariant[]
): NamedDatapoint[] {
if (!variants.length) {
// Return total revenue
let legendLabel = 'Total Revenue';
if (ctx.channel.pricesIncludeTax) {
legendLabel += ' (incl. tax)';
} else {
legendLabel += ' (excl. tax)';
}
const revenuePerOrder: { [orderId: string]: number } = {};
const totalFieldName = ctx.channel.pricesIncludeTax
? 'totalWithTax'
: 'total';
lines.forEach((line) => {
revenuePerOrder[line.order.id] = line.order[totalFieldName];
});
const totalRevenue = Object.values(revenuePerOrder).reduce(
(total, current) => total + current,
0
);
return [
{
legendLabel,
value: totalRevenue / 100,
},
];
}
// Else calculate revenue per variant
const dataPoints: NamedDatapoint[] = [];
const totalFieldName = ctx.channel.pricesIncludeTax
? 'proratedLinePriceWithTax'
: 'proratedLinePrice';
variants.forEach((variant) => {
// Find order lines per variant id
const linesForVariant = lines.filter(
(line) => line.productVariant.id === variant.id
);
// Sum of order lines with this variant

const revenue = linesForVariant.reduce(
(total, current) => total + current[totalFieldName],
0
);
dataPoints.push({
legendLabel: variant.name,
value: revenue / 100,
});
});
return dataPoints;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,20 @@ import {
RequestContext,
TransactionalConnection,
} from '@vendure/core';
import {
AdvancedMetricSummaryInput,
AdvancedMetricType,
} from '../../ui/generated/graphql';
import { AdvancedMetricType } from '../../ui/generated/graphql';
import { MetricStrategy, NamedDatapoint } from '../metric-strategy';

const loggerCtx = 'SalesPerProductMetric';
import { loggerCtx } from '../../constants';

/**
* Calculates the number of products sold per month.
* calculates the sum of all items in an order if no variantIds are provided
*/
export class SalesPerProductMetric implements MetricStrategy<OrderLine> {
readonly metricType: AdvancedMetricType = AdvancedMetricType.Currency;
readonly code = 'sales-per-product';
export class UnitsSoldMetric implements MetricStrategy<OrderLine> {
readonly metricType: AdvancedMetricType = AdvancedMetricType.Number;
readonly code = 'units-sold';

getTitle(ctx: RequestContext): string {
return `Sales per product`;
return `Units sold`;
}

getSortableField(entity: OrderLine): Date {
Expand Down Expand Up @@ -67,9 +63,9 @@ export class SalesPerProductMetric implements MetricStrategy<OrderLine> {
const [items, totalItems] = await query.getManyAndCount();
lines.push(...items);
Logger.info(
`Fetched orderLines ${skip}-${skip + take} for channel ${
ctx.channel.token
}`,
`Fetched order lines ${skip}-${skip + take} for metric ${
this.code
} for channel${ctx.channel.token}`,
loggerCtx
);
skip += items.length;
Expand Down
3 changes: 2 additions & 1 deletion packages/vendure-plugin-metrics/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export * from './api/metrics.service';
export * from './api/metrics.resolver';
export * from './ui/generated/graphql';
export * from './api/metrics/average-order-value';
export * from './api/metrics/sales-per-product';
export * from './api/metrics/revenue-per-product';
export * from './api/metrics/units-sold-metric';
9 changes: 7 additions & 2 deletions packages/vendure-plugin-metrics/src/metrics.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { MetricsResolver } from './api/metrics.resolver';
import { MetricsService } from './api/metrics.service';
import { MetricStrategy } from './api/metric-strategy';
import { PLUGIN_INIT_OPTIONS } from './constants';
import { SalesPerProductMetric } from './api/metrics/sales-per-product';
import { RevenuePerProduct } from './api/metrics/revenue-per-product';
import { AverageOrderValueMetric } from './api/metrics/average-order-value';
import { UnitsSoldMetric } from './api/metrics/units-sold-metric';

export interface MetricsPluginOptions {
metrics: MetricStrategy<any>[];
Expand All @@ -27,7 +28,11 @@ export interface MetricsPluginOptions {
})
export class MetricsPlugin {
static options: MetricsPluginOptions = {
metrics: [new SalesPerProductMetric(), new AverageOrderValueMetric()],
metrics: [
new RevenuePerProduct(),
new AverageOrderValueMetric(),
new UnitsSoldMetric(),
],
};

static init(options: MetricsPluginOptions): typeof MetricsPlugin {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
::ng-deep {
$ct-line-width: 2px !default;
$ct-area-opacity: 0.5 !default;
$ct-area-opacity: 0.2 !default;
$ct-series-colors: (
var(--color-primary-300),
var(--color-accent-300),
Expand Down
Loading

0 comments on commit 9b41784

Please sign in to comment.