- Backend NestJS orchestrant CloudCompare CLI (stats, decimation x2 sur 5 etapes, volume, assemblage .bin) - Scripts Python (laspy, scipy, matplotlib) pour la conversion LAS/LAZ et la generation d'images - Frontend web simple (Tailwind CDN) avec historique des runs - Image Docker (debian:trixie-slim + cloudcompare apt + xvfb) prete pour deploiement Coolify - Lint/typecheck/tests unitaires integres comme portes de qualite au build Docker - CLAUDE.md documentant les comportements CloudCompare CLI verifies empiriquement
167 lines
4.9 KiB
TypeScript
167 lines
4.9 KiB
TypeScript
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { resultsDir, uploadsDir } from '../config';
|
|
import { PipelineService } from '../pipeline/pipeline.service';
|
|
import { RunParamsInput } from '../pipeline/types';
|
|
import { DatabaseService, RunRow } from './database.service';
|
|
|
|
export interface CreateRunOptions {
|
|
tmpFilePath: string;
|
|
originalFilename: string;
|
|
params: RunParamsInput;
|
|
}
|
|
|
|
interface QueueJob {
|
|
id: string;
|
|
inputPath: string;
|
|
originalFilename: string;
|
|
params: RunParamsInput;
|
|
}
|
|
|
|
@Injectable()
|
|
export class RunsService {
|
|
private readonly logger = new Logger(RunsService.name);
|
|
private queue: QueueJob[] = [];
|
|
private processing = false;
|
|
|
|
constructor(
|
|
private readonly db: DatabaseService,
|
|
private readonly pipeline: PipelineService,
|
|
) {}
|
|
|
|
async createRun(opts: CreateRunOptions): Promise<{ id: string }> {
|
|
const id = uuidv4();
|
|
const runUploadDir = path.join(uploadsDir(), id);
|
|
await fs.mkdir(runUploadDir, { recursive: true });
|
|
|
|
const ext = this.extensionOf(opts.originalFilename);
|
|
const destPath = path.join(runUploadDir, `input${ext}`);
|
|
await fs.rename(opts.tmpFilePath, destPath);
|
|
|
|
this.db.insert({
|
|
id,
|
|
original_filename: opts.originalFilename,
|
|
status: 'pending',
|
|
created_at: new Date().toISOString(),
|
|
params_json: JSON.stringify(opts.params),
|
|
});
|
|
|
|
this.queue.push({ id, inputPath: destPath, originalFilename: opts.originalFilename, params: opts.params });
|
|
void this.processQueue();
|
|
|
|
return { id };
|
|
}
|
|
|
|
private async processQueue() {
|
|
if (this.processing) return;
|
|
this.processing = true;
|
|
try {
|
|
while (this.queue.length > 0) {
|
|
const job = this.queue.shift()!;
|
|
await this.runJob(job);
|
|
}
|
|
} finally {
|
|
this.processing = false;
|
|
}
|
|
}
|
|
|
|
private async runJob(job: QueueJob) {
|
|
this.db.updateStatus(job.id, 'running');
|
|
const workDir = path.join(resultsDir(), job.id);
|
|
await fs.mkdir(workDir, { recursive: true });
|
|
|
|
const onProgress = (msg: string) => {
|
|
this.logger.log(`[${job.id}] ${msg}`);
|
|
this.db.appendLog(job.id, msg);
|
|
};
|
|
|
|
try {
|
|
const report = await this.pipeline.execute(job.id, job.inputPath, job.originalFilename, workDir, job.params, onProgress);
|
|
this.db.complete(job.id, JSON.stringify(report));
|
|
onProgress('Run termine avec succes.');
|
|
} catch (err: any) {
|
|
this.logger.error(`Run ${job.id} en erreur: ${err.message}`);
|
|
this.db.appendLog(job.id, `ERREUR FATALE: ${err.message}`);
|
|
this.db.fail(job.id, err.message);
|
|
}
|
|
}
|
|
|
|
list() {
|
|
return this.db.list().map((r) => this.toSummary(r));
|
|
}
|
|
|
|
getDetail(id: string) {
|
|
const row = this.db.get(id);
|
|
if (!row) throw new NotFoundException(`Run ${id} introuvable`);
|
|
return {
|
|
id: row.id,
|
|
originalFilename: row.original_filename,
|
|
status: row.status,
|
|
createdAt: row.created_at,
|
|
finishedAt: row.finished_at,
|
|
params: row.params_json ? JSON.parse(row.params_json) : null,
|
|
report: row.report_json ? JSON.parse(row.report_json) : null,
|
|
log: row.log,
|
|
error: row.error,
|
|
};
|
|
}
|
|
|
|
getRowOrThrow(id: string): RunRow {
|
|
const row = this.db.get(id);
|
|
if (!row) throw new NotFoundException(`Run ${id} introuvable`);
|
|
return row;
|
|
}
|
|
|
|
workDirFor(id: string): string {
|
|
return path.join(resultsDir(), id);
|
|
}
|
|
|
|
async delete(id: string) {
|
|
this.getRowOrThrow(id); // leve NotFoundException si le run n'existe pas
|
|
this.db.remove(id);
|
|
const work = this.workDirFor(id);
|
|
const upload = path.join(uploadsDir(), id);
|
|
await fs.rm(work, { recursive: true, force: true });
|
|
await fs.rm(upload, { recursive: true, force: true });
|
|
return { id };
|
|
}
|
|
|
|
private toSummary(row: RunRow) {
|
|
let summary: any = null;
|
|
if (row.report_json) {
|
|
try {
|
|
const report = JSON.parse(row.report_json);
|
|
const okLevels = (report.levels ?? []).filter((l: any) => l.status === 'ok');
|
|
const first = okLevels[0];
|
|
const last = okLevels[okLevels.length - 1];
|
|
summary = {
|
|
levelsOk: okLevels.length,
|
|
levelsTotal: report.levels?.length ?? 0,
|
|
volumeLevel0: first?.volume ?? null,
|
|
volumeLastLevel: last?.volume ?? null,
|
|
volumeDeltaPct: first && last && first.volume ? (100 * (last.volume - first.volume)) / first.volume : null,
|
|
};
|
|
} catch {
|
|
summary = null;
|
|
}
|
|
}
|
|
return {
|
|
id: row.id,
|
|
originalFilename: row.original_filename,
|
|
status: row.status,
|
|
createdAt: row.created_at,
|
|
finishedAt: row.finished_at,
|
|
error: row.error,
|
|
summary,
|
|
};
|
|
}
|
|
|
|
private extensionOf(filename: string): string {
|
|
const lower = filename.toLowerCase();
|
|
if (lower.endsWith('.copc.laz')) return '.copc.laz';
|
|
const idx = lower.lastIndexOf('.');
|
|
return idx >= 0 ? filename.slice(idx) : '';
|
|
}
|
|
}
|