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:
@@ -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 = [
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
) {}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user