feat(api): add industrial, transfer, and reports backend modules

Add three new NestJS modules following DDD/CQRS architecture:
- Industrial: KCN (industrial park) management with PostGIS geo queries, Typesense search, and market statistics
- Transfer: Furniture/premises transfer listings with AI-powered price estimation and depreciation modeling
- Reports: Async AI report generation via BullMQ with Claude narrative service, PDF generation, and macro data integration

Includes Prisma schema models, migrations, seed scripts, and app.module wiring with BullMQ Redis config.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 09:11:16 +07:00
parent 7ce651fce5
commit deb04989de
123 changed files with 8260 additions and 12 deletions

View File

@@ -0,0 +1,42 @@
import type { TransferCategory, TransferCondition, TransferPricingSource } from '@prisma/client';
export interface CreateTransferItemInput {
name: string;
brand?: string;
modelName?: string;
category: TransferCategory;
condition: TransferCondition;
purchaseYear?: number;
originalPriceVND?: bigint;
askingPriceVND: bigint;
quantity?: number;
dimensions?: Record<string, unknown>;
notes?: string;
}
export class CreateTransferListingCommand {
constructor(
public readonly sellerId: string,
public readonly category: TransferCategory,
public readonly title: string,
public readonly description: string | null,
public readonly address: string,
public readonly ward: string | null,
public readonly district: string,
public readonly city: string,
public readonly latitude: number,
public readonly longitude: number,
public readonly askingPriceVND: bigint,
public readonly pricingSource: TransferPricingSource,
public readonly isNegotiable: boolean,
public readonly areaM2: number | null,
public readonly monthlyRentVND: bigint | null,
public readonly depositMonths: number | null,
public readonly remainingLeaseMo: number | null,
public readonly businessType: string | null,
public readonly footTraffic: string | null,
public readonly contactPhone: string | null,
public readonly contactName: string | null,
public readonly items: CreateTransferItemInput[],
) {}
}

View File

@@ -0,0 +1,60 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { TransferListingEntity } from '../../../domain/entities/transfer-listing.entity';
import {
TRANSFER_LISTING_REPOSITORY,
type ITransferListingRepository,
} from '../../../domain/repositories/transfer-listing.repository';
import { CreateTransferListingCommand } from './create-transfer-listing.command';
@CommandHandler(CreateTransferListingCommand)
export class CreateTransferListingHandler implements ICommandHandler<CreateTransferListingCommand> {
constructor(
@Inject(TRANSFER_LISTING_REPOSITORY)
private readonly repo: ITransferListingRepository,
) {}
async execute(cmd: CreateTransferListingCommand): Promise<{ id: string }> {
const id = createId();
const now = new Date();
const entity = new TransferListingEntity(id, {
sellerId: cmd.sellerId,
category: cmd.category,
status: 'DRAFT',
title: cmd.title,
description: cmd.description,
address: cmd.address,
ward: cmd.ward,
district: cmd.district,
city: cmd.city,
latitude: cmd.latitude,
longitude: cmd.longitude,
askingPriceVND: cmd.askingPriceVND,
aiEstimatePriceVND: null,
aiConfidence: null,
pricingSource: cmd.pricingSource,
isNegotiable: cmd.isNegotiable,
areaM2: cmd.areaM2,
monthlyRentVND: cmd.monthlyRentVND,
depositMonths: cmd.depositMonths,
remainingLeaseMo: cmd.remainingLeaseMo,
businessType: cmd.businessType,
footTraffic: cmd.footTraffic,
media: null,
moderationScore: null,
moderationNotes: null,
viewCount: 0,
saveCount: 0,
inquiryCount: 0,
contactPhone: cmd.contactPhone,
contactName: cmd.contactName,
featuredUntil: null,
expiresAt: null,
publishedAt: null,
}, now, now);
await this.repo.save(entity, cmd.items);
return { id };
}
}

View File

@@ -0,0 +1,3 @@
export { CreateTransferListingCommand } from './create-transfer-listing.command';
export type { CreateTransferItemInput } from './create-transfer-listing.command';
export { CreateTransferListingHandler } from './create-transfer-listing.handler';

View File

@@ -0,0 +1,15 @@
import type { TransferCategory, TransferCondition } from '@prisma/client';
export interface EstimateItemInput {
category: TransferCategory;
condition: TransferCondition;
originalPriceVND: number; // DTO uses number, we convert to BigInt
purchaseYear: number;
brand?: string;
}
export class EstimateTransferPricesCommand {
constructor(
public readonly items: EstimateItemInput[],
) {}
}

View File

@@ -0,0 +1,28 @@
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { estimateTransferListingPrices, type FurniturePricingInput } from '../../../domain/services/furniture-pricing.service';
import { EstimateTransferPricesCommand } from './estimate-transfer-prices.command';
@CommandHandler(EstimateTransferPricesCommand)
export class EstimateTransferPricesHandler implements ICommandHandler<EstimateTransferPricesCommand> {
async execute(cmd: EstimateTransferPricesCommand) {
const inputs: FurniturePricingInput[] = cmd.items.map((item) => ({
category: item.category,
condition: item.condition,
originalPriceVND: BigInt(Math.round(item.originalPriceVND)),
purchaseYear: item.purchaseYear,
brand: item.brand,
}));
const { estimates, totalEstimateVND, avgConfidence } = estimateTransferListingPrices(inputs);
return {
estimates: estimates.map((e) => ({
estimatedPriceVND: e.estimatedPriceVND.toString(),
confidence: Math.round(e.confidence * 100) / 100,
factors: e.factors,
})),
totalEstimateVND: totalEstimateVND.toString(),
avgConfidence: Math.round(avgConfidence * 100) / 100,
};
}
}

View File

@@ -0,0 +1,2 @@
export { EstimateTransferPricesCommand, type EstimateItemInput } from './estimate-transfer-prices.command';
export { EstimateTransferPricesHandler } from './estimate-transfer-prices.handler';

View File

@@ -0,0 +1,2 @@
export * from './create-transfer-listing';
export * from './update-transfer-listing';

View File

@@ -0,0 +1,2 @@
export { UpdateTransferListingCommand } from './update-transfer-listing.command';
export { UpdateTransferListingHandler } from './update-transfer-listing.handler';

View File

@@ -0,0 +1,21 @@
import type { TransferListingStatus } from '@prisma/client';
export class UpdateTransferListingCommand {
constructor(
public readonly id: string,
public readonly title?: string,
public readonly description?: string | null,
public readonly status?: TransferListingStatus,
public readonly askingPriceVND?: bigint,
public readonly isNegotiable?: boolean,
public readonly areaM2?: number | null,
public readonly monthlyRentVND?: bigint | null,
public readonly depositMonths?: number | null,
public readonly remainingLeaseMo?: number | null,
public readonly businessType?: string | null,
public readonly footTraffic?: string | null,
public readonly contactPhone?: string | null,
public readonly contactName?: string | null,
public readonly media?: Record<string, unknown>[],
) {}
}

View File

@@ -0,0 +1,43 @@
import { Inject } from '@nestjs/common';
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@modules/shared';
import {
TRANSFER_LISTING_REPOSITORY,
type ITransferListingRepository,
} from '../../../domain/repositories/transfer-listing.repository';
import { UpdateTransferListingCommand } from './update-transfer-listing.command';
@CommandHandler(UpdateTransferListingCommand)
export class UpdateTransferListingHandler implements ICommandHandler<UpdateTransferListingCommand> {
constructor(
@Inject(TRANSFER_LISTING_REPOSITORY)
private readonly repo: ITransferListingRepository,
) {}
async execute(cmd: UpdateTransferListingCommand): Promise<{ id: string }> {
const entity = await this.repo.findById(cmd.id);
if (!entity) {
throw new NotFoundException('Transfer listing', cmd.id);
}
entity.updateDetails({
title: cmd.title,
description: cmd.description,
status: cmd.status,
askingPriceVND: cmd.askingPriceVND,
isNegotiable: cmd.isNegotiable,
areaM2: cmd.areaM2,
monthlyRentVND: cmd.monthlyRentVND,
depositMonths: cmd.depositMonths,
remainingLeaseMo: cmd.remainingLeaseMo,
businessType: cmd.businessType,
footTraffic: cmd.footTraffic,
contactPhone: cmd.contactPhone,
contactName: cmd.contactName,
media: cmd.media,
});
await this.repo.update(entity);
return { id: cmd.id };
}
}

View File

@@ -0,0 +1,19 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
TRANSFER_LISTING_REPOSITORY,
type ITransferListingRepository,
} from '../../../domain/repositories/transfer-listing.repository';
import { GetTransferListingQuery } from './get-transfer-listing.query';
@QueryHandler(GetTransferListingQuery)
export class GetTransferListingHandler implements IQueryHandler<GetTransferListingQuery> {
constructor(
@Inject(TRANSFER_LISTING_REPOSITORY)
private readonly repo: ITransferListingRepository,
) {}
async execute(query: GetTransferListingQuery) {
return this.repo.findDetailById(query.id);
}
}

View File

@@ -0,0 +1,5 @@
export class GetTransferListingQuery {
constructor(
public readonly id: string,
) {}
}

View File

@@ -0,0 +1,2 @@
export { GetTransferListingQuery } from './get-transfer-listing.query';
export { GetTransferListingHandler } from './get-transfer-listing.handler';

View File

@@ -0,0 +1,3 @@
export * from './list-transfer-listings';
export * from './get-transfer-listing';
export * from './transfer-stats';

View File

@@ -0,0 +1,2 @@
export { ListTransferListingsQuery } from './list-transfer-listings.query';
export { ListTransferListingsHandler } from './list-transfer-listings.handler';

View File

@@ -0,0 +1,30 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
TRANSFER_LISTING_REPOSITORY,
type ITransferListingRepository,
} from '../../../domain/repositories/transfer-listing.repository';
import { ListTransferListingsQuery } from './list-transfer-listings.query';
@QueryHandler(ListTransferListingsQuery)
export class ListTransferListingsHandler implements IQueryHandler<ListTransferListingsQuery> {
constructor(
@Inject(TRANSFER_LISTING_REPOSITORY)
private readonly repo: ITransferListingRepository,
) {}
async execute(query: ListTransferListingsQuery) {
return this.repo.search({
query: query.query,
category: query.category,
status: query.status,
district: query.district,
city: query.city,
minPrice: query.minPrice,
maxPrice: query.maxPrice,
sellerId: query.sellerId,
page: query.page,
limit: query.limit,
});
}
}

View File

@@ -0,0 +1,16 @@
import type { TransferCategory, TransferListingStatus } from '@prisma/client';
export class ListTransferListingsQuery {
constructor(
public readonly query?: string,
public readonly category?: TransferCategory,
public readonly status?: TransferListingStatus,
public readonly district?: string,
public readonly city?: string,
public readonly minPrice?: number,
public readonly maxPrice?: number,
public readonly sellerId?: string,
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -0,0 +1,2 @@
export { TransferStatsQuery } from './transfer-stats.query';
export { TransferStatsHandler } from './transfer-stats.handler';

View File

@@ -0,0 +1,19 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
TRANSFER_LISTING_REPOSITORY,
type ITransferListingRepository,
} from '../../../domain/repositories/transfer-listing.repository';
import { TransferStatsQuery } from './transfer-stats.query';
@QueryHandler(TransferStatsQuery)
export class TransferStatsHandler implements IQueryHandler<TransferStatsQuery> {
constructor(
@Inject(TRANSFER_LISTING_REPOSITORY)
private readonly repo: ITransferListingRepository,
) {}
async execute(_query: TransferStatsQuery) {
return this.repo.getStats();
}
}

View File

@@ -0,0 +1 @@
export class TransferStatsQuery {}

View File

@@ -0,0 +1 @@
export { TransferListingEntity, type TransferListingProps } from './transfer-listing.entity';

View File

@@ -0,0 +1,182 @@
import { type TransferCategory, type TransferListingStatus, type TransferPricingSource } from '@prisma/client';
import { AggregateRoot } from '@modules/shared';
export interface TransferListingProps {
sellerId: string;
category: TransferCategory;
status: TransferListingStatus;
title: string;
description: string | null;
address: string;
ward: string | null;
district: string;
city: string;
latitude: number;
longitude: number;
askingPriceVND: bigint;
aiEstimatePriceVND: bigint | null;
aiConfidence: number | null;
pricingSource: TransferPricingSource;
isNegotiable: boolean;
areaM2: number | null;
monthlyRentVND: bigint | null;
depositMonths: number | null;
remainingLeaseMo: number | null;
businessType: string | null;
footTraffic: string | null;
media: Record<string, unknown>[] | null;
moderationScore: number | null;
moderationNotes: string | null;
viewCount: number;
saveCount: number;
inquiryCount: number;
contactPhone: string | null;
contactName: string | null;
featuredUntil: Date | null;
expiresAt: Date | null;
publishedAt: Date | null;
}
export class TransferListingEntity extends AggregateRoot<string> {
private _sellerId: string;
private _category: TransferCategory;
private _status: TransferListingStatus;
private _title: string;
private _description: string | null;
private _address: string;
private _ward: string | null;
private _district: string;
private _city: string;
private _latitude: number;
private _longitude: number;
private _askingPriceVND: bigint;
private _aiEstimatePriceVND: bigint | null;
private _aiConfidence: number | null;
private _pricingSource: TransferPricingSource;
private _isNegotiable: boolean;
private _areaM2: number | null;
private _monthlyRentVND: bigint | null;
private _depositMonths: number | null;
private _remainingLeaseMo: number | null;
private _businessType: string | null;
private _footTraffic: string | null;
private _media: Record<string, unknown>[] | null;
private _moderationScore: number | null;
private _moderationNotes: string | null;
private _viewCount: number;
private _saveCount: number;
private _inquiryCount: number;
private _contactPhone: string | null;
private _contactName: string | null;
private _featuredUntil: Date | null;
private _expiresAt: Date | null;
private _publishedAt: Date | null;
constructor(id: string, props: TransferListingProps, createdAt: Date, updatedAt: Date) {
super(id, createdAt, updatedAt);
this._sellerId = props.sellerId;
this._category = props.category;
this._status = props.status;
this._title = props.title;
this._description = props.description;
this._address = props.address;
this._ward = props.ward;
this._district = props.district;
this._city = props.city;
this._latitude = props.latitude;
this._longitude = props.longitude;
this._askingPriceVND = props.askingPriceVND;
this._aiEstimatePriceVND = props.aiEstimatePriceVND;
this._aiConfidence = props.aiConfidence;
this._pricingSource = props.pricingSource;
this._isNegotiable = props.isNegotiable;
this._areaM2 = props.areaM2;
this._monthlyRentVND = props.monthlyRentVND;
this._depositMonths = props.depositMonths;
this._remainingLeaseMo = props.remainingLeaseMo;
this._businessType = props.businessType;
this._footTraffic = props.footTraffic;
this._media = props.media;
this._moderationScore = props.moderationScore;
this._moderationNotes = props.moderationNotes;
this._viewCount = props.viewCount;
this._saveCount = props.saveCount;
this._inquiryCount = props.inquiryCount;
this._contactPhone = props.contactPhone;
this._contactName = props.contactName;
this._featuredUntil = props.featuredUntil;
this._expiresAt = props.expiresAt;
this._publishedAt = props.publishedAt;
}
get sellerId() { return this._sellerId; }
get category() { return this._category; }
get status() { return this._status; }
get title() { return this._title; }
get description() { return this._description; }
get address() { return this._address; }
get ward() { return this._ward; }
get district() { return this._district; }
get city() { return this._city; }
get latitude() { return this._latitude; }
get longitude() { return this._longitude; }
get askingPriceVND() { return this._askingPriceVND; }
get aiEstimatePriceVND() { return this._aiEstimatePriceVND; }
get aiConfidence() { return this._aiConfidence; }
get pricingSource() { return this._pricingSource; }
get isNegotiable() { return this._isNegotiable; }
get areaM2() { return this._areaM2; }
get monthlyRentVND() { return this._monthlyRentVND; }
get depositMonths() { return this._depositMonths; }
get remainingLeaseMo() { return this._remainingLeaseMo; }
get businessType() { return this._businessType; }
get footTraffic() { return this._footTraffic; }
get media() { return this._media; }
get moderationScore() { return this._moderationScore; }
get moderationNotes() { return this._moderationNotes; }
get viewCount() { return this._viewCount; }
get saveCount() { return this._saveCount; }
get inquiryCount() { return this._inquiryCount; }
get contactPhone() { return this._contactPhone; }
get contactName() { return this._contactName; }
get featuredUntil() { return this._featuredUntil; }
get expiresAt() { return this._expiresAt; }
get publishedAt() { return this._publishedAt; }
updateDetails(props: Partial<TransferListingProps>): void {
if (props.sellerId !== undefined) this._sellerId = props.sellerId;
if (props.category !== undefined) this._category = props.category;
if (props.status !== undefined) this._status = props.status;
if (props.title !== undefined) this._title = props.title;
if (props.description !== undefined) this._description = props.description;
if (props.address !== undefined) this._address = props.address;
if (props.ward !== undefined) this._ward = props.ward;
if (props.district !== undefined) this._district = props.district;
if (props.city !== undefined) this._city = props.city;
if (props.latitude !== undefined) this._latitude = props.latitude;
if (props.longitude !== undefined) this._longitude = props.longitude;
if (props.askingPriceVND !== undefined) this._askingPriceVND = props.askingPriceVND;
if (props.aiEstimatePriceVND !== undefined) this._aiEstimatePriceVND = props.aiEstimatePriceVND;
if (props.aiConfidence !== undefined) this._aiConfidence = props.aiConfidence;
if (props.pricingSource !== undefined) this._pricingSource = props.pricingSource;
if (props.isNegotiable !== undefined) this._isNegotiable = props.isNegotiable;
if (props.areaM2 !== undefined) this._areaM2 = props.areaM2;
if (props.monthlyRentVND !== undefined) this._monthlyRentVND = props.monthlyRentVND;
if (props.depositMonths !== undefined) this._depositMonths = props.depositMonths;
if (props.remainingLeaseMo !== undefined) this._remainingLeaseMo = props.remainingLeaseMo;
if (props.businessType !== undefined) this._businessType = props.businessType;
if (props.footTraffic !== undefined) this._footTraffic = props.footTraffic;
if (props.media !== undefined) this._media = props.media;
if (props.moderationScore !== undefined) this._moderationScore = props.moderationScore;
if (props.moderationNotes !== undefined) this._moderationNotes = props.moderationNotes;
if (props.viewCount !== undefined) this._viewCount = props.viewCount;
if (props.saveCount !== undefined) this._saveCount = props.saveCount;
if (props.inquiryCount !== undefined) this._inquiryCount = props.inquiryCount;
if (props.contactPhone !== undefined) this._contactPhone = props.contactPhone;
if (props.contactName !== undefined) this._contactName = props.contactName;
if (props.featuredUntil !== undefined) this._featuredUntil = props.featuredUntil;
if (props.expiresAt !== undefined) this._expiresAt = props.expiresAt;
if (props.publishedAt !== undefined) this._publishedAt = props.publishedAt;
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,10 @@
export {
TRANSFER_LISTING_REPOSITORY,
type TransferListingSearchParams,
type PaginatedResult,
type TransferListingListItem,
type TransferItemData,
type TransferListingDetailData,
type TransferStatsData,
type ITransferListingRepository,
} from './transfer-listing.repository';

View File

@@ -0,0 +1,124 @@
import type { TransferCategory, TransferCondition, TransferListingStatus, TransferPricingSource } from '@prisma/client';
import type { CreateTransferItemInput } from '../../application/commands/create-transfer-listing/create-transfer-listing.command';
import type { TransferListingEntity } from '../entities/transfer-listing.entity';
export const TRANSFER_LISTING_REPOSITORY = Symbol('TRANSFER_LISTING_REPOSITORY');
export interface TransferListingSearchParams {
query?: string;
category?: TransferCategory;
status?: TransferListingStatus;
district?: string;
city?: string;
minPrice?: number;
maxPrice?: number;
sellerId?: string;
page?: number;
limit?: number;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface TransferListingListItem {
id: string;
sellerId: string;
category: TransferCategory;
status: TransferListingStatus;
title: string;
address: string;
district: string;
city: string;
latitude: number;
longitude: number;
askingPriceVND: bigint;
aiEstimatePriceVND: bigint | null;
pricingSource: TransferPricingSource;
isNegotiable: boolean;
areaM2: number | null;
media: Record<string, unknown>[] | null;
viewCount: number;
inquiryCount: number;
publishedAt: Date | null;
itemCount: number;
}
export interface TransferItemData {
id: string;
name: string;
brand: string | null;
modelName: string | null;
category: TransferCategory;
condition: TransferCondition;
purchaseYear: number | null;
originalPriceVND: bigint | null;
askingPriceVND: bigint;
aiEstimatePriceVND: bigint | null;
aiConfidence: number | null;
quantity: number;
dimensions: Record<string, unknown> | null;
media: Record<string, unknown>[] | null;
notes: string | null;
}
export interface TransferListingDetailData {
id: string;
sellerId: string;
category: TransferCategory;
status: TransferListingStatus;
title: string;
description: string | null;
address: string;
ward: string | null;
district: string;
city: string;
latitude: number;
longitude: number;
askingPriceVND: bigint;
aiEstimatePriceVND: bigint | null;
aiConfidence: number | null;
pricingSource: TransferPricingSource;
isNegotiable: boolean;
areaM2: number | null;
monthlyRentVND: bigint | null;
depositMonths: number | null;
remainingLeaseMo: number | null;
businessType: string | null;
footTraffic: string | null;
media: Record<string, unknown>[] | null;
moderationScore: number | null;
moderationNotes: string | null;
viewCount: number;
saveCount: number;
inquiryCount: number;
contactPhone: string | null;
contactName: string | null;
featuredUntil: Date | null;
expiresAt: Date | null;
publishedAt: Date | null;
items: TransferItemData[];
createdAt: Date;
updatedAt: Date;
}
export interface TransferStatsData {
totalListings: number;
totalValue: bigint;
byCategory: { category: string; count: number; avgPrice: number }[];
byDistrict: { district: string; count: number; avgPrice: number }[];
byStatus: { status: string; count: number }[];
}
export interface ITransferListingRepository {
findById(id: string): Promise<TransferListingEntity | null>;
findDetailById(id: string): Promise<TransferListingDetailData | null>;
save(entity: TransferListingEntity, items: CreateTransferItemInput[]): Promise<void>;
update(entity: TransferListingEntity): Promise<void>;
search(params: TransferListingSearchParams): Promise<PaginatedResult<TransferListingListItem>>;
getStats(): Promise<TransferStatsData>;
}

View File

@@ -0,0 +1,155 @@
import type { TransferCategory, TransferCondition } from '@prisma/client';
/**
* Depreciation curves by category (annual depreciation rate as fraction).
* Based on Vietnamese secondhand market data:
* - Furniture depreciates ~15-20% per year
* - Appliances ~20-25% (technology obsolescence)
* - Office equipment ~25-30%
* - Kitchen equipment ~15-20%
*/
const DEPRECIATION_RATES: Record<string, number> = {
FURNITURE: 0.18,
APPLIANCE: 0.22,
OFFICE_EQUIPMENT: 0.27,
KITCHEN: 0.18,
PREMISES: 0, // Premises don't depreciate the same way
FULL_UNIT: 0.15,
};
/**
* Condition multiplier — adjusts the depreciation-based estimate.
* NEW and LIKE_NEW items retain more value; WORN items lose extra.
*/
const CONDITION_MULTIPLIERS: Record<string, number> = {
NEW: 1.0,
LIKE_NEW: 0.92,
GOOD: 0.80,
FAIR: 0.65,
WORN: 0.45,
};
/**
* Brand tier multipliers — premium brands retain value better.
* Keys are lowercase brand names (partial match).
*/
const BRAND_TIERS: { keywords: string[]; multiplier: number }[] = [
// Premium tier — 1.3x value retention
{
keywords: [
'herman miller', 'steelcase', 'b&b italia', 'poliform', 'molteni',
'boffi', 'sub-zero', 'wolf', 'miele', 'gaggenau', 'smeg',
'dyson', 'bang & olufsen', 'lg signature',
],
multiplier: 1.30,
},
// Mid-premium tier — 1.15x
{
keywords: [
'ikea', 'muji', 'ashley', 'pottery barn', 'west elm',
'samsung', 'lg', 'panasonic', 'bosch', 'electrolux',
'daikin', 'mitsubishi', 'toshiba', 'hitachi',
'hòa phát', 'xuân hòa',
],
multiplier: 1.15,
},
// Standard tier — 1.0x (default, no adjustment)
];
/** Minimum floor price: 10% of original (items always have some scrap/resale value) */
const MIN_VALUE_RATIO = 0.10;
/** Maximum age for depreciation curve (beyond this, use floor price) */
const MAX_DEPRECIATION_YEARS = 10;
export interface FurniturePriceEstimate {
estimatedPriceVND: bigint;
confidence: number; // 0-1
factors: {
depreciationRate: number;
ageYears: number;
conditionMultiplier: number;
brandMultiplier: number;
depreciatedValue: number; // as fraction of original
};
}
export interface FurniturePricingInput {
category: TransferCategory;
condition: TransferCondition;
originalPriceVND: bigint;
purchaseYear: number;
brand?: string;
}
/**
* Estimate the current market value of a used furniture/appliance item.
*
* Formula: estimatedPrice = originalPrice × depreciatedValue × conditionMultiplier × brandMultiplier
* Where depreciatedValue = max(MIN_VALUE_RATIO, (1 - annualRate) ^ ageYears)
*/
export function estimateFurniturePrice(input: FurniturePricingInput): FurniturePriceEstimate {
const currentYear = new Date().getFullYear();
const ageYears = Math.max(0, currentYear - input.purchaseYear);
const annualRate = DEPRECIATION_RATES[input.category] ?? 0.20;
const conditionMult = CONDITION_MULTIPLIERS[input.condition] ?? 0.70;
const brandMult = getBrandMultiplier(input.brand);
// Exponential depreciation with floor
const rawDepreciated = Math.pow(1 - annualRate, Math.min(ageYears, MAX_DEPRECIATION_YEARS));
const depreciatedValue = Math.max(MIN_VALUE_RATIO, rawDepreciated);
const finalRatio = depreciatedValue * conditionMult * brandMult;
const originalNum = Number(input.originalPriceVND);
const estimatedNum = Math.round(originalNum * finalRatio);
// Confidence based on data quality
let confidence = 0.70; // base confidence
if (input.brand) confidence += 0.10; // known brand helps
if (input.purchaseYear > 0) confidence += 0.10; // known age helps
if (ageYears <= 5) confidence += 0.05; // recent items are more predictable
if (ageYears > MAX_DEPRECIATION_YEARS) confidence -= 0.15; // very old = less predictable
confidence = Math.max(0.30, Math.min(0.95, confidence));
return {
estimatedPriceVND: BigInt(estimatedNum),
confidence,
factors: {
depreciationRate: annualRate,
ageYears,
conditionMultiplier: conditionMult,
brandMultiplier: brandMult,
depreciatedValue: finalRatio,
},
};
}
/**
* Batch estimate prices for multiple items in a transfer listing.
*/
export function estimateTransferListingPrices(
items: FurniturePricingInput[],
): { estimates: FurniturePriceEstimate[]; totalEstimateVND: bigint; avgConfidence: number } {
const estimates = items.map((item) => estimateFurniturePrice(item));
const totalEstimateVND = estimates.reduce(
(sum, e) => sum + e.estimatedPriceVND,
BigInt(0),
);
const avgConfidence = estimates.length > 0
? estimates.reduce((sum, e) => sum + e.confidence, 0) / estimates.length
: 0;
return { estimates, totalEstimateVND, avgConfidence };
}
function getBrandMultiplier(brand?: string): number {
if (!brand) return 1.0;
const lower = brand.toLowerCase();
for (const tier of BRAND_TIERS) {
if (tier.keywords.some((kw) => lower.includes(kw))) {
return tier.multiplier;
}
}
return 1.0;
}

View File

@@ -0,0 +1,6 @@
export {
estimateFurniturePrice,
estimateTransferListingPrices,
type FurniturePriceEstimate,
type FurniturePricingInput,
} from './furniture-pricing.service';

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,10 @@
export { TransferModule } from './transfer.module';
export { TRANSFER_LISTING_REPOSITORY } from './domain/repositories/transfer-listing.repository';
export type {
ITransferListingRepository,
TransferListingDetailData,
TransferListingListItem,
TransferItemData,
TransferStatsData,
} from './domain/repositories/transfer-listing.repository';
export { TransferListingEntity, type TransferListingProps } from './domain/entities/transfer-listing.entity';

View File

@@ -0,0 +1 @@
export { PrismaTransferListingRepository } from './prisma-transfer-listing.repository';

View File

@@ -0,0 +1,447 @@
import { Injectable } from '@nestjs/common';
import { createId } from '@paralleldrive/cuid2';
import type { Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import type { CreateTransferItemInput } from '../../application/commands/create-transfer-listing/create-transfer-listing.command';
import { TransferListingEntity } from '../../domain/entities/transfer-listing.entity';
import type {
ITransferListingRepository,
TransferListingSearchParams,
PaginatedResult,
TransferListingListItem,
TransferListingDetailData,
TransferItemData,
TransferStatsData,
} from '../../domain/repositories/transfer-listing.repository';
interface RawTransferListing {
id: string;
sellerId: string;
category: string;
status: string;
title: string;
description: string | null;
address: string;
ward: string | null;
district: string;
city: string;
lat: number;
lng: number;
askingPriceVND: bigint;
aiEstimatePriceVND: bigint | null;
aiConfidence: number | null;
pricingSource: string;
isNegotiable: boolean;
areaM2: number | null;
monthlyRentVND: bigint | null;
depositMonths: number | null;
remainingLeaseMo: number | null;
businessType: string | null;
footTraffic: string | null;
media: Prisma.JsonValue;
moderationScore: number | null;
moderationNotes: string | null;
viewCount: number;
saveCount: number;
inquiryCount: number;
contactPhone: string | null;
contactName: string | null;
featuredUntil: Date | null;
expiresAt: Date | null;
publishedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
interface RawTransferListingWithCount extends RawTransferListing {
itemCount: number;
}
interface RawTransferItem {
id: string;
transferListingId: string;
name: string;
brand: string | null;
modelName: string | null;
category: string;
condition: string;
purchaseYear: number | null;
originalPriceVND: bigint | null;
askingPriceVND: bigint;
aiEstimatePriceVND: bigint | null;
aiConfidence: number | null;
quantity: number;
dimensions: Prisma.JsonValue;
media: Prisma.JsonValue;
notes: string | null;
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class PrismaTransferListingRepository implements ITransferListingRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<TransferListingEntity | null> {
const rows = await this.prisma.$queryRaw<RawTransferListing[]>`
SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
FROM "TransferListing" WHERE id = ${id} LIMIT 1
`;
return rows[0] ? this.toDomain(rows[0]) : null;
}
async findDetailById(id: string): Promise<TransferListingDetailData | null> {
const rows = await this.prisma.$queryRaw<RawTransferListing[]>`
SELECT *, ST_Y(location::geometry) as lat, ST_X(location::geometry) as lng
FROM "TransferListing" WHERE id = ${id} LIMIT 1
`;
if (!rows[0]) return null;
const items = await this.prisma.$queryRaw<RawTransferItem[]>`
SELECT * FROM "TransferItem" WHERE "transferListingId" = ${id}
`;
return this.toDetail(rows[0], items);
}
async save(entity: TransferListingEntity, items: CreateTransferItemInput[]): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`
INSERT INTO "TransferListing" (
id, "sellerId", category, status, title, description,
address, ward, district, city, location,
"askingPriceVND", "aiEstimatePriceVND", "aiConfidence", "pricingSource",
"isNegotiable", "areaM2", "monthlyRentVND", "depositMonths",
"remainingLeaseMo", "businessType", "footTraffic", media,
"moderationScore", "moderationNotes",
"viewCount", "saveCount", "inquiryCount",
"contactPhone", "contactName",
"featuredUntil", "expiresAt", "publishedAt",
"createdAt", "updatedAt"
) VALUES (
${entity.id}, ${entity.sellerId},
${entity.category}::"TransferCategory",
${entity.status}::"TransferListingStatus",
${entity.title}, ${entity.description},
${entity.address}, ${entity.ward}, ${entity.district}, ${entity.city},
ST_SetSRID(ST_MakePoint(${entity.longitude}, ${entity.latitude}), 4326),
${entity.askingPriceVND}, ${entity.aiEstimatePriceVND}, ${entity.aiConfidence},
${entity.pricingSource}::"TransferPricingSource",
${entity.isNegotiable}, ${entity.areaM2}, ${entity.monthlyRentVND},
${entity.depositMonths}, ${entity.remainingLeaseMo},
${entity.businessType}, ${entity.footTraffic},
${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
${entity.moderationScore}, ${entity.moderationNotes},
${entity.viewCount}, ${entity.saveCount}, ${entity.inquiryCount},
${entity.contactPhone}, ${entity.contactName},
${entity.featuredUntil}, ${entity.expiresAt}, ${entity.publishedAt},
${entity.createdAt}, ${entity.updatedAt}
)
`;
if (items.length > 0) {
const now = new Date();
for (const item of items) {
const itemId = createId();
await tx.$executeRaw`
INSERT INTO "TransferItem" (
id, "transferListingId", name, brand, "modelName",
category, condition, "purchaseYear",
"originalPriceVND", "askingPriceVND",
"aiEstimatePriceVND", "aiConfidence",
quantity, dimensions, media, notes,
"createdAt", "updatedAt"
) VALUES (
${itemId}, ${entity.id},
${item.name}, ${item.brand ?? null}, ${item.modelName ?? null},
${item.category}::"TransferCategory",
${item.condition}::"TransferCondition",
${item.purchaseYear ?? null},
${item.originalPriceVND ?? null}, ${item.askingPriceVND},
${null}::bigint, ${null}::float8,
${item.quantity ?? 1},
${item.dimensions ? JSON.stringify(item.dimensions) : null}::jsonb,
${null}::jsonb,
${item.notes ?? null},
${now}, ${now}
)
`;
}
}
});
}
async update(entity: TransferListingEntity): Promise<void> {
await this.prisma.$executeRaw`
UPDATE "TransferListing" SET
"sellerId" = ${entity.sellerId},
category = ${entity.category}::"TransferCategory",
status = ${entity.status}::"TransferListingStatus",
title = ${entity.title},
description = ${entity.description},
address = ${entity.address},
ward = ${entity.ward},
district = ${entity.district},
city = ${entity.city},
location = ST_SetSRID(ST_MakePoint(${entity.longitude}, ${entity.latitude}), 4326),
"askingPriceVND" = ${entity.askingPriceVND},
"aiEstimatePriceVND" = ${entity.aiEstimatePriceVND},
"aiConfidence" = ${entity.aiConfidence},
"pricingSource" = ${entity.pricingSource}::"TransferPricingSource",
"isNegotiable" = ${entity.isNegotiable},
"areaM2" = ${entity.areaM2},
"monthlyRentVND" = ${entity.monthlyRentVND},
"depositMonths" = ${entity.depositMonths},
"remainingLeaseMo" = ${entity.remainingLeaseMo},
"businessType" = ${entity.businessType},
"footTraffic" = ${entity.footTraffic},
media = ${entity.media ? JSON.stringify(entity.media) : null}::jsonb,
"moderationScore" = ${entity.moderationScore},
"moderationNotes" = ${entity.moderationNotes},
"viewCount" = ${entity.viewCount},
"saveCount" = ${entity.saveCount},
"inquiryCount" = ${entity.inquiryCount},
"contactPhone" = ${entity.contactPhone},
"contactName" = ${entity.contactName},
"featuredUntil" = ${entity.featuredUntil},
"expiresAt" = ${entity.expiresAt},
"publishedAt" = ${entity.publishedAt},
"updatedAt" = ${entity.updatedAt}
WHERE id = ${entity.id}
`;
}
async search(params: TransferListingSearchParams): Promise<PaginatedResult<TransferListingListItem>> {
const page = params.page ?? 1;
const limit = params.limit ?? 20;
const offset = (page - 1) * limit;
const conditions: string[] = ['1=1'];
const values: unknown[] = [];
let paramIndex = 1;
if (params.category) {
conditions.push(`category = $${paramIndex++}::"TransferCategory"`);
values.push(params.category);
}
if (params.status) {
conditions.push(`status = $${paramIndex++}::"TransferListingStatus"`);
values.push(params.status);
}
if (params.district) {
conditions.push(`district = $${paramIndex++}`);
values.push(params.district);
}
if (params.city) {
conditions.push(`city = $${paramIndex++}`);
values.push(params.city);
}
if (params.minPrice != null) {
conditions.push(`"askingPriceVND" >= $${paramIndex++}`);
values.push(params.minPrice);
}
if (params.maxPrice != null) {
conditions.push(`"askingPriceVND" <= $${paramIndex++}`);
values.push(params.maxPrice);
}
if (params.query) {
conditions.push(
`(title ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR address ILIKE $${paramIndex} OR district ILIKE $${paramIndex})`,
);
values.push(`%${params.query}%`);
paramIndex++;
}
const where = conditions.join(' AND ');
const countResult = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>(
`SELECT COUNT(*)::bigint as count FROM "TransferListing" WHERE ${where}`,
...values,
);
const total = Number(countResult[0].count);
const rows = await this.prisma.$queryRawUnsafe<RawTransferListingWithCount[]>(
`SELECT t.*, ST_Y(t.location::geometry) as lat, ST_X(t.location::geometry) as lng,
(SELECT COUNT(*) FROM "TransferItem" WHERE "transferListingId" = t.id)::int as "itemCount"
FROM "TransferListing" t WHERE ${where}
ORDER BY "publishedAt" DESC NULLS LAST, t."createdAt" DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
...values, limit, offset,
);
return {
data: rows.map((r) => this.toListItem(r)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async getStats(): Promise<TransferStatsData> {
const [summary] = await this.prisma.$queryRaw<[{ totalListings: bigint; totalValue: bigint }]>`
SELECT COUNT(*)::bigint as "totalListings",
COALESCE(SUM("askingPriceVND"), 0)::bigint as "totalValue"
FROM "TransferListing" WHERE status = 'ACTIVE'
`;
const byCategory = await this.prisma.$queryRaw<{ category: string; count: bigint; avgPrice: number }[]>`
SELECT category::text, COUNT(*)::bigint as count,
AVG("askingPriceVND"::numeric)::float as "avgPrice"
FROM "TransferListing" WHERE status = 'ACTIVE'
GROUP BY category ORDER BY count DESC
`;
const byDistrict = await this.prisma.$queryRaw<{ district: string; count: bigint; avgPrice: number }[]>`
SELECT district, COUNT(*)::bigint as count,
AVG("askingPriceVND"::numeric)::float as "avgPrice"
FROM "TransferListing" WHERE status = 'ACTIVE'
GROUP BY district ORDER BY count DESC
`;
const byStatus = await this.prisma.$queryRaw<{ status: string; count: bigint }[]>`
SELECT status::text, COUNT(*)::bigint as count
FROM "TransferListing" GROUP BY status ORDER BY count DESC
`;
return {
totalListings: Number(summary.totalListings),
totalValue: summary.totalValue,
byCategory: byCategory.map((r) => ({ category: r.category, count: Number(r.count), avgPrice: r.avgPrice })),
byDistrict: byDistrict.map((r) => ({ district: r.district, count: Number(r.count), avgPrice: r.avgPrice })),
byStatus: byStatus.map((r) => ({ status: r.status, count: Number(r.count) })),
};
}
private toDomain(row: RawTransferListing): TransferListingEntity {
return new TransferListingEntity(
row.id,
{
sellerId: row.sellerId,
category: row.category as TransferListingEntity['category'],
status: row.status as TransferListingEntity['status'],
title: row.title,
description: row.description,
address: row.address,
ward: row.ward,
district: row.district,
city: row.city,
latitude: Number(row.lat),
longitude: Number(row.lng),
askingPriceVND: row.askingPriceVND,
aiEstimatePriceVND: row.aiEstimatePriceVND,
aiConfidence: row.aiConfidence,
pricingSource: row.pricingSource as TransferListingEntity['pricingSource'],
isNegotiable: row.isNegotiable,
areaM2: row.areaM2,
monthlyRentVND: row.monthlyRentVND,
depositMonths: row.depositMonths,
remainingLeaseMo: row.remainingLeaseMo,
businessType: row.businessType,
footTraffic: row.footTraffic,
media: row.media as Record<string, unknown>[] | null,
moderationScore: row.moderationScore,
moderationNotes: row.moderationNotes,
viewCount: row.viewCount,
saveCount: row.saveCount,
inquiryCount: row.inquiryCount,
contactPhone: row.contactPhone,
contactName: row.contactName,
featuredUntil: row.featuredUntil,
expiresAt: row.expiresAt,
publishedAt: row.publishedAt,
},
row.createdAt,
row.updatedAt,
);
}
private toListItem(row: RawTransferListingWithCount): TransferListingListItem {
return {
id: row.id,
sellerId: row.sellerId,
category: row.category as TransferListingListItem['category'],
status: row.status as TransferListingListItem['status'],
title: row.title,
address: row.address,
district: row.district,
city: row.city,
latitude: Number(row.lat),
longitude: Number(row.lng),
askingPriceVND: row.askingPriceVND,
aiEstimatePriceVND: row.aiEstimatePriceVND,
pricingSource: row.pricingSource as TransferListingListItem['pricingSource'],
isNegotiable: row.isNegotiable,
areaM2: row.areaM2,
media: row.media as Record<string, unknown>[] | null,
viewCount: row.viewCount,
inquiryCount: row.inquiryCount,
publishedAt: row.publishedAt,
itemCount: row.itemCount,
};
}
private toDetail(row: RawTransferListing, items: RawTransferItem[]): TransferListingDetailData {
return {
id: row.id,
sellerId: row.sellerId,
category: row.category as TransferListingDetailData['category'],
status: row.status as TransferListingDetailData['status'],
title: row.title,
description: row.description,
address: row.address,
ward: row.ward,
district: row.district,
city: row.city,
latitude: Number(row.lat),
longitude: Number(row.lng),
askingPriceVND: row.askingPriceVND,
aiEstimatePriceVND: row.aiEstimatePriceVND,
aiConfidence: row.aiConfidence,
pricingSource: row.pricingSource as TransferListingDetailData['pricingSource'],
isNegotiable: row.isNegotiable,
areaM2: row.areaM2,
monthlyRentVND: row.monthlyRentVND,
depositMonths: row.depositMonths,
remainingLeaseMo: row.remainingLeaseMo,
businessType: row.businessType,
footTraffic: row.footTraffic,
media: row.media as Record<string, unknown>[] | null,
moderationScore: row.moderationScore,
moderationNotes: row.moderationNotes,
viewCount: row.viewCount,
saveCount: row.saveCount,
inquiryCount: row.inquiryCount,
contactPhone: row.contactPhone,
contactName: row.contactName,
featuredUntil: row.featuredUntil,
expiresAt: row.expiresAt,
publishedAt: row.publishedAt,
items: items.map((i) => this.toItemData(i)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private toItemData(row: RawTransferItem): TransferItemData {
return {
id: row.id,
name: row.name,
brand: row.brand,
modelName: row.modelName,
category: row.category as TransferItemData['category'],
condition: row.condition as TransferItemData['condition'],
purchaseYear: row.purchaseYear,
originalPriceVND: row.originalPriceVND,
askingPriceVND: row.askingPriceVND,
aiEstimatePriceVND: row.aiEstimatePriceVND,
aiConfidence: row.aiConfidence,
quantity: row.quantity,
dimensions: row.dimensions as Record<string, unknown> | null,
media: row.media as Record<string, unknown>[] | null,
notes: row.notes,
};
}
}

View File

@@ -0,0 +1 @@
export { TypesenseTransferService, TRANSFER_LISTINGS_COLLECTION } from './typesense-transfer.service';

View File

@@ -0,0 +1,183 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { type Client as TypesenseClient } from 'typesense';
import { type CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
import { type TypesenseClientService } from '@modules/search';
import { type LoggerService, type PrismaService } from '@modules/shared';
export const TRANSFER_LISTINGS_COLLECTION = 'transfer_listings';
const COLLECTION_SCHEMA: CollectionCreateSchema = {
name: TRANSFER_LISTINGS_COLLECTION,
enable_nested_fields: false,
token_separators: ['-', '_'],
fields: [
{ name: 'listingId', type: 'string', facet: false },
{ name: 'title', type: 'string', facet: false },
{ name: 'description', type: 'string', facet: false, optional: true },
{ name: 'category', type: 'string', facet: true },
{ name: 'status', type: 'string', facet: true },
{ name: 'district', type: 'string', facet: true },
{ name: 'city', type: 'string', facet: true },
{ name: 'address', type: 'string', facet: false },
{ name: 'askingPriceVND', type: 'int64', facet: false },
{ name: 'isNegotiable', type: 'bool', facet: true },
{ name: 'areaM2', type: 'float', facet: false, optional: true },
{ name: 'hasPremises', type: 'bool', facet: true },
{ name: 'itemCount', type: 'int32', facet: false },
{ name: 'location', type: 'geopoint', facet: false },
{ name: 'sellerId', type: 'string', facet: false },
{ name: 'viewCount', type: 'int32', facet: false },
{ name: 'inquiryCount', type: 'int32', facet: false },
{ name: 'publishedAt', type: 'int64', facet: false, optional: true },
{ name: 'createdAt', type: 'int64', facet: false },
],
};
interface RawTransferListingForSync {
id: string;
sellerId: string;
title: string;
description: string | null;
category: string;
status: string;
district: string;
city: string;
address: string;
askingPriceVND: bigint;
isNegotiable: boolean;
areaM2: number | null;
viewCount: number;
inquiryCount: number;
publishedAt: Date | null;
createdAt: Date;
lat: number;
lng: number;
itemCount: number;
}
@Injectable()
export class TypesenseTransferService implements OnModuleInit {
private client: TypesenseClient | null = null;
constructor(
private readonly typesenseClient: TypesenseClientService,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async onModuleInit(): Promise<void> {
try {
this.client = this.typesenseClient.getClient();
await this.ensureCollection();
await this.syncListings();
} catch (err) {
this.logger.warn(`Typesense transfer init failed (non-fatal): ${err}`, 'TypesenseTransfer');
}
}
private async ensureCollection(): Promise<void> {
if (!this.client) return;
try {
await this.client.collections(TRANSFER_LISTINGS_COLLECTION).retrieve();
this.logger.log(`Collection "${TRANSFER_LISTINGS_COLLECTION}" exists`, 'TypesenseTransfer');
} catch {
await this.client.collections().create(COLLECTION_SCHEMA);
this.logger.log(`Collection "${TRANSFER_LISTINGS_COLLECTION}" created`, 'TypesenseTransfer');
}
}
async syncListings(): Promise<void> {
if (!this.client) return;
const listings = await this.prisma.$queryRaw<RawTransferListingForSync[]>`
SELECT t.id, t."sellerId", t.title, t.description,
t.category::text, t.status::text,
t.district, t.city, t.address,
t."askingPriceVND", t."isNegotiable", t."areaM2",
t."viewCount", t."inquiryCount",
t."publishedAt", t."createdAt",
ST_Y(t.location::geometry) as lat, ST_X(t.location::geometry) as lng,
(SELECT COUNT(*) FROM "TransferItem" WHERE "transferListingId" = t.id)::int as "itemCount"
FROM "TransferListing" t
WHERE t.status = 'ACTIVE'
`;
if (listings.length === 0) return;
const docs = listings.map((l) => ({
id: l.id,
listingId: l.id,
title: l.title,
description: l.description ?? undefined,
category: l.category.toLowerCase(),
status: l.status.toLowerCase(),
district: l.district,
city: l.city,
address: l.address,
askingPriceVND: Number(l.askingPriceVND),
isNegotiable: l.isNegotiable,
areaM2: l.areaM2 ?? undefined,
hasPremises: (l.areaM2 ?? 0) > 0,
itemCount: l.itemCount,
location: [Number(l.lat), Number(l.lng)],
sellerId: l.sellerId,
viewCount: l.viewCount,
inquiryCount: l.inquiryCount,
publishedAt: l.publishedAt ? Math.floor(l.publishedAt.getTime() / 1000) : undefined,
createdAt: Math.floor(l.createdAt.getTime() / 1000),
}));
try {
const jsonl = docs.map((d) => JSON.stringify(d)).join('\n');
await this.client.collections(TRANSFER_LISTINGS_COLLECTION).documents().import(jsonl, { action: 'upsert' });
this.logger.log(`Synced ${docs.length} transfer listings to Typesense`, 'TypesenseTransfer');
} catch (err) {
this.logger.warn(`Transfer listing sync error: ${err}`, 'TypesenseTransfer');
}
}
async indexListing(listingId: string): Promise<void> {
if (!this.client) return;
const [listing] = await this.prisma.$queryRaw<RawTransferListingForSync[]>`
SELECT t.id, t."sellerId", t.title, t.description,
t.category::text, t.status::text,
t.district, t.city, t.address,
t."askingPriceVND", t."isNegotiable", t."areaM2",
t."viewCount", t."inquiryCount",
t."publishedAt", t."createdAt",
ST_Y(t.location::geometry) as lat, ST_X(t.location::geometry) as lng,
(SELECT COUNT(*) FROM "TransferItem" WHERE "transferListingId" = t.id)::int as "itemCount"
FROM "TransferListing" t
WHERE t.id = ${listingId}
`;
if (!listing) return;
const doc = {
id: listing.id,
listingId: listing.id,
title: listing.title,
description: listing.description ?? undefined,
category: listing.category.toLowerCase(),
status: listing.status.toLowerCase(),
district: listing.district,
city: listing.city,
address: listing.address,
askingPriceVND: Number(listing.askingPriceVND),
isNegotiable: listing.isNegotiable,
areaM2: listing.areaM2 ?? undefined,
hasPremises: (listing.areaM2 ?? 0) > 0,
itemCount: listing.itemCount,
location: [Number(listing.lat), Number(listing.lng)],
sellerId: listing.sellerId,
viewCount: listing.viewCount,
inquiryCount: listing.inquiryCount,
publishedAt: listing.publishedAt ? Math.floor(listing.publishedAt.getTime() / 1000) : undefined,
createdAt: Math.floor(listing.createdAt.getTime() / 1000),
};
await this.client.collections(TRANSFER_LISTINGS_COLLECTION).documents().upsert(doc);
}
}

View File

@@ -0,0 +1 @@
export { TransferController } from './transfer.controller';

View File

@@ -0,0 +1,155 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
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 { CreateTransferListingCommand } from '../../application/commands/create-transfer-listing/create-transfer-listing.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 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';
@ApiTags('transfer')
@Controller('transfer')
export class TransferController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
// ── Public endpoints ──────────────────────────────────────────────
@ApiOperation({ summary: 'Danh sách sang nhượng', description: 'Tìm kiếm và lọc tin sang nhượng' })
@ApiResponse({ status: 200, description: 'Danh sách tin sang nhượng phân trang' })
@Get('listings')
async listListings(@Query() dto: SearchTransferListingsDto) {
return this.queryBus.execute(
new ListTransferListingsQuery(
dto.q,
dto.category,
dto.status,
dto.district,
dto.city,
dto.minPrice,
dto.maxPrice,
undefined, // sellerId — only used for "my listings" queries
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiOperation({ summary: 'Chi tiết tin sang nhượng', description: 'Xem chi tiết tin sang nhượng theo ID' })
@ApiResponse({ status: 200, description: 'Thông tin chi tiết tin sang nhượng' })
@ApiResponse({ status: 404, description: 'Không tìm thấy tin sang nhượng' })
@Get('listings/:id')
async getListing(@Param('id') id: string) {
const result = await this.queryBus.execute(new GetTransferListingQuery(id));
if (!result) {
throw new NotFoundException('Transfer listing', id);
}
return result;
}
@ApiOperation({ summary: 'Thống kê sang nhượng', description: 'Tổng quan thống kê tin sang nhượng' })
@ApiResponse({ status: 200, description: 'Dữ liệu thống kê' })
@Get('stats')
async getStats() {
return this.queryBus.execute(new TransferStatsQuery());
}
@ApiOperation({ summary: 'Ước tính giá AI', description: 'Ước tính giá trị nội thất/thiết bị dựa trên khấu hao, thương hiệu và tình trạng' })
@ApiResponse({ status: 200, description: 'Kết quả ước tính giá' })
@Post('estimate')
async estimatePrices(@Body() dto: EstimateTransferPricesDto) {
return this.commandBus.execute(
new EstimateTransferPricesCommand(dto.items),
);
}
// ── Authenticated endpoints ───────────────────────────────────────
@ApiOperation({ summary: 'Tạo tin sang nhượng', description: 'Đăng tin sang nhượng mới' })
@ApiResponse({ status: 201, description: 'Tin sang nhượng đã tạo' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Post('listings')
async createListing(
@CurrentUser() user: { sub: string },
@Body() dto: CreateTransferListingDto,
) {
return this.commandBus.execute(
new CreateTransferListingCommand(
user.sub,
dto.category,
dto.title,
dto.description ?? null,
dto.address,
dto.ward ?? null,
dto.district,
dto.city,
dto.latitude,
dto.longitude,
BigInt(Math.round(dto.askingPriceVND)),
dto.pricingSource ?? 'MANUAL',
dto.isNegotiable ?? true,
dto.areaM2 ?? null,
dto.monthlyRentVND ? BigInt(Math.round(dto.monthlyRentVND)) : null,
dto.depositMonths ?? null,
dto.remainingLeaseMo ?? null,
dto.businessType ?? null,
dto.footTraffic ?? null,
dto.contactPhone ?? null,
dto.contactName ?? null,
(dto.items ?? []).map((item) => ({
name: item.name,
brand: item.brand,
modelName: item.modelName,
category: item.category,
condition: item.condition,
purchaseYear: item.purchaseYear,
originalPriceVND: item.originalPriceVND != null ? BigInt(Math.round(item.originalPriceVND)) : undefined,
askingPriceVND: BigInt(Math.round(item.askingPriceVND)),
quantity: item.quantity,
dimensions: item.dimensions,
notes: item.notes,
})),
),
);
}
@ApiOperation({ summary: 'Cập nhật tin sang nhượng', description: 'Cập nhật thông tin tin sang nhượng' })
@ApiResponse({ status: 200, description: 'Tin sang nhượng đã cập nhật' })
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Patch('listings/:id')
async updateListing(
@Param('id') id: string,
@Body() dto: UpdateTransferListingDto,
) {
return this.commandBus.execute(
new UpdateTransferListingCommand(
id,
dto.title,
dto.description,
dto.status,
dto.askingPriceVND ? BigInt(Math.round(dto.askingPriceVND)) : undefined,
dto.isNegotiable,
dto.areaM2,
dto.monthlyRentVND !== undefined ? BigInt(Math.round(dto.monthlyRentVND)) : undefined,
dto.depositMonths,
dto.remainingLeaseMo,
dto.businessType,
dto.footTraffic,
dto.contactPhone,
dto.contactName,
dto.media,
),
);
}
}

View File

@@ -0,0 +1,169 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { TransferCategory, TransferCondition, TransferPricingSource } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsEnum, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
export class CreateTransferItemDto {
@ApiProperty()
@IsNotEmpty()
@IsString()
name!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
brand?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
modelName?: string;
@ApiProperty({ enum: TransferCategory })
@IsEnum(TransferCategory)
category!: TransferCategory;
@ApiProperty({ enum: TransferCondition })
@IsEnum(TransferCondition)
condition!: TransferCondition;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Type(() => Number)
purchaseYear?: number;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
originalPriceVND?: number;
@ApiProperty()
@IsNumber()
@Type(() => Number)
askingPriceVND!: number;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsInt()
@Type(() => Number)
quantity?: number;
@ApiPropertyOptional()
@IsOptional()
dimensions?: Record<string, unknown>;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
export class CreateTransferListingDto {
@ApiProperty({ enum: TransferCategory })
@IsEnum(TransferCategory)
category!: TransferCategory;
@ApiProperty()
@IsNotEmpty()
@IsString()
title!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
address!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
ward?: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
district!: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
city!: string;
@ApiProperty()
@IsNumber()
latitude!: number;
@ApiProperty()
@IsNumber()
longitude!: number;
@ApiProperty()
@IsNumber()
@Type(() => Number)
askingPriceVND!: number;
@ApiPropertyOptional({ enum: TransferPricingSource, default: 'MANUAL' })
@IsOptional()
@IsEnum(TransferPricingSource)
pricingSource?: TransferPricingSource;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isNegotiable?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
areaM2?: number;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
monthlyRentVND?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Type(() => Number)
depositMonths?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Type(() => Number)
remainingLeaseMo?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
businessType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
footTraffic?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
contactPhone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
contactName?: string;
@ApiPropertyOptional({ type: [CreateTransferItemDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateTransferItemDto)
items?: CreateTransferItemDto[];
}

View File

@@ -0,0 +1,37 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { TransferCategory, TransferCondition } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
export class EstimateItemDto {
@ApiProperty({ enum: TransferCategory })
@IsEnum(TransferCategory)
category!: TransferCategory;
@ApiProperty({ enum: TransferCondition })
@IsEnum(TransferCondition)
condition!: TransferCondition;
@ApiProperty({ description: 'Giá mua ban đầu (VND)' })
@IsNumber()
@Type(() => Number)
originalPriceVND!: number;
@ApiProperty({ description: 'Năm mua' })
@IsInt()
@Type(() => Number)
purchaseYear!: number;
@ApiPropertyOptional({ description: 'Thương hiệu' })
@IsOptional()
@IsString()
brand?: string;
}
export class EstimateTransferPricesDto {
@ApiProperty({ type: [EstimateItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => EstimateItemDto)
items!: EstimateItemDto[];
}

View File

@@ -0,0 +1,4 @@
export { SearchTransferListingsDto } from './search-transfer-listings.dto';
export { CreateTransferListingDto, CreateTransferItemDto } from './create-transfer-listing.dto';
export { UpdateTransferListingDto } from './update-transfer-listing.dto';
export { EstimateTransferPricesDto, EstimateItemDto } from './estimate-transfer-prices.dto';

View File

@@ -0,0 +1,56 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { TransferCategory, TransferListingStatus } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class SearchTransferListingsDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ enum: TransferCategory })
@IsOptional()
@IsEnum(TransferCategory)
category?: TransferCategory;
@ApiPropertyOptional({ enum: TransferListingStatus })
@IsOptional()
@IsEnum(TransferListingStatus)
status?: TransferListingStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
district?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
city?: string;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
minPrice?: number;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
maxPrice?: number;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number;
}

View File

@@ -0,0 +1,79 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { TransferListingStatus } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpdateTransferListingDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ enum: TransferListingStatus })
@IsOptional()
@IsEnum(TransferListingStatus)
status?: TransferListingStatus;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
askingPriceVND?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isNegotiable?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Type(() => Number)
areaM2?: number;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
monthlyRentVND?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Type(() => Number)
depositMonths?: number;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Type(() => Number)
remainingLeaseMo?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
businessType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
footTraffic?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
contactPhone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
contactName?: string;
@ApiPropertyOptional()
@IsOptional()
media?: Record<string, unknown>[];
}

View File

@@ -0,0 +1,38 @@
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 { 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';
import { ListTransferListingsHandler } from './application/queries/list-transfer-listings/list-transfer-listings.handler';
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 { TypesenseTransferService } from './infrastructure/services/typesense-transfer.service';
import { TransferController } from './presentation/controllers/transfer.controller';
const CommandHandlers = [
CreateTransferListingHandler,
EstimateTransferPricesHandler,
UpdateTransferListingHandler,
];
const QueryHandlers = [
GetTransferListingHandler,
ListTransferListingsHandler,
TransferStatsHandler,
];
@Module({
imports: [CqrsModule, SearchModule],
controllers: [TransferController],
providers: [
{ provide: TRANSFER_LISTING_REPOSITORY, useClass: PrismaTransferListingRepository },
TypesenseTransferService,
...CommandHandlers,
...QueryHandlers,
],
exports: [TRANSFER_LISTING_REPOSITORY, TypesenseTransferService],
})
export class TransferModule {}