feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Industrial-listing label-density report (TEC-2769 — R5.2.2).
|
||||
*
|
||||
* Lightweight monitoring query. Answers: do we have enough labelled
|
||||
* listing-level data (with `priceUsdM2` populated) to train a listing-level
|
||||
* AVM for industrial real estate?
|
||||
*
|
||||
* Emits:
|
||||
* - Human-readable table to stdout (default).
|
||||
* - JSON blob to stdout when `--json` is passed (for CI / dashboards).
|
||||
*
|
||||
* Report sections:
|
||||
* - Totals (rows, rows with priceUsdM2, % label coverage).
|
||||
* - Histogram by VietnamRegion.
|
||||
* - Histogram by IndustrialPropertyType.
|
||||
* - Park coverage (parks with ≥1 listing / total parks).
|
||||
* - priceUsdM2 P10/P50/P90 by (region, propertyType).
|
||||
* - Acceptance-gate verdict for TEC-2769 (≥500 rows, NORTH/SOUTH ≥20%,
|
||||
* CENTRAL ≥5%, every propertyType enum ≥10 rows).
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/report-industrial-label-density.ts
|
||||
* pnpm tsx scripts/report-industrial-label-density.ts --json
|
||||
*/
|
||||
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import {
|
||||
IndustrialPropertyType,
|
||||
PrismaClient,
|
||||
VietnamRegion,
|
||||
} from '@prisma/client';
|
||||
import pg from 'pg';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types.
|
||||
// ---------------------------------------------------------------------------
|
||||
type Verdict = 'PASS' | 'FAIL';
|
||||
|
||||
interface Percentiles {
|
||||
p10: number | null;
|
||||
p50: number | null;
|
||||
p90: number | null;
|
||||
n: number;
|
||||
}
|
||||
|
||||
interface Report {
|
||||
generatedAt: string;
|
||||
totals: {
|
||||
listings: number;
|
||||
withPriceUsdM2: number;
|
||||
labelCoveragePct: number;
|
||||
};
|
||||
regionHistogram: Record<string, { rows: number; withPrice: number }>;
|
||||
propertyTypeHistogram: Record<string, { rows: number; withPrice: number }>;
|
||||
parkCoverage: {
|
||||
parksWithListings: number;
|
||||
parksTotal: number;
|
||||
coveragePct: number;
|
||||
};
|
||||
priceBuckets: Array<{
|
||||
region: VietnamRegion;
|
||||
propertyType: IndustrialPropertyType;
|
||||
percentiles: Percentiles;
|
||||
}>;
|
||||
gates: {
|
||||
totalAtLeast500: Verdict;
|
||||
northAtLeast20pct: Verdict;
|
||||
southAtLeast20pct: Verdict;
|
||||
centralAtLeast5pct: Verdict;
|
||||
everyPropertyTypeAtLeast10: Verdict;
|
||||
overall: Verdict;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Percentile helper. Linear interpolation on sorted array.
|
||||
// ---------------------------------------------------------------------------
|
||||
function percentile(sorted: number[], p: number): number | null {
|
||||
if (sorted.length === 0) return null;
|
||||
if (sorted.length === 1) return sorted[0] ?? null;
|
||||
const rank = (p / 100) * (sorted.length - 1);
|
||||
const lo = Math.floor(rank);
|
||||
const hi = Math.ceil(rank);
|
||||
if (lo === hi) return sorted[lo] ?? null;
|
||||
const loVal = sorted[lo] ?? 0;
|
||||
const hiVal = sorted[hi] ?? 0;
|
||||
return loVal + (hiVal - loVal) * (rank - lo);
|
||||
}
|
||||
|
||||
function pct(x: number, total: number): number {
|
||||
if (total === 0) return 0;
|
||||
return Math.round((x / total) * 1000) / 10;
|
||||
}
|
||||
|
||||
function fmtPrice(n: number | null): string {
|
||||
if (n == null) return ' — ';
|
||||
if (n >= 100) return n.toFixed(0).padStart(6);
|
||||
return n.toFixed(2).padStart(6);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function run(): Promise<Report> {
|
||||
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
const adapter = new PrismaPg(pool);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
try {
|
||||
const parksTotal = await prisma.industrialPark.count();
|
||||
|
||||
const listings = await prisma.industrialListing.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
parkId: true,
|
||||
propertyType: true,
|
||||
priceUsdM2: true,
|
||||
park: { select: { region: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const total = listings.length;
|
||||
const withPrice = listings.filter((l) => l.priceUsdM2 != null).length;
|
||||
|
||||
// Region histogram.
|
||||
const regionHistogram: Report['regionHistogram'] = {};
|
||||
for (const region of Object.values(VietnamRegion)) {
|
||||
regionHistogram[region] = { rows: 0, withPrice: 0 };
|
||||
}
|
||||
for (const l of listings) {
|
||||
const r = l.park?.region;
|
||||
if (!r) continue;
|
||||
const bucket = regionHistogram[r];
|
||||
if (!bucket) continue;
|
||||
bucket.rows += 1;
|
||||
if (l.priceUsdM2 != null) bucket.withPrice += 1;
|
||||
}
|
||||
|
||||
// PropertyType histogram.
|
||||
const propertyTypeHistogram: Report['propertyTypeHistogram'] = {};
|
||||
for (const pt of Object.values(IndustrialPropertyType)) {
|
||||
propertyTypeHistogram[pt] = { rows: 0, withPrice: 0 };
|
||||
}
|
||||
for (const l of listings) {
|
||||
const bucket = propertyTypeHistogram[l.propertyType];
|
||||
if (!bucket) continue;
|
||||
bucket.rows += 1;
|
||||
if (l.priceUsdM2 != null) bucket.withPrice += 1;
|
||||
}
|
||||
|
||||
// Park coverage.
|
||||
const parkIdsWithListings = new Set(listings.map((l) => l.parkId));
|
||||
const parkCoverage = {
|
||||
parksWithListings: parkIdsWithListings.size,
|
||||
parksTotal,
|
||||
coveragePct: pct(parkIdsWithListings.size, parksTotal),
|
||||
};
|
||||
|
||||
// Price percentiles by (region, propertyType).
|
||||
const priceBuckets: Report['priceBuckets'] = [];
|
||||
for (const region of Object.values(VietnamRegion)) {
|
||||
for (const propertyType of Object.values(IndustrialPropertyType)) {
|
||||
const values = listings
|
||||
.filter(
|
||||
(l) =>
|
||||
l.park?.region === region &&
|
||||
l.propertyType === propertyType &&
|
||||
l.priceUsdM2 != null,
|
||||
)
|
||||
.map((l) => Number(l.priceUsdM2))
|
||||
.sort((a, b) => a - b);
|
||||
priceBuckets.push({
|
||||
region,
|
||||
propertyType,
|
||||
percentiles: {
|
||||
n: values.length,
|
||||
p10: percentile(values, 10),
|
||||
p50: percentile(values, 50),
|
||||
p90: percentile(values, 90),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance gates.
|
||||
const northPct = pct(regionHistogram[VietnamRegion.NORTH]?.rows ?? 0, total);
|
||||
const southPct = pct(regionHistogram[VietnamRegion.SOUTH]?.rows ?? 0, total);
|
||||
const centralPct = pct(
|
||||
regionHistogram[VietnamRegion.CENTRAL]?.rows ?? 0,
|
||||
total,
|
||||
);
|
||||
const everyPropertyTypeAtLeast10: Verdict = Object.values(
|
||||
propertyTypeHistogram,
|
||||
).every((b) => b.rows >= 10)
|
||||
? 'PASS'
|
||||
: 'FAIL';
|
||||
const gates: Report['gates'] = {
|
||||
totalAtLeast500: total >= 500 ? 'PASS' : 'FAIL',
|
||||
northAtLeast20pct: northPct >= 20 ? 'PASS' : 'FAIL',
|
||||
southAtLeast20pct: southPct >= 20 ? 'PASS' : 'FAIL',
|
||||
centralAtLeast5pct: centralPct >= 5 ? 'PASS' : 'FAIL',
|
||||
everyPropertyTypeAtLeast10,
|
||||
overall: 'FAIL',
|
||||
};
|
||||
gates.overall =
|
||||
gates.totalAtLeast500 === 'PASS' &&
|
||||
gates.northAtLeast20pct === 'PASS' &&
|
||||
gates.southAtLeast20pct === 'PASS' &&
|
||||
gates.centralAtLeast5pct === 'PASS' &&
|
||||
gates.everyPropertyTypeAtLeast10 === 'PASS'
|
||||
? 'PASS'
|
||||
: 'FAIL';
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
totals: {
|
||||
listings: total,
|
||||
withPriceUsdM2: withPrice,
|
||||
labelCoveragePct: pct(withPrice, total),
|
||||
},
|
||||
regionHistogram,
|
||||
propertyTypeHistogram,
|
||||
parkCoverage,
|
||||
priceBuckets,
|
||||
gates,
|
||||
};
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function renderHuman(report: Report): string {
|
||||
const out: string[] = [];
|
||||
const push = (s = ''): void => {
|
||||
out.push(s);
|
||||
};
|
||||
|
||||
push('');
|
||||
push('━'.repeat(72));
|
||||
push(' Industrial listing — label density report');
|
||||
push(` generated: ${report.generatedAt}`);
|
||||
push('━'.repeat(72));
|
||||
|
||||
push('');
|
||||
push(' TOTALS');
|
||||
push(` listings: ${report.totals.listings}`);
|
||||
push(
|
||||
` with priceUsdM2: ${report.totals.withPriceUsdM2} ` +
|
||||
`(${report.totals.labelCoveragePct}%)`,
|
||||
);
|
||||
|
||||
push('');
|
||||
push(' BY REGION');
|
||||
push(' region | rows | withPrice');
|
||||
push(' ---------+------+----------');
|
||||
for (const [region, b] of Object.entries(report.regionHistogram)) {
|
||||
push(
|
||||
` ${region.padEnd(8)} | ${String(b.rows).padStart(4)} | ${String(
|
||||
b.withPrice,
|
||||
).padStart(8)}`,
|
||||
);
|
||||
}
|
||||
|
||||
push('');
|
||||
push(' BY PROPERTY TYPE');
|
||||
push(' type | rows | withPrice');
|
||||
push(' --------------------+------+----------');
|
||||
for (const [pt, b] of Object.entries(report.propertyTypeHistogram)) {
|
||||
push(
|
||||
` ${pt.padEnd(19)} | ${String(b.rows).padStart(4)} | ${String(
|
||||
b.withPrice,
|
||||
).padStart(8)}`,
|
||||
);
|
||||
}
|
||||
|
||||
push('');
|
||||
push(' PARK COVERAGE');
|
||||
push(
|
||||
` ${report.parkCoverage.parksWithListings} / ${report.parkCoverage.parksTotal} ` +
|
||||
`parks have ≥1 listing (${report.parkCoverage.coveragePct}%)`,
|
||||
);
|
||||
|
||||
push('');
|
||||
push(' priceUsdM2 P10/P50/P90 (USD/m², USD/m²/mo, or USD/m²/yr by type)');
|
||||
push(' region | type | n | P10 | P50 | P90');
|
||||
push(' ---------+---------------------+-----+--------+--------+-------');
|
||||
for (const bucket of report.priceBuckets) {
|
||||
if (bucket.percentiles.n === 0) continue;
|
||||
push(
|
||||
` ${bucket.region.padEnd(8)} | ${bucket.propertyType.padEnd(19)} | ` +
|
||||
`${String(bucket.percentiles.n).padStart(3)} | ${fmtPrice(
|
||||
bucket.percentiles.p10,
|
||||
)} | ${fmtPrice(bucket.percentiles.p50)} | ${fmtPrice(
|
||||
bucket.percentiles.p90,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
push('');
|
||||
push(' ACCEPTANCE GATES (TEC-2769)');
|
||||
push(` total ≥ 500 rows: ${report.gates.totalAtLeast500}`);
|
||||
push(` NORTH ≥ 20% of rows: ${report.gates.northAtLeast20pct}`);
|
||||
push(` SOUTH ≥ 20% of rows: ${report.gates.southAtLeast20pct}`);
|
||||
push(` CENTRAL ≥ 5% of rows: ${report.gates.centralAtLeast5pct}`);
|
||||
push(
|
||||
` every propertyType ≥ 10 rows: ${report.gates.everyPropertyTypeAtLeast10}`,
|
||||
);
|
||||
push(` OVERALL: ${report.gates.overall}`);
|
||||
push('');
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const asJson = process.argv.includes('--json');
|
||||
try {
|
||||
const report = await run();
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
console.log(renderHuman(report));
|
||||
}
|
||||
process.exit(report.gates.overall === 'PASS' ? 0 : 1);
|
||||
} catch (err) {
|
||||
console.error('Label-density report failed:', err);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
Reference in New Issue
Block a user