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:
@@ -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' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { EstimateFromPhotosCommand } from './estimate-from-photos.command';
|
||||||
|
export { EstimateFromPhotosHandler } from './estimate-from-photos.handler';
|
||||||
@@ -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' }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}`;
|
||||||
@@ -2,14 +2,16 @@ import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@ne
|
|||||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard, CurrentUser } from '@modules/auth';
|
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 { 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 { EstimateTransferPricesCommand } from '../../application/commands/estimate-transfer-prices/estimate-transfer-prices.command';
|
||||||
import { UpdateTransferListingCommand } from '../../application/commands/update-transfer-listing/update-transfer-listing.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 { GetTransferListingQuery } from '../../application/queries/get-transfer-listing/get-transfer-listing.query';
|
||||||
import { ListTransferListingsQuery } from '../../application/queries/list-transfer-listings/list-transfer-listings.query';
|
import { ListTransferListingsQuery } from '../../application/queries/list-transfer-listings/list-transfer-listings.query';
|
||||||
import { TransferStatsQuery } from '../../application/queries/transfer-stats/transfer-stats.query';
|
import { TransferStatsQuery } from '../../application/queries/transfer-stats/transfer-stats.query';
|
||||||
import { type CreateTransferListingDto } from '../dto/create-transfer-listing.dto';
|
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 EstimateTransferPricesDto } from '../dto/estimate-transfer-prices.dto';
|
||||||
import { type SearchTransferListingsDto } from '../dto/search-transfer-listings.dto';
|
import { type SearchTransferListingsDto } from '../dto/search-transfer-listings.dto';
|
||||||
import { type UpdateTransferListingDto } from '../dto/update-transfer-listing.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 ───────────────────────────────────────
|
// ── Authenticated endpoints ───────────────────────────────────────
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Tạo tin sang nhượng', description: 'Đăng tin sang nhượng mới' })
|
@ApiOperation({ summary: 'Tạo tin sang nhượng', description: 'Đăng tin sang nhượng mới' })
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { SearchModule } from '@modules/search';
|
import { SearchModule } from '@modules/search';
|
||||||
import { CreateTransferListingHandler } from './application/commands/create-transfer-listing/create-transfer-listing.handler';
|
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 { EstimateTransferPricesHandler } from './application/commands/estimate-transfer-prices/estimate-transfer-prices.handler';
|
||||||
import { UpdateTransferListingHandler } from './application/commands/update-transfer-listing/update-transfer-listing.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';
|
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 { TransferStatsHandler } from './application/queries/transfer-stats/transfer-stats.handler';
|
||||||
import { TRANSFER_LISTING_REPOSITORY } from './domain/repositories/transfer-listing.repository';
|
import { TRANSFER_LISTING_REPOSITORY } from './domain/repositories/transfer-listing.repository';
|
||||||
import { PrismaTransferListingRepository } from './infrastructure/repositories/prisma-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 { TypesenseTransferService } from './infrastructure/services/typesense-transfer.service';
|
||||||
import { TransferController } from './presentation/controllers/transfer.controller';
|
import { TransferController } from './presentation/controllers/transfer.controller';
|
||||||
|
|
||||||
const CommandHandlers = [
|
const CommandHandlers = [
|
||||||
CreateTransferListingHandler,
|
CreateTransferListingHandler,
|
||||||
|
EstimateFromPhotosHandler,
|
||||||
EstimateTransferPricesHandler,
|
EstimateTransferPricesHandler,
|
||||||
UpdateTransferListingHandler,
|
UpdateTransferListingHandler,
|
||||||
];
|
];
|
||||||
@@ -29,6 +32,7 @@ const QueryHandlers = [
|
|||||||
controllers: [TransferController],
|
controllers: [TransferController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: TRANSFER_LISTING_REPOSITORY, useClass: PrismaTransferListingRepository },
|
{ provide: TRANSFER_LISTING_REPOSITORY, useClass: PrismaTransferListingRepository },
|
||||||
|
{ provide: CLAUDE_VISION_SERVICE, useClass: ClaudeVisionService },
|
||||||
TypesenseTransferService,
|
TypesenseTransferService,
|
||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
...QueryHandlers,
|
...QueryHandlers,
|
||||||
|
|||||||
Reference in New Issue
Block a user