feat(transfer): add Claude Vision condition assessment for transfer pricing

Add POST /transfer/estimate-from-photos endpoint that uses Claude Vision API
to assess furniture/appliance condition from photos, integrating with the
existing rule-based pricing engine. Includes rate limiting (5/min), image hash
caching, graceful fallback, and 17 unit tests covering all paths.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 14:41:32 +07:00
parent b22543d59e
commit ca41f7e604
9 changed files with 833 additions and 1 deletions

View File

@@ -0,0 +1,179 @@
import type { IClaudeVisionService, VisionAssessmentResult } from '../../infrastructure/services/claude-vision.service';
import { EstimateFromPhotosCommand } from '../commands/estimate-from-photos/estimate-from-photos.command';
import { EstimateFromPhotosHandler } from '../commands/estimate-from-photos/estimate-from-photos.handler';
function mockVisionResult(overrides?: Partial<VisionAssessmentResult>): VisionAssessmentResult {
return {
condition: 'GOOD',
conditionConfidence: 0.85,
detectedBrand: null,
detectedCategory: null,
qualityScore: 70,
description: 'Ghế sofa tình trạng tốt, ít trầy xước.',
...overrides,
};
}
describe('EstimateFromPhotosHandler', () => {
let handler: EstimateFromPhotosHandler;
let mockVisionService: { [K in keyof IClaudeVisionService]: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockVisionService = {
assessFurnitureCondition: vi.fn(),
isAvailable: vi.fn().mockReturnValue(true),
};
handler = new EstimateFromPhotosHandler(
mockVisionService as any,
{ error: vi.fn() } as any,
);
});
it('returns vision assessment with pricing estimates', async () => {
mockVisionService.assessFurnitureCondition.mockResolvedValue(
mockVisionResult({ condition: 'LIKE_NEW', detectedBrand: 'IKEA' }),
);
const command = new EstimateFromPhotosCommand([
{
imageUrls: ['https://example.com/sofa.jpg'],
category: 'FURNITURE',
originalPriceVND: 15_000_000,
purchaseYear: 2024,
},
]);
const result = await handler.execute(command);
expect(result.visionAvailable).toBe(true);
expect(result.items).toHaveLength(1);
expect(result.items[0].visionAssessment.condition).toBe('LIKE_NEW');
expect(result.items[0].visionAssessment.detectedBrand).toBe('IKEA');
expect(result.items[0].visionAdjustedEstimate).not.toBeNull();
expect(result.items[0].ruleBasedEstimate).not.toBeNull();
});
it('returns null pricing when category/price/year not provided', async () => {
mockVisionService.assessFurnitureCondition.mockResolvedValue(mockVisionResult());
const command = new EstimateFromPhotosCommand([
{ imageUrls: ['https://example.com/chair.jpg'] },
]);
const result = await handler.execute(command);
expect(result.items[0].visionAssessment).toBeDefined();
expect(result.items[0].ruleBasedEstimate).toBeNull();
expect(result.items[0].visionAdjustedEstimate).toBeNull();
});
it('uses Vision-detected category when user did not provide one', async () => {
mockVisionService.assessFurnitureCondition.mockResolvedValue(
mockVisionResult({ detectedCategory: 'APPLIANCE', condition: 'FAIR' }),
);
const command = new EstimateFromPhotosCommand([
{
imageUrls: ['https://example.com/washer.jpg'],
originalPriceVND: 10_000_000,
purchaseYear: 2022,
},
]);
const result = await handler.execute(command);
// visionAdjustedEstimate should use APPLIANCE category
expect(result.items[0].visionAdjustedEstimate).not.toBeNull();
// ruleBasedEstimate should be null since no user-provided category
expect(result.items[0].ruleBasedEstimate).toBeNull();
});
it('handles multiple items in batch', async () => {
mockVisionService.assessFurnitureCondition
.mockResolvedValueOnce(mockVisionResult({ condition: 'LIKE_NEW' }))
.mockResolvedValueOnce(mockVisionResult({ condition: 'WORN' }));
const command = new EstimateFromPhotosCommand([
{
imageUrls: ['https://example.com/item1.jpg'],
category: 'FURNITURE',
originalPriceVND: 10_000_000,
purchaseYear: 2023,
},
{
imageUrls: ['https://example.com/item2.jpg'],
category: 'KITCHEN',
originalPriceVND: 5_000_000,
purchaseYear: 2020,
},
]);
const result = await handler.execute(command);
expect(result.items).toHaveLength(2);
expect(result.items[0].visionAssessment.condition).toBe('LIKE_NEW');
expect(result.items[1].visionAssessment.condition).toBe('WORN');
expect(mockVisionService.assessFurnitureCondition).toHaveBeenCalledTimes(2);
});
it('reports visionAvailable=false when service unavailable', async () => {
mockVisionService.isAvailable.mockReturnValue(false);
mockVisionService.assessFurnitureCondition.mockResolvedValue(
mockVisionResult({ conditionConfidence: 0.3 }),
);
const command = new EstimateFromPhotosCommand([
{ imageUrls: ['https://example.com/img.jpg'] },
]);
const result = await handler.execute(command);
expect(result.visionAvailable).toBe(false);
// Still returns results (fallback)
expect(result.items).toHaveLength(1);
});
it('uses Vision-detected brand for pricing adjustment', async () => {
mockVisionService.assessFurnitureCondition.mockResolvedValue(
mockVisionResult({ detectedBrand: 'Herman Miller', condition: 'GOOD' }),
);
const command = new EstimateFromPhotosCommand([
{
imageUrls: ['https://example.com/chair.jpg'],
category: 'OFFICE_EQUIPMENT',
originalPriceVND: 30_000_000,
purchaseYear: 2023,
},
]);
const result = await handler.execute(command);
// Vision-adjusted should use Herman Miller brand multiplier (1.3x)
const adjusted = result.items[0].visionAdjustedEstimate;
const ruleBased = result.items[0].ruleBasedEstimate;
expect(adjusted).not.toBeNull();
expect(ruleBased).not.toBeNull();
// Herman Miller (premium) should yield higher estimate than no-brand rule-based
expect(Number(adjusted!.estimatedPriceVND)).toBeGreaterThan(Number(ruleBased!.estimatedPriceVND));
});
it('passes image data correctly to vision service', async () => {
mockVisionService.assessFurnitureCondition.mockResolvedValue(mockVisionResult());
const command = new EstimateFromPhotosCommand([
{
imageUrls: ['https://example.com/a.jpg', 'https://example.com/b.jpg'],
imageBase64: [{ data: 'base64data', mediaType: 'image/jpeg' }],
},
]);
await handler.execute(command);
expect(mockVisionService.assessFurnitureCondition).toHaveBeenCalledWith({
imageUrls: ['https://example.com/a.jpg', 'https://example.com/b.jpg'],
imageBase64: [{ data: 'base64data', mediaType: 'image/jpeg' }],
});
});
});

View File

@@ -0,0 +1,16 @@
import type { TransferCategory } from '@prisma/client';
export interface PhotoEstimateItemInput {
imageUrls?: string[];
imageBase64?: { data: string; mediaType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' }[];
category?: TransferCategory;
originalPriceVND?: number;
purchaseYear?: number;
brand?: string;
}
export class EstimateFromPhotosCommand {
constructor(
public readonly items: PhotoEstimateItemInput[],
) {}
}

View File

@@ -0,0 +1,117 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import type { TransferCategory, TransferCondition } from '@prisma/client';
import { DomainException, LoggerService } from '@modules/shared';
import {
estimateFurniturePrice,
type FurniturePriceEstimate,
} from '../../../domain/services/furniture-pricing.service';
import {
CLAUDE_VISION_SERVICE,
type IClaudeVisionService,
type VisionAssessmentResult,
} from '../../../infrastructure/services/claude-vision.service';
import { EstimateFromPhotosCommand, type PhotoEstimateItemInput } from './estimate-from-photos.command';
export interface PhotoEstimateItemResult {
visionAssessment: VisionAssessmentResult;
ruleBasedEstimate: {
estimatedPriceVND: string;
confidence: number;
factors: FurniturePriceEstimate['factors'];
} | null;
visionAdjustedEstimate: {
estimatedPriceVND: string;
confidence: number;
factors: FurniturePriceEstimate['factors'];
} | null;
}
export interface PhotoEstimateResult {
items: PhotoEstimateItemResult[];
visionAvailable: boolean;
}
@CommandHandler(EstimateFromPhotosCommand)
export class EstimateFromPhotosHandler implements ICommandHandler<EstimateFromPhotosCommand> {
constructor(
@Inject(CLAUDE_VISION_SERVICE) private readonly visionService: IClaudeVisionService,
private readonly logger: LoggerService,
) {}
async execute(command: EstimateFromPhotosCommand): Promise<PhotoEstimateResult> {
try {
const visionAvailable = this.visionService.isAvailable();
const results: PhotoEstimateItemResult[] = [];
for (const item of command.items) {
const result = await this.assessItem(item);
results.push(result);
}
return { items: results, visionAvailable };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to estimate from photos: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể ước tính giá từ ảnh');
}
}
private async assessItem(item: PhotoEstimateItemInput): Promise<PhotoEstimateItemResult> {
// Get Vision assessment
const visionAssessment = await this.visionService.assessFurnitureCondition({
imageUrls: item.imageUrls,
imageBase64: item.imageBase64,
});
// Rule-based estimate using user-provided condition (if enough data)
const ruleBasedEstimate = this.tryRuleBasedEstimate(
item.category,
undefined, // no condition override — use default GOOD
item.originalPriceVND,
item.purchaseYear,
item.brand,
);
// Vision-adjusted estimate using Vision-assessed condition
const category = visionAssessment.detectedCategory ?? item.category;
const brand = visionAssessment.detectedBrand ?? item.brand;
const visionAdjustedEstimate = this.tryRuleBasedEstimate(
category,
visionAssessment.condition,
item.originalPriceVND,
item.purchaseYear,
brand,
);
return { visionAssessment, ruleBasedEstimate, visionAdjustedEstimate };
}
private tryRuleBasedEstimate(
category: TransferCategory | undefined,
condition: TransferCondition | undefined,
originalPriceVND: number | undefined,
purchaseYear: number | undefined,
brand: string | undefined,
): PhotoEstimateItemResult['ruleBasedEstimate'] {
if (!category || !originalPriceVND || !purchaseYear) return null;
const estimate = estimateFurniturePrice({
category,
condition: condition ?? 'GOOD',
originalPriceVND: BigInt(Math.round(originalPriceVND)),
purchaseYear,
brand,
});
return {
estimatedPriceVND: estimate.estimatedPriceVND.toString(),
confidence: estimate.confidence,
factors: estimate.factors,
};
}
}

View File

@@ -0,0 +1,2 @@
export { EstimateFromPhotosCommand } from './estimate-from-photos.command';
export { EstimateFromPhotosHandler } from './estimate-from-photos.handler';

View File

@@ -0,0 +1,190 @@
import { ClaudeVisionService } from '../services/claude-vision.service';
// We test the service with mocked Anthropic client
// The constructor reads CLAUDE_API_KEY from ConfigService
describe('ClaudeVisionService', () => {
let service: ClaudeVisionService;
let mockConfig: { get: ReturnType<typeof vi.fn> };
let mockCache: {
getOrSet: ReturnType<typeof vi.fn>;
};
describe('without API key', () => {
beforeEach(() => {
mockConfig = { get: vi.fn().mockReturnValue(undefined) };
mockCache = { getOrSet: vi.fn().mockImplementation(async (_k, loader) => loader()) };
service = new ClaudeVisionService(mockConfig as any, mockCache as any);
});
it('reports unavailable when no API key', () => {
expect(service.isAvailable()).toBe(false);
});
it('returns fallback assessment when no API key', async () => {
const result = await service.assessFurnitureCondition({
imageUrls: ['https://example.com/img.jpg'],
});
expect(result.condition).toBe('GOOD');
expect(result.conditionConfidence).toBe(0.3);
expect(result.description).toContain('không khả dụng');
});
});
describe('with API key (mocked client)', () => {
let mockCreate: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockConfig = { get: vi.fn().mockReturnValue('test-api-key') };
mockCache = { getOrSet: vi.fn().mockImplementation(async (_k, loader) => loader()) };
service = new ClaudeVisionService(mockConfig as any, mockCache as any);
// Replace the private client with a mock
mockCreate = vi.fn();
(service as any).client = { messages: { create: mockCreate } };
});
it('reports available when API key present', () => {
expect(service.isAvailable()).toBe(true);
});
it('parses valid Vision response', async () => {
mockCreate.mockResolvedValue({
content: [
{
type: 'text',
text: JSON.stringify({
condition: 'like-new',
conditionConfidence: 0.9,
brand: 'IKEA',
category: 'furniture',
qualityScore: 85,
description: 'Ghế sofa IKEA tình trạng rất tốt',
}),
},
],
});
const result = await service.assessFurnitureCondition({
imageUrls: ['https://example.com/sofa.jpg'],
});
expect(result.condition).toBe('LIKE_NEW');
expect(result.conditionConfidence).toBe(0.9);
expect(result.detectedBrand).toBe('IKEA');
expect(result.detectedCategory).toBe('FURNITURE');
expect(result.qualityScore).toBe(85);
});
it('maps "poor" condition to WORN', async () => {
mockCreate.mockResolvedValue({
content: [
{
type: 'text',
text: JSON.stringify({
condition: 'poor',
conditionConfidence: 0.7,
brand: 'unknown',
category: 'appliance',
qualityScore: 20,
description: 'Máy giặt cũ, nhiều vết rỉ sét',
}),
},
],
});
const result = await service.assessFurnitureCondition({
imageUrls: ['https://example.com/washer.jpg'],
});
expect(result.condition).toBe('WORN');
expect(result.detectedBrand).toBeNull(); // 'unknown' maps to null
expect(result.detectedCategory).toBe('APPLIANCE');
});
it('returns fallback on API error', async () => {
mockCreate.mockRejectedValue(new Error('API timeout'));
const result = await service.assessFurnitureCondition({
imageUrls: ['https://example.com/img.jpg'],
});
expect(result.condition).toBe('GOOD');
expect(result.conditionConfidence).toBe(0.3);
});
it('returns fallback on invalid JSON response', async () => {
mockCreate.mockResolvedValue({
content: [{ type: 'text', text: 'This is not valid JSON at all' }],
});
const result = await service.assessFurnitureCondition({
imageUrls: ['https://example.com/img.jpg'],
});
expect(result.condition).toBe('GOOD');
expect(result.conditionConfidence).toBe(0.3);
});
it('returns fallback when no images provided', async () => {
const result = await service.assessFurnitureCondition({});
expect(result.condition).toBe('GOOD');
expect(mockCreate).not.toHaveBeenCalled();
});
it('clamps confidence and quality score to valid ranges', async () => {
mockCreate.mockResolvedValue({
content: [
{
type: 'text',
text: JSON.stringify({
condition: 'good',
conditionConfidence: 1.5, // over 1
brand: 'unknown',
category: 'furniture',
qualityScore: 150, // over 100
description: 'Test',
}),
},
],
});
const result = await service.assessFurnitureCondition({
imageUrls: ['https://example.com/img.jpg'],
});
expect(result.conditionConfidence).toBe(1);
expect(result.qualityScore).toBe(100);
});
it('sends image content to Claude API correctly', async () => {
mockCreate.mockResolvedValue({
content: [{ type: 'text', text: '{"condition":"good","conditionConfidence":0.8,"brand":"unknown","category":"furniture","qualityScore":70,"description":"OK"}' }],
});
await service.assessFurnitureCondition({
imageUrls: ['https://example.com/a.jpg'],
imageBase64: [{ data: 'abc123', mediaType: 'image/jpeg' }],
});
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: expect.any(String),
max_tokens: 1024,
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'image', source: expect.objectContaining({ type: 'url' }) }),
expect.objectContaining({ type: 'image', source: expect.objectContaining({ type: 'base64' }) }),
expect.objectContaining({ type: 'text' }),
]),
},
],
}),
);
});
});
});

View File

@@ -0,0 +1,235 @@
import { createHash } from 'crypto';
import Anthropic from '@anthropic-ai/sdk';
import { Injectable, Logger } from '@nestjs/common';
import { type ConfigService } from '@nestjs/config';
import type { TransferCategory, TransferCondition } from '@prisma/client';
import { CachePrefix, type CacheService } from '@modules/shared';
export interface VisionAssessmentInput {
imageUrls?: string[];
imageBase64?: { data: string; mediaType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' }[];
}
export interface VisionAssessmentResult {
condition: TransferCondition;
conditionConfidence: number;
detectedBrand: string | null;
detectedCategory: TransferCategory | null;
qualityScore: number; // 0-100
description: string;
}
export const CLAUDE_VISION_SERVICE = Symbol('CLAUDE_VISION_SERVICE');
export interface IClaudeVisionService {
assessFurnitureCondition(input: VisionAssessmentInput): Promise<VisionAssessmentResult>;
isAvailable(): boolean;
}
@Injectable()
export class ClaudeVisionService implements IClaudeVisionService {
private readonly logger = new Logger(ClaudeVisionService.name);
private readonly client: Anthropic | null;
private readonly model = 'claude-sonnet-4-20250514';
constructor(
private readonly config: ConfigService,
private readonly cache: CacheService,
) {
const apiKey = this.config.get<string>('CLAUDE_API_KEY');
this.client = apiKey ? new Anthropic({ apiKey }) : null;
if (!this.client) {
this.logger.warn('CLAUDE_API_KEY not configured — Vision assessment will use fallback.');
}
}
isAvailable(): boolean {
return this.client !== null;
}
async assessFurnitureCondition(input: VisionAssessmentInput): Promise<VisionAssessmentResult> {
const cacheKey = this.buildCacheKey(input);
const cached = await this.tryGetCached(cacheKey);
if (cached) return cached;
if (!this.client) {
return this.fallbackAssessment();
}
try {
const imageContent = this.buildImageContent(input);
if (imageContent.length === 0) {
return this.fallbackAssessment();
}
const response = await this.client.messages.create({
model: this.model,
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [
{
role: 'user',
content: [
...imageContent,
{ type: 'text', text: USER_PROMPT },
],
},
],
});
const text = response.content
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
const result = this.parseAssessment(text);
await this.cacheResult(cacheKey, result);
return result;
} catch (error) {
this.logger.error(
`Claude Vision API error: ${error instanceof Error ? error.message : 'Unknown'}`,
error instanceof Error ? error.stack : undefined,
);
return this.fallbackAssessment();
}
}
private buildImageContent(
input: VisionAssessmentInput,
): Anthropic.Messages.ImageBlockParam[] {
const blocks: Anthropic.Messages.ImageBlockParam[] = [];
if (input.imageUrls) {
for (const url of input.imageUrls.slice(0, 4)) {
blocks.push({
type: 'image',
source: { type: 'url', url },
});
}
}
if (input.imageBase64) {
for (const img of input.imageBase64.slice(0, 4)) {
blocks.push({
type: 'image',
source: {
type: 'base64',
media_type: img.mediaType,
data: img.data,
},
});
}
}
return blocks.slice(0, 4); // Claude max 4 images per request
}
private parseAssessment(text: string): VisionAssessmentResult {
try {
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return this.fallbackAssessment();
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
const condition = this.mapCondition(parsed['condition'] as string);
const conditionConfidence = Math.max(0, Math.min(1, Number(parsed['conditionConfidence']) || 0.5));
const detectedBrand = typeof parsed['brand'] === 'string' && parsed['brand'] !== 'unknown'
? parsed['brand']
: null;
const detectedCategory = this.mapCategory(parsed['category'] as string);
const qualityScore = Math.max(0, Math.min(100, Math.round(Number(parsed['qualityScore']) || 50)));
const description = typeof parsed['description'] === 'string'
? parsed['description']
: 'Không có mô tả';
return { condition, conditionConfidence, detectedBrand, detectedCategory, qualityScore, description };
} catch {
this.logger.warn('Failed to parse Vision response, using fallback');
return this.fallbackAssessment();
}
}
private mapCondition(raw: string | undefined): TransferCondition {
const map: Record<string, TransferCondition> = {
new: 'NEW',
'like-new': 'LIKE_NEW',
like_new: 'LIKE_NEW',
good: 'GOOD',
fair: 'FAIR',
poor: 'WORN',
worn: 'WORN',
};
return map[raw?.toLowerCase() ?? ''] ?? 'GOOD';
}
private mapCategory(raw: string | undefined): TransferCategory | null {
const map: Record<string, TransferCategory> = {
furniture: 'FURNITURE',
appliance: 'APPLIANCE',
office_equipment: 'OFFICE_EQUIPMENT',
kitchen: 'KITCHEN',
premises: 'PREMISES',
full_unit: 'FULL_UNIT',
};
return map[raw?.toLowerCase() ?? ''] ?? null;
}
private fallbackAssessment(): VisionAssessmentResult {
return {
condition: 'GOOD',
conditionConfidence: 0.3,
detectedBrand: null,
detectedCategory: null,
qualityScore: 50,
description: 'Đánh giá AI không khả dụng — sử dụng giá trị mặc định.',
};
}
private buildCacheKey(input: VisionAssessmentInput): string {
const hash = createHash('sha256');
if (input.imageUrls) hash.update(input.imageUrls.sort().join('|'));
if (input.imageBase64) hash.update(input.imageBase64.map((i) => i.data.slice(0, 64)).join('|'));
return `${CachePrefix.VALUATION}:vision:${hash.digest('hex').slice(0, 16)}`;
}
private async tryGetCached(key: string): Promise<VisionAssessmentResult | null> {
try {
return await this.cache.getOrSet<VisionAssessmentResult | null>(
key,
() => Promise.resolve(null),
0, // TTL 0 = just try to GET, don't set
'vision_assessment',
);
} catch {
return null;
}
}
private async cacheResult(key: string, result: VisionAssessmentResult): Promise<void> {
try {
await this.cache.getOrSet(
key,
() => Promise.resolve(result),
3600, // Cache for 1 hour
'vision_assessment',
);
} catch {
// Cache failure is non-critical
}
}
}
const SYSTEM_PROMPT = `You are a furniture and home appliance condition assessor for a Vietnamese real estate transfer marketplace.
Analyze the provided images and assess the item's condition for pricing purposes.
Respond ONLY with a JSON object — no markdown, no explanation outside the JSON.`;
const USER_PROMPT = `Assess the furniture/appliance in these images. Return a JSON object with EXACTLY these fields:
{
"condition": "new" | "like-new" | "good" | "fair" | "poor",
"conditionConfidence": 0.0-1.0,
"brand": "detected brand name or unknown",
"category": "furniture" | "appliance" | "office_equipment" | "kitchen" | "premises" | "full_unit",
"qualityScore": 0-100,
"description": "Brief Vietnamese description of the item's condition and notable features"
}`;

View File

@@ -2,14 +2,16 @@ import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@ne
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard, CurrentUser } from '@modules/auth';
import { NotFoundException } from '@modules/shared';
import { EndpointRateLimit, EndpointRateLimitGuard, NotFoundException } from '@modules/shared';
import { CreateTransferListingCommand } from '../../application/commands/create-transfer-listing/create-transfer-listing.command';
import { EstimateFromPhotosCommand } from '../../application/commands/estimate-from-photos/estimate-from-photos.command';
import { EstimateTransferPricesCommand } from '../../application/commands/estimate-transfer-prices/estimate-transfer-prices.command';
import { UpdateTransferListingCommand } from '../../application/commands/update-transfer-listing/update-transfer-listing.command';
import { GetTransferListingQuery } from '../../application/queries/get-transfer-listing/get-transfer-listing.query';
import { ListTransferListingsQuery } from '../../application/queries/list-transfer-listings/list-transfer-listings.query';
import { TransferStatsQuery } from '../../application/queries/transfer-stats/transfer-stats.query';
import { type CreateTransferListingDto } from '../dto/create-transfer-listing.dto';
import { type EstimateFromPhotosDto } from '../dto/estimate-from-photos.dto';
import { type EstimateTransferPricesDto } from '../dto/estimate-transfer-prices.dto';
import { type SearchTransferListingsDto } from '../dto/search-transfer-listings.dto';
import { type UpdateTransferListingDto } from '../dto/update-transfer-listing.dto';
@@ -72,6 +74,21 @@ export class TransferController {
);
}
@ApiOperation({
summary: 'Ước tính giá từ ảnh (AI Vision)',
description: 'Sử dụng Claude Vision để đánh giá tình trạng nội thất từ ảnh, kết hợp với công cụ định giá rule-based',
})
@ApiResponse({ status: 200, description: 'Kết quả đánh giá tình trạng và ước tính giá' })
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
@EndpointRateLimit({ limit: 5, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('estimate-from-photos')
async estimateFromPhotos(@Body() dto: EstimateFromPhotosDto) {
return this.commandBus.execute(
new EstimateFromPhotosCommand(dto.items),
);
}
// ── Authenticated endpoints ───────────────────────────────────────
@ApiOperation({ summary: 'Tạo tin sang nhượng', description: 'Đăng tin sang nhượng mới' })

View File

@@ -0,0 +1,72 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { TransferCategory } from '@prisma/client';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUrl,
ValidateNested,
} from 'class-validator';
export class ImageBase64Dto {
@ApiProperty({ description: 'Base64-encoded image data' })
@IsString()
data!: string;
@ApiProperty({ enum: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] })
@IsString()
mediaType!: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif';
}
export class PhotoEstimateItemDto {
@ApiPropertyOptional({ description: 'Image URLs to assess', type: [String] })
@IsOptional()
@IsArray()
@IsUrl({}, { each: true, message: 'URL ảnh không hợp lệ' })
@ArrayMaxSize(4, { message: 'Tối đa 4 ảnh mỗi item' })
imageUrls?: string[];
@ApiPropertyOptional({ description: 'Base64-encoded images', type: [ImageBase64Dto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ImageBase64Dto)
@ArrayMaxSize(4, { message: 'Tối đa 4 ảnh mỗi item' })
imageBase64?: ImageBase64Dto[];
@ApiPropertyOptional({ enum: TransferCategory, description: 'Danh mục (nếu biết)' })
@IsOptional()
@IsEnum(TransferCategory)
category?: TransferCategory;
@ApiPropertyOptional({ description: 'Giá mua ban đầu (VND)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
originalPriceVND?: number;
@ApiPropertyOptional({ description: 'Năm mua' })
@IsOptional()
@IsInt()
@Type(() => Number)
purchaseYear?: number;
@ApiPropertyOptional({ description: 'Thương hiệu (nếu biết)' })
@IsOptional()
@IsString()
brand?: string;
}
export class EstimateFromPhotosDto {
@ApiProperty({ type: [PhotoEstimateItemDto], description: 'Items to assess from photos' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PhotoEstimateItemDto)
@ArrayMaxSize(5, { message: 'Tối đa 5 items mỗi lần đánh giá' })
items!: PhotoEstimateItemDto[];
}

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { SearchModule } from '@modules/search';
import { CreateTransferListingHandler } from './application/commands/create-transfer-listing/create-transfer-listing.handler';
import { EstimateFromPhotosHandler } from './application/commands/estimate-from-photos/estimate-from-photos.handler';
import { EstimateTransferPricesHandler } from './application/commands/estimate-transfer-prices/estimate-transfer-prices.handler';
import { UpdateTransferListingHandler } from './application/commands/update-transfer-listing/update-transfer-listing.handler';
import { GetTransferListingHandler } from './application/queries/get-transfer-listing/get-transfer-listing.handler';
@@ -9,11 +10,13 @@ import { ListTransferListingsHandler } from './application/queries/list-transfer
import { TransferStatsHandler } from './application/queries/transfer-stats/transfer-stats.handler';
import { TRANSFER_LISTING_REPOSITORY } from './domain/repositories/transfer-listing.repository';
import { PrismaTransferListingRepository } from './infrastructure/repositories/prisma-transfer-listing.repository';
import { CLAUDE_VISION_SERVICE, ClaudeVisionService } from './infrastructure/services/claude-vision.service';
import { TypesenseTransferService } from './infrastructure/services/typesense-transfer.service';
import { TransferController } from './presentation/controllers/transfer.controller';
const CommandHandlers = [
CreateTransferListingHandler,
EstimateFromPhotosHandler,
EstimateTransferPricesHandler,
UpdateTransferListingHandler,
];
@@ -29,6 +32,7 @@ const QueryHandlers = [
controllers: [TransferController],
providers: [
{ provide: TRANSFER_LISTING_REPOSITORY, useClass: PrismaTransferListingRepository },
{ provide: CLAUDE_VISION_SERVICE, useClass: ClaudeVisionService },
TypesenseTransferService,
...CommandHandlers,
...QueryHandlers,