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:
@@ -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[],
|
||||
) {}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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[],
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EstimateTransferPricesCommand, type EstimateItemInput } from './estimate-transfer-prices.command';
|
||||
export { EstimateTransferPricesHandler } from './estimate-transfer-prices.handler';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './create-transfer-listing';
|
||||
export * from './update-transfer-listing';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { UpdateTransferListingCommand } from './update-transfer-listing.command';
|
||||
export { UpdateTransferListingHandler } from './update-transfer-listing.handler';
|
||||
@@ -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>[],
|
||||
) {}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class GetTransferListingQuery {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GetTransferListingQuery } from './get-transfer-listing.query';
|
||||
export { GetTransferListingHandler } from './get-transfer-listing.handler';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './list-transfer-listings';
|
||||
export * from './get-transfer-listing';
|
||||
export * from './transfer-stats';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ListTransferListingsQuery } from './list-transfer-listings.query';
|
||||
export { ListTransferListingsHandler } from './list-transfer-listings.handler';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TransferStatsQuery } from './transfer-stats.query';
|
||||
export { TransferStatsHandler } from './transfer-stats.handler';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export class TransferStatsQuery {}
|
||||
1
apps/api/src/modules/transfer/domain/entities/index.ts
Normal file
1
apps/api/src/modules/transfer/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TransferListingEntity, type TransferListingProps } from './transfer-listing.entity';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
1
apps/api/src/modules/transfer/domain/events/index.ts
Normal file
1
apps/api/src/modules/transfer/domain/events/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
10
apps/api/src/modules/transfer/domain/repositories/index.ts
Normal file
10
apps/api/src/modules/transfer/domain/repositories/index.ts
Normal 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';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
6
apps/api/src/modules/transfer/domain/services/index.ts
Normal file
6
apps/api/src/modules/transfer/domain/services/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
estimateFurniturePrice,
|
||||
estimateTransferListingPrices,
|
||||
type FurniturePriceEstimate,
|
||||
type FurniturePricingInput,
|
||||
} from './furniture-pricing.service';
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
10
apps/api/src/modules/transfer/index.ts
Normal file
10
apps/api/src/modules/transfer/index.ts
Normal 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';
|
||||
@@ -0,0 +1 @@
|
||||
export { PrismaTransferListingRepository } from './prisma-transfer-listing.repository';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TypesenseTransferService, TRANSFER_LISTINGS_COLLECTION } from './typesense-transfer.service';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TransferController } from './transfer.controller';
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
4
apps/api/src/modules/transfer/presentation/dto/index.ts
Normal file
4
apps/api/src/modules/transfer/presentation/dto/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>[];
|
||||
}
|
||||
38
apps/api/src/modules/transfer/transfer.module.ts
Normal file
38
apps/api/src/modules/transfer/transfer.module.ts
Normal 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 {}
|
||||
Reference in New Issue
Block a user