fix(api,web): runtime fixes found during E2E + DB seed repair
Some checks failed
Security Scanning / Trivy Scan — API Image (push) Failing after 53s
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has started running
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 58s
Deploy / Build API Image (push) Failing after 18s
Deploy / Build Web Image (push) Failing after 7s
CI / E2E Tests (push) Has been skipped
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 16s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Some checks failed
Security Scanning / Trivy Scan — API Image (push) Failing after 53s
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has started running
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 58s
Deploy / Build API Image (push) Failing after 18s
Deploy / Build Web Image (push) Failing after 7s
CI / E2E Tests (push) Has been skipped
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 16s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
API bootstrap fixes (DI wiring):
- analytics.module: add forwardRef(() => AdminModule) to import
AI_CONFIG_PROVIDER for GetListingAiAdviceHandler + GetProjectAiAdviceHandler
- listings.module: add PaymentsModule to imports so PAYMENT_INITIATOR is
resolvable by FeatureListingHandler
- metrics.module: register 3 missing Prometheus providers that MetricsService
injects (READ_MODEL_PROJECTOR_LAG_SECONDS / REFRESH_DURATION /
RECONCILIATION_DRIFT_TOTAL) — caused boot failure previously
- get-listing-ai-advice.handler: switch LISTING_REPOSITORY import from barrel
@modules/listings to direct internal path to break circular reference that
made the symbol evaluate as undefined at decorator time
- shared.module: comment out broken EVENT_BUS / OutboxService / OutboxRelay
providers (depend on @goodgo/contracts-events workspace pkg not yet wired)
CSRF middleware:
- Rewrite exclude logic as inline path-check inside the middleware itself.
Nest 11 + path-to-regexp v8 changed how MiddlewareConsumer.exclude() matches
against forRoutes('*') — the previous string patterns silently stopped
matching, causing every POST to /auth/login to return 403 CSRF Forbidden.
Inlined exempt list strips the /api/v1 prefix and checks against a Set.
Admin revenue stats:
- admin-stats.queries: use Prisma.sql template fragments for DATE_TRUNC unit
('day'|'month'). Passing the unit as a bind parameter caused Postgres error
42803 (column must appear in GROUP BY) because the planner treats $1 as an
opaque scalar and cannot prove SELECT and GROUP BY expressions are equal.
Admin audit-log page:
- SeverityPill: add ?? 'info' fallback — backend AuditLogEntry does not
include a `severity` field, so SEVERITY_CONFIG[undefined] was undefined
and .dir threw TypeError, crashing the whole audit-log page.
DB seed fixes:
- seed.ts: replace Vietnamese enum literals ('Sổ hồng', 'Sổ đỏ') with
correct enum keys ('SO_HONG', 'SO_DO') for the LegalStatus column
- seed-industrial-parks.ts: gate the standalone main() behind
require.main === module so importing the file from seed.ts doesn't
immediately close the pg.Pool used by the orchestrator
- scripts/seed-industrial-listings.ts: restore from tmp/ stash; was missing
from scripts/ causing seed.ts import to fail at startup
- migration 20260429010000_add_property_certificate_verified: Property table
was missing the certificateVerified column required by seed + Prisma schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { type PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type DashboardStats,
|
type DashboardStats,
|
||||||
@@ -80,7 +81,12 @@ export async function getRevenueStats(
|
|||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncUnit = groupBy === 'day' ? 'day' : 'month';
|
// Postgres can't prove that `DATE_TRUNC($n, ...)` in SELECT and in GROUP BY
|
||||||
|
// are the same expression when the first argument is a bind parameter — it
|
||||||
|
// raises "column must appear in the GROUP BY clause" (42803). Inline the
|
||||||
|
// unit as a raw fragment instead. `groupBy` is already constrained to the
|
||||||
|
// 'day' | 'month' union so this is safe from injection.
|
||||||
|
const truncUnit = groupBy === 'day' ? Prisma.sql`'day'` : Prisma.sql`'month'`;
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<RevenueRawRow[]>`
|
const rows = await prisma.$queryRaw<RevenueRawRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
|
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
|
||||||
|
import { AdminModule } from '@modules/admin';
|
||||||
import { ListingsModule } from '@modules/listings';
|
import { ListingsModule } from '@modules/listings';
|
||||||
import { ProjectsModule } from '@modules/projects';
|
import { ProjectsModule } from '@modules/projects';
|
||||||
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||||
@@ -84,7 +85,12 @@ const EventHandlers = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CqrsModule, forwardRef(() => ListingsModule), ProjectsModule],
|
imports: [
|
||||||
|
CqrsModule,
|
||||||
|
forwardRef(() => ListingsModule),
|
||||||
|
ProjectsModule,
|
||||||
|
forwardRef(() => AdminModule), // for AI_CONFIG_PROVIDER (used by AI advice handlers)
|
||||||
|
],
|
||||||
controllers: [AnalyticsController, AvmController],
|
controllers: [AnalyticsController, AvmController],
|
||||||
providers: [
|
providers: [
|
||||||
// AI service client
|
// AI service client
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { HttpStatus, Inject } from '@nestjs/common';
|
import { HttpStatus, Inject } from '@nestjs/common';
|
||||||
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
// Direct internal path: barrel `@modules/listings` exports `ListingsModule`
|
||||||
|
// first, which transitively imports the analytics handler back here. At
|
||||||
|
// constructor-decorator evaluation time the barrel has not yet exported
|
||||||
|
// `LISTING_REPOSITORY`, so DI resolves it as `undefined`.
|
||||||
|
// eslint-disable-next-line no-restricted-imports -- circular-import workaround; see comment above
|
||||||
import {
|
import {
|
||||||
LISTING_REPOSITORY,
|
LISTING_REPOSITORY,
|
||||||
type IListingRepository,
|
type IListingRepository,
|
||||||
} from '@modules/listings';
|
} from '@modules/listings/domain/repositories/listing.repository';
|
||||||
import {
|
import {
|
||||||
AI_CONFIG_PROVIDER,
|
AI_CONFIG_PROVIDER,
|
||||||
DomainException,
|
DomainException,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { forwardRef, Module } from '@nestjs/common';
|
|||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { MulterModule } from '@nestjs/platform-express';
|
import { MulterModule } from '@nestjs/platform-express';
|
||||||
import { AnalyticsModule } from '@modules/analytics';
|
import { AnalyticsModule } from '@modules/analytics';
|
||||||
|
import { PaymentsModule } from '@modules/payments';
|
||||||
import { FeatureListingThrottlerGuard } from '@modules/shared';
|
import { FeatureListingThrottlerGuard } from '@modules/shared';
|
||||||
import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
|
import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
|
||||||
import { BulkUpdateListingsHandler } from './application/commands/bulk-update-listings/bulk-update-listings.handler';
|
import { BulkUpdateListingsHandler } from './application/commands/bulk-update-listings/bulk-update-listings.handler';
|
||||||
@@ -68,6 +69,7 @@ const EventHandlers = [
|
|||||||
imports: [
|
imports: [
|
||||||
CqrsModule,
|
CqrsModule,
|
||||||
forwardRef(() => AnalyticsModule),
|
forwardRef(() => AnalyticsModule),
|
||||||
|
PaymentsModule, // for PAYMENT_INITIATOR (used by FeatureListingHandler)
|
||||||
MulterModule.register({
|
MulterModule.register({
|
||||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB — per-type limits enforced by FileValidationPipe
|
limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB — per-type limits enforced by FileValidationPipe
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import {
|
|||||||
SEARCH_QUERY_DURATION,
|
SEARCH_QUERY_DURATION,
|
||||||
GOODGO_WS_CONNECTED_CLIENTS,
|
GOODGO_WS_CONNECTED_CLIENTS,
|
||||||
GOODGO_WS_MESSAGES_TOTAL,
|
GOODGO_WS_MESSAGES_TOTAL,
|
||||||
|
READ_MODEL_PROJECTOR_LAG_SECONDS,
|
||||||
|
READ_MODEL_REFRESH_DURATION_SECONDS,
|
||||||
|
READ_MODEL_RECONCILIATION_DRIFT_TOTAL,
|
||||||
WEB_VITALS_LCP,
|
WEB_VITALS_LCP,
|
||||||
WEB_VITALS_FCP,
|
WEB_VITALS_FCP,
|
||||||
WEB_VITALS_CLS,
|
WEB_VITALS_CLS,
|
||||||
@@ -111,6 +114,24 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
|
|||||||
labelNames: ['namespace', 'event', 'direction'],
|
labelNames: ['namespace', 'event', 'direction'],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ── Read-Model Metrics (RFC-003) ──
|
||||||
|
makeGaugeProvider({
|
||||||
|
name: READ_MODEL_PROJECTOR_LAG_SECONDS,
|
||||||
|
help: 'Projector replication lag in seconds, by read model',
|
||||||
|
labelNames: ['read_model'],
|
||||||
|
}),
|
||||||
|
makeHistogramProvider({
|
||||||
|
name: READ_MODEL_REFRESH_DURATION_SECONDS,
|
||||||
|
help: 'Materialized-view refresh duration in seconds',
|
||||||
|
labelNames: ['read_model'],
|
||||||
|
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60],
|
||||||
|
}),
|
||||||
|
makeCounterProvider({
|
||||||
|
name: READ_MODEL_RECONCILIATION_DRIFT_TOTAL,
|
||||||
|
help: 'Drift events detected during read-model reconciliation',
|
||||||
|
labelNames: ['read_model', 'severity'],
|
||||||
|
}),
|
||||||
|
|
||||||
// ── Services & Interceptors ──
|
// ── Services & Interceptors ──
|
||||||
MetricsService,
|
MetricsService,
|
||||||
HttpMetricsInterceptor,
|
HttpMetricsInterceptor,
|
||||||
|
|||||||
@@ -8,6 +8,47 @@ const TOKEN_LENGTH = 32;
|
|||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes that bootstrap a session (or accept beacons that can't carry a
|
||||||
|
* CSRF header) and are therefore exempt from the double-submit check.
|
||||||
|
* Matched against the request path with the API global prefix stripped.
|
||||||
|
*
|
||||||
|
* NOTE: We check inside the middleware instead of relying on
|
||||||
|
* `MiddlewareConsumer.exclude(...)` because Nest 11 + path-to-regexp v8
|
||||||
|
* changed how `forRoutes('*')` interacts with prefixed excludes — patterns
|
||||||
|
* that used to work (e.g. `'auth/login'`) silently no longer match.
|
||||||
|
*/
|
||||||
|
const EXEMPT_POST_PATHS = new Set<string>([
|
||||||
|
'/auth/login',
|
||||||
|
'/auth/register',
|
||||||
|
'/auth/refresh',
|
||||||
|
'/auth/logout',
|
||||||
|
'/auth/exchange-token',
|
||||||
|
'/auth/forgot-password',
|
||||||
|
'/auth/reset-password',
|
||||||
|
'/web-vitals',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EXEMPT_POST_PREFIXES: ReadonlyArray<string> = [
|
||||||
|
'/payments/callback/',
|
||||||
|
];
|
||||||
|
|
||||||
|
function stripApiPrefix(url: string): string {
|
||||||
|
// Only the path matters for matching — drop the query string.
|
||||||
|
const path = url.split('?')[0] ?? url;
|
||||||
|
if (path.startsWith('/api/v1')) {
|
||||||
|
return path.slice('/api/v1'.length) || '/';
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExempt(method: string, url: string): boolean {
|
||||||
|
if (method !== 'POST') return false;
|
||||||
|
const path = stripApiPrefix(url);
|
||||||
|
if (EXEMPT_POST_PATHS.has(path)) return true;
|
||||||
|
return EXEMPT_POST_PREFIXES.some((prefix) => path.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CsrfMiddleware implements NestMiddleware {
|
export class CsrfMiddleware implements NestMiddleware {
|
||||||
use(req: Request, res: Response, next: NextFunction): void {
|
use(req: Request, res: Response, next: NextFunction): void {
|
||||||
@@ -17,6 +58,13 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bootstrap + sendBeacon endpoints — never check, but still plant the
|
||||||
|
// cookie so the next request from the same client can pass validation.
|
||||||
|
if (isExempt(req.method, req.originalUrl ?? req.url)) {
|
||||||
|
this.ensureCsrfCookie(req, res);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
// State-changing methods: validate the double-submit token
|
// State-changing methods: validate the double-submit token
|
||||||
const cookieToken = req.cookies?.[CSRF_COOKIE] as string | undefined;
|
const cookieToken = req.cookies?.[CSRF_COOKIE] as string | undefined;
|
||||||
const headerToken = req.headers[CSRF_HEADER] as string | undefined;
|
const headerToken = req.headers[CSRF_HEADER] as string | undefined;
|
||||||
|
|||||||
@@ -90,14 +90,29 @@ export class SharedModule implements NestModule {
|
|||||||
consumer
|
consumer
|
||||||
.apply(CsrfMiddleware)
|
.apply(CsrfMiddleware)
|
||||||
.exclude(
|
.exclude(
|
||||||
{ path: 'payments/callback/(.*)', method: RequestMethod.POST },
|
// NOTE: Nest 11 + path-to-regexp v8 matches `forRoutes('*')`
|
||||||
|
// middleware exclude paths against the FULL request URL — i.e.
|
||||||
|
// including the global prefix `api/v1`. Listing both forms keeps
|
||||||
|
// the rule resilient if the prefix or matching mode changes.
|
||||||
|
{ path: 'api/v1/payments/callback/*path', method: RequestMethod.POST },
|
||||||
|
{ path: 'api/v1/auth/login', method: RequestMethod.POST },
|
||||||
|
{ path: 'api/v1/auth/register', method: RequestMethod.POST },
|
||||||
|
{ path: 'api/v1/auth/refresh', method: RequestMethod.POST },
|
||||||
|
{ path: 'api/v1/auth/exchange-token', method: RequestMethod.POST },
|
||||||
|
{ path: 'api/v1/auth/logout', method: RequestMethod.POST },
|
||||||
|
{ path: 'api/v1/auth/forgot-password', method: RequestMethod.POST },
|
||||||
|
{ path: 'api/v1/auth/reset-password', method: RequestMethod.POST },
|
||||||
|
{ path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers
|
||||||
|
// Legacy controller-relative forms (kept for older path-matching modes).
|
||||||
{ path: 'auth/login', method: RequestMethod.POST },
|
{ path: 'auth/login', method: RequestMethod.POST },
|
||||||
{ path: 'auth/register', method: RequestMethod.POST },
|
{ path: 'auth/register', method: RequestMethod.POST },
|
||||||
{ path: 'auth/refresh', method: RequestMethod.POST },
|
{ path: 'auth/refresh', method: RequestMethod.POST },
|
||||||
{ path: 'auth/exchange-token', method: RequestMethod.POST },
|
{ path: 'auth/exchange-token', method: RequestMethod.POST },
|
||||||
{ path: 'auth/logout', method: RequestMethod.POST },
|
{ path: 'auth/logout', method: RequestMethod.POST },
|
||||||
{ path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers
|
{ path: 'auth/forgot-password', method: RequestMethod.POST },
|
||||||
{ path: 'web-vitals', method: RequestMethod.POST }, // middleware exclude uses controller-relative path
|
{ path: 'auth/reset-password', method: RequestMethod.POST },
|
||||||
|
{ path: 'web-vitals', method: RequestMethod.POST },
|
||||||
|
{ path: 'payments/callback/*path', method: RequestMethod.POST },
|
||||||
)
|
)
|
||||||
.forRoutes('*');
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,11 @@ const MODULE_LABELS: Record<string, string> = {
|
|||||||
moderation: 'Kiểm duyệt',
|
moderation: 'Kiểm duyệt',
|
||||||
};
|
};
|
||||||
|
|
||||||
function SeverityPill({ severity }: { severity: AuditLogItem['severity'] }) {
|
function SeverityPill({ severity }: { severity: AuditLogItem['severity'] | undefined }) {
|
||||||
const cfg = SEVERITY_CONFIG[severity];
|
// The backend doesn't always populate `severity` (only the moderation
|
||||||
|
// audit log enriches it). Fall back to `info` so the pill renders rather
|
||||||
|
// than crashing the whole page when an entry omits the field.
|
||||||
|
const cfg = SEVERITY_CONFIG[severity ?? 'info'] ?? SEVERITY_CONFIG.info;
|
||||||
return <Signal direction={cfg.dir} label={cfg.label} />;
|
return <Signal direction={cfg.dir} label={cfg.label} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add Property.certificateVerified flag — true when the listing's owner has
|
||||||
|
-- uploaded an ownership certificate (red book / pink book) that has passed
|
||||||
|
-- moderation. Defaults to false so existing rows keep current behaviour.
|
||||||
|
|
||||||
|
ALTER TABLE "Property"
|
||||||
|
ADD COLUMN "certificateVerified" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -357,16 +357,16 @@ async function seedProperties() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const properties: PropSeed[] = [
|
const properties: PropSeed[] = [
|
||||||
{ id: 'seed-prop-001', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Vinhomes Central Park 3PN view sông Sài Gòn', description: 'Căn hộ 3 phòng ngủ tại Vinhomes Central Park, tầng cao view sông Sài Gòn tuyệt đẹp. Full nội thất cao cấp Châu Âu, tiện ích 5 sao.', address: '208 Nguyễn Hữu Cảnh', ward: 'Phường 22', district: 'Quận Bình Thạnh', city: 'Hồ Chí Minh', lat: 10.7942, lng: 106.7214, areaM2: 108, usableAreaM2: 95, bedrooms: 3, bathrooms: 2, floor: 25, totalFloors: 50, direction: Direction.SOUTHEAST, yearBuilt: 2018, legalStatus: 'Sổ hồng', projectName: 'Vinhomes Central Park', amenities: '["hồ bơi","gym","công viên","siêu thị"]' },
|
{ id: 'seed-prop-001', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Vinhomes Central Park 3PN view sông Sài Gòn', description: 'Căn hộ 3 phòng ngủ tại Vinhomes Central Park, tầng cao view sông Sài Gòn tuyệt đẹp. Full nội thất cao cấp Châu Âu, tiện ích 5 sao.', address: '208 Nguyễn Hữu Cảnh', ward: 'Phường 22', district: 'Quận Bình Thạnh', city: 'Hồ Chí Minh', lat: 10.7942, lng: 106.7214, areaM2: 108, usableAreaM2: 95, bedrooms: 3, bathrooms: 2, floor: 25, totalFloors: 50, direction: Direction.SOUTHEAST, yearBuilt: 2018, legalStatus: 'SO_HONG' as const, projectName: 'Vinhomes Central Park', amenities: '["hồ bơi","gym","công viên","siêu thị"]' },
|
||||||
{ id: 'seed-prop-002', propertyType: PropertyType.APARTMENT, title: 'Căn hộ The Sun Avenue 2PN cho thuê gần Metro', description: 'Cho thuê căn hộ 2PN The Sun Avenue, nội thất đầy đủ, gần tuyến Metro số 1.', address: '28 Mai Chí Thọ', ward: 'An Phú', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.7696, lng: 106.7511, areaM2: 76, usableAreaM2: 68, bedrooms: 2, bathrooms: 2, floor: 15, totalFloors: 28, direction: Direction.NORTH, yearBuilt: 2020, legalStatus: 'Sổ hồng', projectName: 'The Sun Avenue', amenities: '["hồ bơi","gym","BBQ"]' },
|
{ id: 'seed-prop-002', propertyType: PropertyType.APARTMENT, title: 'Căn hộ The Sun Avenue 2PN cho thuê gần Metro', description: 'Cho thuê căn hộ 2PN The Sun Avenue, nội thất đầy đủ, gần tuyến Metro số 1.', address: '28 Mai Chí Thọ', ward: 'An Phú', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.7696, lng: 106.7511, areaM2: 76, usableAreaM2: 68, bedrooms: 2, bathrooms: 2, floor: 15, totalFloors: 28, direction: Direction.NORTH, yearBuilt: 2020, legalStatus: 'SO_HONG' as const, projectName: 'The Sun Avenue', amenities: '["hồ bơi","gym","BBQ"]' },
|
||||||
{ id: 'seed-prop-003', propertyType: PropertyType.TOWNHOUSE, title: 'Nhà phố Thảo Điền 1 trệt 3 lầu compound an ninh', description: 'Nhà phố khu compound an ninh Thảo Điền, sân vườn rộng, gara 2 ô tô.', address: '12 Nguyễn Văn Hưởng', ward: 'Thảo Điền', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8033, lng: 106.7391, areaM2: 200, usableAreaM2: 350, bedrooms: 4, bathrooms: 5, floors: 4, direction: Direction.SOUTH, yearBuilt: 2015, legalStatus: 'Sổ hồng', projectName: null, amenities: '["sân vườn","gara ô tô","bảo vệ 24/7"]' },
|
{ id: 'seed-prop-003', propertyType: PropertyType.TOWNHOUSE, title: 'Nhà phố Thảo Điền 1 trệt 3 lầu compound an ninh', description: 'Nhà phố khu compound an ninh Thảo Điền, sân vườn rộng, gara 2 ô tô.', address: '12 Nguyễn Văn Hưởng', ward: 'Thảo Điền', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8033, lng: 106.7391, areaM2: 200, usableAreaM2: 350, bedrooms: 4, bathrooms: 5, floors: 4, direction: Direction.SOUTH, yearBuilt: 2015, legalStatus: 'SO_HONG' as const, projectName: null, amenities: '["sân vườn","gara ô tô","bảo vệ 24/7"]' },
|
||||||
{ id: 'seed-prop-004', propertyType: PropertyType.LAND, title: 'Đất nền Quận 7 gần Phú Mỹ Hưng thổ cư 100%', description: 'Đất nền thổ cư 100%, sổ riêng từng nền, hẻm ô tô 8m.', address: '56 Huỳnh Tấn Phát', ward: 'Phú Thuận', district: 'Quận 7', city: 'Hồ Chí Minh', lat: 10.7312, lng: 106.7283, areaM2: 120, usableAreaM2: null, bedrooms: null, bathrooms: null, direction: Direction.EAST, yearBuilt: null, legalStatus: 'Sổ đỏ', projectName: null, amenities: null },
|
{ id: 'seed-prop-004', propertyType: PropertyType.LAND, title: 'Đất nền Quận 7 gần Phú Mỹ Hưng thổ cư 100%', description: 'Đất nền thổ cư 100%, sổ riêng từng nền, hẻm ô tô 8m.', address: '56 Huỳnh Tấn Phát', ward: 'Phú Thuận', district: 'Quận 7', city: 'Hồ Chí Minh', lat: 10.7312, lng: 106.7283, areaM2: 120, usableAreaM2: null, bedrooms: null, bathrooms: null, direction: Direction.EAST, yearBuilt: null, legalStatus: 'SO_DO' as const, projectName: null, amenities: null },
|
||||||
{ id: 'seed-prop-005', propertyType: PropertyType.OFFICE, title: 'Văn phòng cho thuê Quận 1 200m² trung tâm Nguyễn Huệ', description: 'Văn phòng hạng B+ trung tâm Quận 1, full nội thất, PCCC, thang máy.', address: '123 Nguyễn Huệ', ward: 'Bến Nghé', district: 'Quận 1', city: 'Hồ Chí Minh', lat: 10.7731, lng: 106.703, areaM2: 200, usableAreaM2: 180, bedrooms: null, bathrooms: 2, floor: 8, totalFloors: 15, direction: Direction.WEST, yearBuilt: 2010, legalStatus: 'Sổ hồng', projectName: null, amenities: '["thang máy","PCCC","bảo vệ 24/7"]' },
|
{ id: 'seed-prop-005', propertyType: PropertyType.OFFICE, title: 'Văn phòng cho thuê Quận 1 200m² trung tâm Nguyễn Huệ', description: 'Văn phòng hạng B+ trung tâm Quận 1, full nội thất, PCCC, thang máy.', address: '123 Nguyễn Huệ', ward: 'Bến Nghé', district: 'Quận 1', city: 'Hồ Chí Minh', lat: 10.7731, lng: 106.703, areaM2: 200, usableAreaM2: 180, bedrooms: null, bathrooms: 2, floor: 8, totalFloors: 15, direction: Direction.WEST, yearBuilt: 2010, legalStatus: 'SO_HONG' as const, projectName: null, amenities: '["thang máy","PCCC","bảo vệ 24/7"]' },
|
||||||
{ id: 'seed-prop-006', propertyType: PropertyType.VILLA, title: 'Biệt thự Sala Đại Quang Minh view công viên', description: 'Biệt thự song lập Sala, 230m² đất, 1 trệt 2 lầu 1 áp mái, bể bơi riêng.', address: '10 Mai Chí Thọ', ward: 'An Lợi Đông', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.7721, lng: 106.7432, areaM2: 230, usableAreaM2: 420, bedrooms: 5, bathrooms: 6, floors: 3, direction: Direction.NORTHEAST, yearBuilt: 2019, legalStatus: 'Sổ hồng', projectName: 'Sala Đại Quang Minh', amenities: '["bể bơi riêng","sân vườn","gara 3 ô tô"]' },
|
{ id: 'seed-prop-006', propertyType: PropertyType.VILLA, title: 'Biệt thự Sala Đại Quang Minh view công viên', description: 'Biệt thự song lập Sala, 230m² đất, 1 trệt 2 lầu 1 áp mái, bể bơi riêng.', address: '10 Mai Chí Thọ', ward: 'An Lợi Đông', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.7721, lng: 106.7432, areaM2: 230, usableAreaM2: 420, bedrooms: 5, bathrooms: 6, floors: 3, direction: Direction.NORTHEAST, yearBuilt: 2019, legalStatus: 'SO_HONG' as const, projectName: 'Sala Đại Quang Minh', amenities: '["bể bơi riêng","sân vườn","gara 3 ô tô"]' },
|
||||||
{ id: 'seed-prop-007', propertyType: PropertyType.SHOPHOUSE, title: 'Shophouse Vạn Phúc City mặt tiền kinh doanh', description: 'Shophouse mặt tiền 30m khu đô thị Vạn Phúc City, 1 trệt 4 lầu.', address: '15 Nguyễn Thị Nhung', ward: 'Hiệp Bình Phước', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8345, lng: 106.7188, areaM2: 100, usableAreaM2: 400, bedrooms: 3, bathrooms: 4, floors: 5, direction: Direction.SOUTH, yearBuilt: 2022, legalStatus: 'Sổ hồng', projectName: 'Vạn Phúc City', amenities: '["mặt tiền kinh doanh","bãi đỗ xe"]' },
|
{ id: 'seed-prop-007', propertyType: PropertyType.SHOPHOUSE, title: 'Shophouse Vạn Phúc City mặt tiền kinh doanh', description: 'Shophouse mặt tiền 30m khu đô thị Vạn Phúc City, 1 trệt 4 lầu.', address: '15 Nguyễn Thị Nhung', ward: 'Hiệp Bình Phước', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8345, lng: 106.7188, areaM2: 100, usableAreaM2: 400, bedrooms: 3, bathrooms: 4, floors: 5, direction: Direction.SOUTH, yearBuilt: 2022, legalStatus: 'SO_HONG' as const, projectName: 'Vạn Phúc City', amenities: '["mặt tiền kinh doanh","bãi đỗ xe"]' },
|
||||||
{ id: 'seed-prop-008', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Masteri Thảo Điền 1PN full nội thất', description: 'Căn hộ 1PN Masteri Thảo Điền, nội thất cao cấp, view hồ bơi.', address: '159 Xa lộ Hà Nội', ward: 'Thảo Điền', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8025, lng: 106.7415, areaM2: 50, usableAreaM2: 45, bedrooms: 1, bathrooms: 1, floor: 12, totalFloors: 40, direction: Direction.NORTHWEST, yearBuilt: 2017, legalStatus: 'Sổ hồng', projectName: 'Masteri Thảo Điền', amenities: '["hồ bơi","gym","sky lounge"]' },
|
{ id: 'seed-prop-008', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Masteri Thảo Điền 1PN full nội thất', description: 'Căn hộ 1PN Masteri Thảo Điền, nội thất cao cấp, view hồ bơi.', address: '159 Xa lộ Hà Nội', ward: 'Thảo Điền', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8025, lng: 106.7415, areaM2: 50, usableAreaM2: 45, bedrooms: 1, bathrooms: 1, floor: 12, totalFloors: 40, direction: Direction.NORTHWEST, yearBuilt: 2017, legalStatus: 'SO_HONG' as const, projectName: 'Masteri Thảo Điền', amenities: '["hồ bơi","gym","sky lounge"]' },
|
||||||
{ id: 'seed-prop-009', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Midtown Phú Mỹ Hưng 2PN giá tốt', description: 'Căn hộ 2PN Midtown The Peak, Phú Mỹ Hưng. Nội thất cơ bản, view đẹp.', address: '12 Nguyễn Lương Bằng', ward: 'Tân Phú', district: 'Quận 7', city: 'Hồ Chí Minh', lat: 10.7285, lng: 106.7195, areaM2: 85, usableAreaM2: 78, bedrooms: 2, bathrooms: 2, floor: 18, totalFloors: 30, direction: Direction.EAST, yearBuilt: 2021, legalStatus: 'Sổ hồng', projectName: 'Midtown Phú Mỹ Hưng', amenities: '["hồ bơi","gym","công viên"]' },
|
{ id: 'seed-prop-009', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Midtown Phú Mỹ Hưng 2PN giá tốt', description: 'Căn hộ 2PN Midtown The Peak, Phú Mỹ Hưng. Nội thất cơ bản, view đẹp.', address: '12 Nguyễn Lương Bằng', ward: 'Tân Phú', district: 'Quận 7', city: 'Hồ Chí Minh', lat: 10.7285, lng: 106.7195, areaM2: 85, usableAreaM2: 78, bedrooms: 2, bathrooms: 2, floor: 18, totalFloors: 30, direction: Direction.EAST, yearBuilt: 2021, legalStatus: 'SO_HONG' as const, projectName: 'Midtown Phú Mỹ Hưng', amenities: '["hồ bơi","gym","công viên"]' },
|
||||||
{ id: 'seed-prop-010', propertyType: PropertyType.TOWNHOUSE, title: 'Nhà phố Gò Vấp 1 trệt 2 lầu sổ hồng riêng', description: 'Nhà phố mới xây tại Gò Vấp, 1 trệt 2 lầu, sân thượng, hẻm xe hơi 6m.', address: '88 Nguyễn Oanh', ward: 'Phường 17', district: 'Quận Gò Vấp', city: 'Hồ Chí Minh', lat: 10.8352, lng: 106.6648, areaM2: 65, usableAreaM2: 150, bedrooms: 3, bathrooms: 3, floors: 3, direction: Direction.WEST, yearBuilt: 2024, legalStatus: 'Sổ hồng', projectName: null, amenities: '["sân thượng","ban công"]' },
|
{ id: 'seed-prop-010', propertyType: PropertyType.TOWNHOUSE, title: 'Nhà phố Gò Vấp 1 trệt 2 lầu sổ hồng riêng', description: 'Nhà phố mới xây tại Gò Vấp, 1 trệt 2 lầu, sân thượng, hẻm xe hơi 6m.', address: '88 Nguyễn Oanh', ward: 'Phường 17', district: 'Quận Gò Vấp', city: 'Hồ Chí Minh', lat: 10.8352, lng: 106.6648, areaM2: 65, usableAreaM2: 150, bedrooms: 3, bathrooms: 3, floors: 3, direction: Direction.WEST, yearBuilt: 2024, legalStatus: 'SO_HONG' as const, projectName: null, amenities: '["sân thượng","ban công"]' },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const p of properties) {
|
for (const p of properties) {
|
||||||
|
|||||||
509
scripts/seed-industrial-listings.ts
Normal file
509
scripts/seed-industrial-listings.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
/**
|
||||||
|
* Seed industrial listings — 12 sample listings across 8 parks.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/seed-industrial-listings.ts
|
||||||
|
* Idempotent: uses upsert on id.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import {
|
||||||
|
PrismaClient,
|
||||||
|
IndustrialPropertyType,
|
||||||
|
IndustrialLeaseType,
|
||||||
|
IndustrialListingStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
interface IndustrialListingSeed {
|
||||||
|
id: string;
|
||||||
|
parkId: string;
|
||||||
|
sellerId: string;
|
||||||
|
agentId: string | null;
|
||||||
|
propertyType: IndustrialPropertyType;
|
||||||
|
leaseType: IndustrialLeaseType;
|
||||||
|
status: IndustrialListingStatus;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
areaM2: number;
|
||||||
|
ceilingHeightM: number | null;
|
||||||
|
floorLoadTonM2: number | null;
|
||||||
|
columnSpacingM: number | null;
|
||||||
|
dockCount: number | null;
|
||||||
|
craneCapacityTon: number | null;
|
||||||
|
hasMezzanine: boolean;
|
||||||
|
hasOfficeArea: boolean;
|
||||||
|
officeAreaM2: number | null;
|
||||||
|
priceUsdM2: number | null;
|
||||||
|
pricingUnit: string | null;
|
||||||
|
totalLeasePrice: number | null;
|
||||||
|
managementFee: number | null;
|
||||||
|
depositMonths: number | null;
|
||||||
|
minLeaseYears: number | null;
|
||||||
|
maxLeaseYears: number | null;
|
||||||
|
availableFrom: Date | null;
|
||||||
|
powerCapacityKva: number | null;
|
||||||
|
waterSupplyM3Day: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const oneMonthLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const threeMonthsLater = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const LISTINGS: IndustrialListingSeed[] = [
|
||||||
|
// --- VSIP Bac Ninh (seed-kcn-001) — 2 listings ---
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-001',
|
||||||
|
parkId: 'seed-kcn-001',
|
||||||
|
sellerId: 'seed-seller-001',
|
||||||
|
agentId: 'seed-agentprofile-001',
|
||||||
|
propertyType: IndustrialPropertyType.READY_BUILT_FACTORY,
|
||||||
|
leaseType: IndustrialLeaseType.FACTORY_LEASE,
|
||||||
|
status: IndustrialListingStatus.ACTIVE,
|
||||||
|
title: 'Nhà xưởng xây sẵn 3.000m² KCN VSIP Bắc Ninh',
|
||||||
|
description: 'Nhà xưởng xây sẵn tiêu chuẩn VSIP tại KCN VSIP Bắc Ninh. Kết cấu thép tiền chế, nền bê tông cốt thép chịu tải 3 tấn/m², hệ thống PCCC tự động, điện 3 pha 500kVA.',
|
||||||
|
areaM2: 3000,
|
||||||
|
ceilingHeightM: 10,
|
||||||
|
floorLoadTonM2: 3,
|
||||||
|
columnSpacingM: 12,
|
||||||
|
dockCount: 4,
|
||||||
|
craneCapacityTon: null,
|
||||||
|
hasMezzanine: true,
|
||||||
|
hasOfficeArea: true,
|
||||||
|
officeAreaM2: 200,
|
||||||
|
priceUsdM2: 5.5,
|
||||||
|
pricingUnit: 'usd/m2/month',
|
||||||
|
totalLeasePrice: 16500,
|
||||||
|
managementFee: 0.7,
|
||||||
|
depositMonths: 3,
|
||||||
|
minLeaseYears: 3,
|
||||||
|
maxLeaseYears: 10,
|
||||||
|
availableFrom: oneMonthLater,
|
||||||
|
powerCapacityKva: 500,
|
||||||
|
waterSupplyM3Day: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-002',
|
||||||
|
parkId: 'seed-kcn-001',
|
||||||
|
sellerId: 'seed-seller-002',
|
||||||
|
agentId: null,
|
||||||
|
propertyType: IndustrialPropertyType.INDUSTRIAL_LAND,
|
||||||
|
leaseType: IndustrialLeaseType.LAND_LEASE,
|
||||||
|
status: IndustrialListingStatus.ACTIVE,
|
||||||
|
title: 'Đất công nghiệp 10.000m² VSIP Bắc Ninh — vị trí đắc địa',
|
||||||
|
description: 'Lô đất công nghiệp mặt tiền đường chính 40m, gần cổng chính KCN. Phù hợp xây dựng nhà máy sản xuất điện tử, linh kiện.',
|
||||||
|
areaM2: 10000,
|
||||||
|
ceilingHeightM: null,
|
||||||
|
floorLoadTonM2: null,
|
||||||
|
columnSpacingM: null,
|
||||||
|
dockCount: null,
|
||||||
|
craneCapacityTon: null,
|
||||||
|
hasMezzanine: false,
|
||||||
|
hasOfficeArea: false,
|
||||||
|
officeAreaM2: null,
|
||||||
|
priceUsdM2: 90,
|
||||||
|
pricingUnit: 'usd/m2/year',
|
||||||
|
totalLeasePrice: 900000,
|
||||||
|
managementFee: 0.7,
|
||||||
|
depositMonths: 6,
|
||||||
|
minLeaseYears: 20,
|
||||||
|
maxLeaseYears: 50,
|
||||||
|
availableFrom: now,
|
||||||
|
powerCapacityKva: null,
|
||||||
|
waterSupplyM3Day: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Amata Dong Nai (seed-kcn-003) — 2 listings ---
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-003',
|
||||||
|
parkId: 'seed-kcn-003',
|
||||||
|
sellerId: 'seed-seller-001',
|
||||||
|
agentId: 'seed-agentprofile-002',
|
||||||
|
propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE,
|
||||||
|
leaseType: IndustrialLeaseType.WAREHOUSE_LEASE,
|
||||||
|
status: IndustrialListingStatus.ACTIVE,
|
||||||
|
title: 'Kho xưởng 5.000m² KCN Amata Đồng Nai — sẵn dock container',
|
||||||
|
description: 'Kho xưởng xây sẵn với 6 dock container, hệ thống kệ pallet, nền chịu tải 5 tấn/m². Thích hợp logistics và phân phối.',
|
||||||
|
areaM2: 5000,
|
||||||
|
ceilingHeightM: 12,
|
||||||
|
floorLoadTonM2: 5,
|
||||||
|
columnSpacingM: 15,
|
||||||
|
dockCount: 6,
|
||||||
|
craneCapacityTon: 5,
|
||||||
|
hasMezzanine: false,
|
||||||
|
hasOfficeArea: true,
|
||||||
|
officeAreaM2: 150,
|
||||||
|
priceUsdM2: 5.0,
|
||||||
|
pricingUnit: 'usd/m2/month',
|
||||||
|
totalLeasePrice: 25000,
|
||||||
|
managementFee: 0.65,
|
||||||
|
depositMonths: 3,
|
||||||
|
minLeaseYears: 2,
|
||||||
|
maxLeaseYears: 10,
|
||||||
|
availableFrom: oneMonthLater,
|
||||||
|
powerCapacityKva: 300,
|
||||||
|
waterSupplyM3Day: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-004',
|
||||||
|
parkId: 'seed-kcn-003',
|
||||||
|
sellerId: 'seed-seller-002',
|
||||||
|
agentId: 'seed-agentprofile-001',
|
||||||
|
propertyType: IndustrialPropertyType.READY_BUILT_FACTORY,
|
||||||
|
leaseType: IndustrialLeaseType.FACTORY_LEASE,
|
||||||
|
status: IndustrialListingStatus.DRAFT,
|
||||||
|
title: 'Nhà máy sản xuất 8.000m² Amata — cần bàn giao sớm',
|
||||||
|
description: 'Nhà máy quy mô lớn với 2 bay sản xuất, cầu trục 10 tấn, hệ thống xử lý nước thải riêng. Đang hoàn thiện, dự kiến bàn giao Q3/2026.',
|
||||||
|
areaM2: 8000,
|
||||||
|
ceilingHeightM: 14,
|
||||||
|
floorLoadTonM2: 5,
|
||||||
|
columnSpacingM: 18,
|
||||||
|
dockCount: 8,
|
||||||
|
craneCapacityTon: 10,
|
||||||
|
hasMezzanine: true,
|
||||||
|
hasOfficeArea: true,
|
||||||
|
officeAreaM2: 500,
|
||||||
|
priceUsdM2: 4.8,
|
||||||
|
pricingUnit: 'usd/m2/month',
|
||||||
|
totalLeasePrice: 38400,
|
||||||
|
managementFee: 0.65,
|
||||||
|
depositMonths: 6,
|
||||||
|
minLeaseYears: 5,
|
||||||
|
maxLeaseYears: 20,
|
||||||
|
availableFrom: threeMonthsLater,
|
||||||
|
powerCapacityKva: 1500,
|
||||||
|
waterSupplyM3Day: 100,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Nam Dinh Vu (seed-kcn-005) ---
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-005',
|
||||||
|
parkId: 'seed-kcn-005',
|
||||||
|
sellerId: 'seed-seller-001',
|
||||||
|
agentId: 'seed-agentprofile-003',
|
||||||
|
propertyType: IndustrialPropertyType.LOGISTICS_CENTER,
|
||||||
|
leaseType: IndustrialLeaseType.WAREHOUSE_LEASE,
|
||||||
|
status: IndustrialListingStatus.ACTIVE,
|
||||||
|
title: 'Trung tâm logistics 15.000m² KCN Nam Đình Vũ — sát cảng biển',
|
||||||
|
description: 'Trung tâm logistics hiện đại ngay cảng Đình Vũ, phù hợp cho kho ngoại quan, trung chuyển hàng hóa quốc tế. Hệ thống bãi container 5.000m².',
|
||||||
|
areaM2: 15000,
|
||||||
|
ceilingHeightM: 12,
|
||||||
|
floorLoadTonM2: 5,
|
||||||
|
columnSpacingM: 20,
|
||||||
|
dockCount: 12,
|
||||||
|
craneCapacityTon: null,
|
||||||
|
hasMezzanine: false,
|
||||||
|
hasOfficeArea: true,
|
||||||
|
officeAreaM2: 300,
|
||||||
|
priceUsdM2: 4.8,
|
||||||
|
pricingUnit: 'usd/m2/month',
|
||||||
|
totalLeasePrice: 72000,
|
||||||
|
managementFee: 0.6,
|
||||||
|
depositMonths: 3,
|
||||||
|
minLeaseYears: 3,
|
||||||
|
maxLeaseYears: 15,
|
||||||
|
availableFrom: now,
|
||||||
|
powerCapacityKva: 800,
|
||||||
|
waterSupplyM3Day: 60,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Long Hau (seed-kcn-006) ---
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-006',
|
||||||
|
parkId: 'seed-kcn-006',
|
||||||
|
sellerId: 'seed-seller-002',
|
||||||
|
agentId: 'seed-agentprofile-002',
|
||||||
|
propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE,
|
||||||
|
leaseType: IndustrialLeaseType.SUBLEASE,
|
||||||
|
status: IndustrialListingStatus.ACTIVE,
|
||||||
|
title: 'Kho hàng 2.000m² Long Hậu — cho thuê lại giá tốt',
|
||||||
|
description: 'Kho hàng cho thuê lại tại KCN Long Hậu, còn 4 năm hợp đồng gốc. Nền epoxy, PCCC đầy đủ, gần cảng Hiệp Phước.',
|
||||||
|
areaM2: 2000,
|
||||||
|
ceilingHeightM: 9,
|
||||||
|
floorLoadTonM2: 3,
|
||||||
|
columnSpacingM: 10,
|
||||||
|
dockCount: 2,
|
||||||
|
craneCapacityTon: null,
|
||||||
|
hasMezzanine: false,
|
||||||
|
hasOfficeArea: false,
|
||||||
|
officeAreaM2: null,
|
||||||
|
priceUsdM2: 4.0,
|
||||||
|
pricingUnit: 'usd/m2/month',
|
||||||
|
totalLeasePrice: 8000,
|
||||||
|
managementFee: 0.5,
|
||||||
|
depositMonths: 2,
|
||||||
|
minLeaseYears: 1,
|
||||||
|
maxLeaseYears: 4,
|
||||||
|
availableFrom: now,
|
||||||
|
powerCapacityKva: 200,
|
||||||
|
waterSupplyM3Day: 15,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Thang Long II Hung Yen (seed-kcn-011) ---
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-007',
|
||||||
|
parkId: 'seed-kcn-011',
|
||||||
|
sellerId: 'seed-seller-001',
|
||||||
|
agentId: 'seed-agentprofile-001',
|
||||||
|
propertyType: IndustrialPropertyType.READY_BUILT_FACTORY,
|
||||||
|
leaseType: IndustrialLeaseType.FACTORY_LEASE,
|
||||||
|
status: IndustrialListingStatus.ACTIVE,
|
||||||
|
title: 'Xưởng sản xuất 4.500m² KCN Thăng Long II — tiêu chuẩn Nhật',
|
||||||
|
description: 'Nhà xưởng tiêu chuẩn Nhật Bản tại KCN Thăng Long II Hưng Yên. Clean room sẵn, hệ thống AHU, phù hợp sản xuất linh kiện điện tử.',
|
||||||
|
areaM2: 4500,
|
||||||
|
ceilingHeightM: 10,
|
||||||
|
floorLoadTonM2: 3,
|
||||||
|
columnSpacingM: 12,
|
||||||
|
dockCount: 4,
|
||||||
|
craneCapacityTon: null,
|
||||||
|
hasMezzanine: true,
|
||||||
|
hasOfficeArea: true,
|
||||||
|
officeAreaM2: 350,
|
||||||
|
priceUsdM2: 4.5,
|
||||||
|
pricingUnit: 'usd/m2/month',
|
||||||
|
totalLeasePrice: 20250,
|
||||||
|
managementFee: 0.6,
|
||||||
|
depositMonths: 3,
|
||||||
|
minLeaseYears: 3,
|
||||||
|
maxLeaseYears: 15,
|
||||||
|
availableFrom: oneMonthLater,
|
||||||
|
powerCapacityKva: 600,
|
||||||
|
waterSupplyM3Day: 40,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Yen Phong Bac Ninh (seed-kcn-012) ---
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-008',
|
||||||
|
parkId: 'seed-kcn-012',
|
||||||
|
sellerId: 'seed-seller-002',
|
||||||
|
agentId: null,
|
||||||
|
propertyType: IndustrialPropertyType.INDUSTRIAL_LAND,
|
||||||
|
leaseType: IndustrialLeaseType.LAND_LEASE,
|
||||||
|
status: IndustrialListingStatus.ACTIVE,
|
||||||
|
title: 'Đất công nghiệp 5.000m² Yên Phong — gần Samsung',
|
||||||
|
description: 'Lô đất công nghiệp cuối cùng tại KCN Yên Phong, liền kề nhà máy Samsung Display. Hạ tầng hoàn chỉnh, phù hợp nhà cung cấp Samsung.',
|
||||||
|
areaM2: 5000,
|
||||||
|
ceilingHeightM: null,
|
||||||
|
floorLoadTonM2: null,
|
||||||
|
columnSpacingM: null,
|
||||||
|
dockCount: null,
|
||||||
|
craneCapacityTon: null,
|
||||||
|
hasMezzanine: false,
|
||||||
|
hasOfficeArea: false,
|
||||||
|
officeAreaM2: null,
|
||||||
|
priceUsdM2: 85,
|
||||||
|
pricingUnit: 'usd/m2/year',
|
||||||
|
totalLeasePrice: 425000,
|
||||||
|
managementFee: 0.6,
|
||||||
|
depositMonths: 6,
|
||||||
|
minLeaseYears: 20,
|
||||||
|
maxLeaseYears: 47,
|
||||||
|
availableFrom: now,
|
||||||
|
powerCapacityKva: null,
|
||||||
|
waterSupplyM3Day: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- DEEP C Hai Phong (seed-kcn-016) ---
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-009',
|
||||||
|
parkId: 'seed-kcn-016',
|
||||||
|
sellerId: 'seed-seller-001',
|
||||||
|
agentId: 'seed-agentprofile-003',
|
||||||
|
propertyType: IndustrialPropertyType.READY_BUILT_FACTORY,
|
||||||
|
leaseType: IndustrialLeaseType.FACTORY_LEASE,
|
||||||
|
status: IndustrialListingStatus.ACTIVE,
|
||||||
|
title: 'Nhà xưởng xanh 6.000m² DEEP C Hải Phòng — EDGE certified',
|
||||||
|
description: 'Nhà xưởng đạt chứng chỉ EDGE Green Building, mái solar panels 200kWp, hệ thống thu gom nước mưa. Phù hợp doanh nghiệp ESG.',
|
||||||
|
areaM2: 6000,
|
||||||
|
ceilingHeightM: 11,
|
||||||
|
floorLoadTonM2: 3,
|
||||||
|
columnSpacingM: 15,
|
||||||
|
dockCount: 6,
|
||||||
|
craneCapacityTon: null,
|
||||||
|
hasMezzanine: false,
|
||||||
|
hasOfficeArea: true,
|
||||||
|
officeAreaM2: 250,
|
||||||
|
priceUsdM2: 4.5,
|
||||||
|
pricingUnit: 'usd/m2/month',
|
||||||
|
totalLeasePrice: 27000,
|
||||||
|
managementFee: 0.6,
|
||||||
|
depositMonths: 3,
|
||||||
|
minLeaseYears: 3,
|
||||||
|
maxLeaseYears: 15,
|
||||||
|
availableFrom: threeMonthsLater,
|
||||||
|
powerCapacityKva: 700,
|
||||||
|
waterSupplyM3Day: 50,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- My Phuoc 3 Binh Duong (seed-kcn-017) ---
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-010',
|
||||||
|
parkId: 'seed-kcn-017',
|
||||||
|
sellerId: 'seed-seller-002',
|
||||||
|
agentId: 'seed-agentprofile-002',
|
||||||
|
propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE,
|
||||||
|
leaseType: IndustrialLeaseType.WAREHOUSE_LEASE,
|
||||||
|
status: IndustrialListingStatus.ACTIVE,
|
||||||
|
title: 'Kho xưởng 3.500m² Mỹ Phước 3 — gần đường Mỹ Phước Tân Vạn',
|
||||||
|
description: 'Kho xưởng xây sẵn mặt đường nội khu, gần đường Mỹ Phước - Tân Vạn. Phù hợp kho hàng FMCG, logistics e-commerce.',
|
||||||
|
areaM2: 3500,
|
||||||
|
ceilingHeightM: 10,
|
||||||
|
floorLoadTonM2: 3,
|
||||||
|
columnSpacingM: 12,
|
||||||
|
dockCount: 4,
|
||||||
|
craneCapacityTon: null,
|
||||||
|
hasMezzanine: false,
|
||||||
|
hasOfficeArea: true,
|
||||||
|
officeAreaM2: 100,
|
||||||
|
priceUsdM2: 4.8,
|
||||||
|
pricingUnit: 'usd/m2/month',
|
||||||
|
totalLeasePrice: 16800,
|
||||||
|
managementFee: 0.55,
|
||||||
|
depositMonths: 3,
|
||||||
|
minLeaseYears: 2,
|
||||||
|
maxLeaseYears: 10,
|
||||||
|
availableFrom: oneMonthLater,
|
||||||
|
powerCapacityKva: 400,
|
||||||
|
waterSupplyM3Day: 30,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- KTG Nhon Trach (seed-kcn-009) ---
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-011',
|
||||||
|
parkId: 'seed-kcn-009',
|
||||||
|
sellerId: 'seed-seller-001',
|
||||||
|
agentId: 'seed-agentprofile-001',
|
||||||
|
propertyType: IndustrialPropertyType.OFFICE_IN_PARK,
|
||||||
|
leaseType: IndustrialLeaseType.FACTORY_LEASE,
|
||||||
|
status: IndustrialListingStatus.DRAFT,
|
||||||
|
title: 'Văn phòng trong KCN KTG Nhơn Trạch 500m²',
|
||||||
|
description: 'Văn phòng mới xây trong KCN KTG Nhơn Trạch, 2 tầng, điều hòa trung tâm, bãi đỗ xe riêng. Phù hợp văn phòng vùng cho nhà máy lân cận.',
|
||||||
|
areaM2: 500,
|
||||||
|
ceilingHeightM: 3.5,
|
||||||
|
floorLoadTonM2: null,
|
||||||
|
columnSpacingM: null,
|
||||||
|
dockCount: null,
|
||||||
|
craneCapacityTon: null,
|
||||||
|
hasMezzanine: false,
|
||||||
|
hasOfficeArea: true,
|
||||||
|
officeAreaM2: 500,
|
||||||
|
priceUsdM2: 8.0,
|
||||||
|
pricingUnit: 'usd/m2/month',
|
||||||
|
totalLeasePrice: 4000,
|
||||||
|
managementFee: 0.55,
|
||||||
|
depositMonths: 2,
|
||||||
|
minLeaseYears: 1,
|
||||||
|
maxLeaseYears: 5,
|
||||||
|
availableFrom: now,
|
||||||
|
powerCapacityKva: 100,
|
||||||
|
waterSupplyM3Day: 5,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Chu Lai Quang Nam (seed-kcn-020) ---
|
||||||
|
{
|
||||||
|
id: 'seed-ind-listing-012',
|
||||||
|
parkId: 'seed-kcn-020',
|
||||||
|
sellerId: 'seed-seller-002',
|
||||||
|
agentId: 'seed-agentprofile-003',
|
||||||
|
propertyType: IndustrialPropertyType.INDUSTRIAL_LAND,
|
||||||
|
leaseType: IndustrialLeaseType.LAND_LEASE,
|
||||||
|
status: IndustrialListingStatus.ACTIVE,
|
||||||
|
title: 'Đất KCN Chu Lai 20.000m² — ưu đãi KKTM đặc biệt',
|
||||||
|
description: 'Lô đất lớn tại KCN Chu Lai, thuộc Khu kinh tế mở Chu Lai với ưu đãi thuế đặc biệt: miễn tiền thuê đất 15 năm, miễn thuế NK toàn bộ. Phù hợp sản xuất ô tô, cơ khí.',
|
||||||
|
areaM2: 20000,
|
||||||
|
ceilingHeightM: null,
|
||||||
|
floorLoadTonM2: null,
|
||||||
|
columnSpacingM: null,
|
||||||
|
dockCount: null,
|
||||||
|
craneCapacityTon: null,
|
||||||
|
hasMezzanine: false,
|
||||||
|
hasOfficeArea: false,
|
||||||
|
officeAreaM2: null,
|
||||||
|
priceUsdM2: 40,
|
||||||
|
pricingUnit: 'usd/m2/year',
|
||||||
|
totalLeasePrice: 800000,
|
||||||
|
managementFee: 0.35,
|
||||||
|
depositMonths: 6,
|
||||||
|
minLeaseYears: 20,
|
||||||
|
maxLeaseYears: 50,
|
||||||
|
availableFrom: now,
|
||||||
|
powerCapacityKva: null,
|
||||||
|
waterSupplyM3Day: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedIndustrialListings() {
|
||||||
|
console.log('🏭 Seeding industrial listings...');
|
||||||
|
|
||||||
|
for (const l of LISTINGS) {
|
||||||
|
const isPublished =
|
||||||
|
l.status === IndustrialListingStatus.ACTIVE ||
|
||||||
|
l.status === IndustrialListingStatus.RESERVED;
|
||||||
|
|
||||||
|
await prisma.industrialListing.upsert({
|
||||||
|
where: { id: l.id },
|
||||||
|
update: {
|
||||||
|
title: l.title,
|
||||||
|
status: l.status,
|
||||||
|
priceUsdM2: l.priceUsdM2,
|
||||||
|
totalLeasePrice: l.totalLeasePrice,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: l.id,
|
||||||
|
parkId: l.parkId,
|
||||||
|
sellerId: l.sellerId,
|
||||||
|
agentId: l.agentId,
|
||||||
|
propertyType: l.propertyType,
|
||||||
|
leaseType: l.leaseType,
|
||||||
|
status: l.status,
|
||||||
|
title: l.title,
|
||||||
|
description: l.description,
|
||||||
|
areaM2: l.areaM2,
|
||||||
|
ceilingHeightM: l.ceilingHeightM,
|
||||||
|
floorLoadTonM2: l.floorLoadTonM2,
|
||||||
|
columnSpacingM: l.columnSpacingM,
|
||||||
|
dockCount: l.dockCount,
|
||||||
|
craneCapacityTon: l.craneCapacityTon,
|
||||||
|
hasMezzanine: l.hasMezzanine,
|
||||||
|
hasOfficeArea: l.hasOfficeArea,
|
||||||
|
officeAreaM2: l.officeAreaM2,
|
||||||
|
priceUsdM2: l.priceUsdM2,
|
||||||
|
pricingUnit: l.pricingUnit,
|
||||||
|
totalLeasePrice: l.totalLeasePrice,
|
||||||
|
managementFee: l.managementFee,
|
||||||
|
depositMonths: l.depositMonths,
|
||||||
|
minLeaseYears: l.minLeaseYears,
|
||||||
|
maxLeaseYears: l.maxLeaseYears,
|
||||||
|
availableFrom: l.availableFrom,
|
||||||
|
powerCapacityKva: l.powerCapacityKva,
|
||||||
|
waterSupplyM3Day: l.waterSupplyM3Day,
|
||||||
|
viewCount: Math.floor(Math.random() * 200) + 5,
|
||||||
|
inquiryCount: Math.floor(Math.random() * 15),
|
||||||
|
publishedAt: isPublished ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` ✓ ${l.title.slice(0, 60)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🏭 Seeded ${LISTINGS.length} industrial listings.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run standalone
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
await seedIndustrialListings();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Seed error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
void main();
|
||||||
|
}
|
||||||
@@ -839,7 +839,9 @@ export async function seedIndustrialParks() {
|
|||||||
console.log(`🏭 Seeded ${PARKS.length} industrial parks.`);
|
console.log(`🏭 Seeded ${PARKS.length} industrial parks.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run standalone
|
// Run standalone — but ONLY when invoked directly (`tsx scripts/...`).
|
||||||
|
// When imported by `prisma/seed.ts`, the orchestrator owns the lifecycle
|
||||||
|
// and we must not end this module's pool prematurely.
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
await seedIndustrialParks();
|
await seedIndustrialParks();
|
||||||
@@ -852,4 +854,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
if (require.main === module) {
|
||||||
|
void main();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user