feat(analytics): add GET /analytics/price-movers endpoint

Top tăng/giảm giá theo district cho Home dashboard.
Compares avg listing prices between current and previous time windows,
filters by min sample size (10), caches for 30 min.

TEC-3053

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 02:24:44 +07:00
parent a70db64da1
commit 0651074319
8 changed files with 337 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap
import { GetListingAiAdviceHandler } from './application/queries/get-listing-ai-advice/get-listing-ai-advice.handler';
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler';
import { GetPriceMoversHandler } from './application/queries/get-price-movers/get-price-movers.handler';
import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler';
import { GetNearbyPOIsHandler } from './application/queries/get-nearby-pois/get-nearby-pois.handler';
import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler';
@@ -63,6 +64,7 @@ const QueryHandlers = [
GetListingAiAdviceHandler,
GetProjectAiAdviceHandler,
GetMarketSnapshotHandler,
GetPriceMoversHandler,
];
const EventHandlers = [

View File

@@ -0,0 +1,107 @@
import { type CacheService, type LoggerService } from '@modules/shared';
import { GetPriceMoversHandler } from '../queries/get-price-movers/get-price-movers.handler';
import { GetPriceMoversQuery } from '../queries/get-price-movers/get-price-movers.query';
describe('GetPriceMoversHandler', () => {
let handler: GetPriceMoversHandler;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
let mockCache: Partial<CacheService>;
let mockLogger: Partial<LoggerService>;
beforeEach(() => {
mockPrisma = {
$queryRaw: vi.fn(),
};
mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as Partial<CacheService>;
mockLogger = { error: vi.fn(), warn: vi.fn(), log: vi.fn() } as unknown as Partial<LoggerService>;
handler = new GetPriceMoversHandler(
mockPrisma as any,
mockCache as CacheService,
mockLogger as LoggerService,
);
});
it('returns top price gainers sorted by changePct descending', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'Quận 1', current_avg: 5_000_000_000, previous_avg: 4_000_000_000, sample_size: BigInt(15) },
{ district: 'Quận 7', current_avg: 3_000_000_000, previous_avg: 2_500_000_000, sample_size: BigInt(20) },
{ district: 'Bình Thạnh', current_avg: 2_000_000_000, previous_avg: 2_200_000_000, sample_size: BigInt(12) },
]);
const query = new GetPriceMoversQuery('up', '7d', 5, 'district');
const result = await handler.execute(query);
expect(result.direction).toBe('up');
expect(result.period).toBe('7d');
expect(result.movers.length).toBe(2); // Only positive changes
// Quận 1: +25%, Quận 7: +20%
expect(result.movers[0].districtId).toBe('Quận 1');
expect(result.movers[0].changePct).toBe(25);
expect(result.movers[1].districtId).toBe('Quận 7');
expect(result.movers[1].changePct).toBe(20);
});
it('returns top price losers sorted by changePct ascending', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'Quận 1', current_avg: 5_000_000_000, previous_avg: 4_000_000_000, sample_size: BigInt(15) },
{ district: 'Bình Thạnh', current_avg: 2_000_000_000, previous_avg: 2_200_000_000, sample_size: BigInt(12) },
{ district: 'Thủ Đức', current_avg: 1_800_000_000, previous_avg: 2_100_000_000, sample_size: BigInt(18) },
]);
const query = new GetPriceMoversQuery('down', '7d', 5, 'district');
const result = await handler.execute(query);
expect(result.direction).toBe('down');
expect(result.movers.length).toBe(2); // Only negative changes
// Thủ Đức: -14.29%, Bình Thạnh: -9.09%
expect(result.movers[0].districtId).toBe('Thủ Đức');
expect(result.movers[1].districtId).toBe('Bình Thạnh');
expect(result.movers[0].changePct).toBeLessThan(result.movers[1].changePct);
});
it('respects the limit parameter', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'A', current_avg: 200, previous_avg: 100, sample_size: BigInt(10) },
{ district: 'B', current_avg: 180, previous_avg: 100, sample_size: BigInt(10) },
{ district: 'C', current_avg: 160, previous_avg: 100, sample_size: BigInt(10) },
]);
const query = new GetPriceMoversQuery('up', '7d', 2, 'district');
const result = await handler.execute(query);
expect(result.movers.length).toBe(2);
expect(result.limit).toBe(2);
});
it('returns empty movers when no data', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
const query = new GetPriceMoversQuery('up', '7d', 5, 'district');
const result = await handler.execute(query);
expect(result.movers).toEqual([]);
});
it('rounds changePct to two decimal places', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ district: 'Quận 1', current_avg: 3_333_333, previous_avg: 3_000_000, sample_size: BigInt(15) },
]);
const query = new GetPriceMoversQuery('up', '7d', 5, 'district');
const result = await handler.execute(query);
expect(result.movers[0].changePct).toBe(11.11);
});
it('throws InternalServerErrorException on unexpected errors', async () => {
mockPrisma.$queryRaw.mockRejectedValue(new Error('DB connection lost'));
const query = new GetPriceMoversQuery('up', '7d', 5, 'district');
await expect(handler.execute(query)).rejects.toThrow(
'Không thể truy vấn biến động giá. Vui lòng thử lại sau.',
);
});
});

View File

@@ -0,0 +1,144 @@
import { InternalServerErrorException } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { DomainException, CacheService, CachePrefix, CacheTTL, Cacheable, LoggerService, PrismaService } from '@modules/shared';
import { GetPriceMoversQuery } from './get-price-movers.query';
export interface PriceMoverItem {
districtId: string;
name: string;
currentAvgPrice: number;
previousAvgPrice: number;
changePct: number;
sampleSize: number;
}
export interface PriceMoversDto {
direction: 'up' | 'down';
period: string;
level: string;
limit: number;
movers: PriceMoverItem[];
}
/** Days extracted from period string, e.g. '7d' → 7 */
function periodToDays(period: string): number {
return parseInt(period.replace('d', ''), 10);
}
interface RawPriceMoverRow {
district: string;
current_avg: number | null;
previous_avg: number | null;
sample_size: bigint;
}
@QueryHandler(GetPriceMoversQuery)
export class GetPriceMoversHandler implements IQueryHandler<GetPriceMoversQuery> {
constructor(
private readonly prisma: PrismaService,
private readonly cacheService: CacheService,
private readonly logger: LoggerService,
) {}
@Cacheable({
prefix: CachePrefix.PRICE_MOVERS,
ttl: CacheTTL.PRICE_MOVERS,
resource: 'price_movers',
keyFrom: (query: unknown) => {
const q = query as GetPriceMoversQuery;
return [q.direction, q.period, String(q.limit), q.level];
},
})
async execute(query: GetPriceMoversQuery): Promise<PriceMoversDto> {
const { direction, period, limit, level } = query;
try {
const days = periodToDays(period);
const now = new Date();
const currentStart = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
const previousStart = new Date(currentStart.getTime() - days * 24 * 60 * 60 * 1000);
// Compare average listing price per district between current window and previous window.
// Only include districts with at least 10 listings in the current window (min sample size).
const rows = await this.prisma.$queryRaw<RawPriceMoverRow[]>`
WITH current_window AS (
SELECT
p.district,
AVG(l.price) AS avg_price,
COUNT(l.id) AS sample_size
FROM "Listing" l
INNER JOIN "Property" p ON p.id = l."propertyId"
WHERE l."createdAt" >= ${currentStart}
AND l.status = 'ACTIVE'
AND l.price > 0
GROUP BY p.district
HAVING COUNT(l.id) >= 10
),
previous_window AS (
SELECT
p.district,
AVG(l.price) AS avg_price
FROM "Listing" l
INNER JOIN "Property" p ON p.id = l."propertyId"
WHERE l."createdAt" >= ${previousStart}
AND l."createdAt" < ${currentStart}
AND l.status = 'ACTIVE'
AND l.price > 0
GROUP BY p.district
)
SELECT
c.district,
c.avg_price AS current_avg,
pr.avg_price AS previous_avg,
c.sample_size
FROM current_window c
INNER JOIN previous_window pr ON pr.district = c.district
WHERE pr.avg_price > 0
`;
// Compute changePct and sort by direction
const computed = rows
.map((r) => {
const currentAvg = Number(r.current_avg);
const previousAvg = Number(r.previous_avg);
const changePct = ((currentAvg - previousAvg) / previousAvg) * 100;
return {
district: r.district,
currentAvgPrice: Math.round(currentAvg),
previousAvgPrice: Math.round(previousAvg),
changePct: Math.round(changePct * 100) / 100,
sampleSize: Number(r.sample_size),
};
})
.filter((r) => (direction === 'up' ? r.changePct > 0 : r.changePct < 0));
// Sort: 'up' → descending changePct, 'down' → ascending changePct
computed.sort((a, b) =>
direction === 'up' ? b.changePct - a.changePct : a.changePct - b.changePct,
);
const top = computed.slice(0, limit);
const movers: PriceMoverItem[] = top.map((r) => ({
districtId: r.district,
name: r.district,
currentAvgPrice: r.currentAvgPrice,
previousAvgPrice: r.previousAvgPrice,
changePct: r.changePct,
sampleSize: r.sampleSize,
}));
return { direction, period, level, limit, movers };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to query price movers: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException(
'Không thể truy vấn biến động giá. Vui lòng thử lại sau.',
);
}
}
}

View File

@@ -0,0 +1,12 @@
export class GetPriceMoversQuery {
constructor(
/** Price movement direction: 'up' for gainers, 'down' for losers */
public readonly direction: 'up' | 'down',
/** Look-back period string, e.g. '7d', '14d', '30d' */
public readonly period: string,
/** Maximum number of results to return */
public readonly limit: number,
/** Geographic aggregation level — currently only 'district' */
public readonly level: 'district',
) {}
}

View File

@@ -32,6 +32,8 @@ import { type MarketReportDto } from '../../application/queries/get-market-repor
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
import { type MarketSnapshotDto } from '../../application/queries/get-market-snapshot/get-market-snapshot.handler';
import { GetMarketSnapshotQuery } from '../../application/queries/get-market-snapshot/get-market-snapshot.query';
import { type PriceMoversDto } from '../../application/queries/get-price-movers/get-price-movers.handler';
import { GetPriceMoversQuery } from '../../application/queries/get-price-movers/get-price-movers.query';
import { type NearbyPOIsResultDto } from '../../application/queries/get-nearby-pois/get-nearby-pois.handler';
import { GetNearbyPOIsQuery } from '../../application/queries/get-nearby-pois/get-nearby-pois.query';
import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query';
@@ -51,6 +53,7 @@ import { GetDistrictStatsDto } from '../dto/get-district-stats.dto';
import { GetHeatmapDto } from '../dto/get-heatmap.dto';
import { GetMarketReportDto } from '../dto/get-market-report.dto';
import { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto';
import { GetPriceMoversDto } from '../dto/get-price-movers.dto';
import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto';
import { GetPriceTrendDto } from '../dto/get-price-trend.dto';
import { GetValuationDto } from '../dto/get-valuation.dto';
@@ -96,6 +99,23 @@ export class AnalyticsController {
);
}
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')
@Get('price-movers')
@ApiOperation({
summary: 'Top tăng/giảm giá theo quận cho Home dashboard',
description:
'Trả về danh sách quận có biến động giá lớn nhất (tăng hoặc giảm) trong khoảng thời gian chỉ định. Chỉ hiển thị quận có ≥ 10 tin đăng. Cache Redis 30 phút.',
})
@ApiResponse({ status: 200, description: 'Price movers retrieved' })
@ApiResponse({ status: 403, description: 'Quota exceeded' })
async getPriceMovers(@Query() dto: GetPriceMoversDto): Promise<PriceMoversDto> {
return this.queryBus.execute(
new GetPriceMoversQuery(dto.direction, dto.period, dto.limit, dto.level),
);
}
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, QuotaGuard)
@RequireQuota('analytics_queries')

View File

@@ -0,0 +1,47 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
export class GetPriceMoversDto {
@ApiProperty({
description: 'Price movement direction',
enum: ['up', 'down'],
example: 'up',
})
@IsIn(['up', 'down'])
direction: 'up' | 'down' = 'up';
@ApiPropertyOptional({
description: 'Look-back period',
enum: ['7d', '14d', '30d'],
default: '7d',
example: '7d',
})
@IsOptional()
@IsIn(['7d', '14d', '30d'])
period: string = '7d';
@ApiPropertyOptional({
description: 'Maximum number of results to return',
minimum: 1,
maximum: 20,
default: 5,
example: 5,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(20)
limit: number = 5;
@ApiPropertyOptional({
description: 'Geographic aggregation level (currently only "district" is supported)',
enum: ['district'],
default: 'district',
example: 'district',
})
@IsOptional()
@IsIn(['district'])
level: 'district' = 'district';
}

View File

@@ -8,3 +8,5 @@ export { ValuationHistoryDto } from './valuation-history.dto';
export { ValuationComparisonDto } from './valuation-comparison.dto';
export { AvmCompareQueryDto } from './avm-compare-query.dto';
export { IndustrialValuationDto } from './industrial-valuation.dto';
export { GetTrendingAreasDto } from './get-trending-areas.dto';
export { GetPriceMoversDto } from './get-price-movers.dto';

View File

@@ -37,6 +37,8 @@ export const CacheTTL = {
MARKET_SNAPSHOT: 300, // 5 min
/** Trending areas — 30 min TTL, aggregation is expensive */
TRENDING_AREAS: 1800, // 30 min
/** Price movers — 30 min TTL, aggregation over two time windows */
PRICE_MOVERS: 1800, // 30 min
} as const;
export enum CachePrefix {
@@ -55,6 +57,7 @@ export enum CachePrefix {
AGENT_LISTINGS = 'cache:agent:listings',
MARKET_SNAPSHOT = 'cache:analytics:market_snapshot',
TRENDING_AREAS = 'cache:analytics:trending_areas',
PRICE_MOVERS = 'cache:analytics:price_movers',
}
@Injectable()