310 lines
14 KiB
TypeScript
310 lines
14 KiB
TypeScript
/**
|
||
* BACKFILL LABOR NODES — standalone script
|
||
*
|
||
* Parse le section ПОУЗЛОВОЙ d'un Excel smeta et écrit UNIQUEMENT
|
||
* dans smeta_labor_nodes (+ photos sur disque). Ne touche à RIEN d'autre.
|
||
*
|
||
* Usage:
|
||
* npx tsx scripts/backfill-labor-nodes.ts --file <chemin.xlsx> --smeta <smetaId>
|
||
* npx tsx scripts/backfill-labor-nodes.ts --file <chemin.xlsx> --smeta <smetaId> --apply
|
||
*
|
||
* --dry-run est le MODE PAR DÉFAUT (pas besoin de le passer).
|
||
* --apply écrit en DB + sauvegarde les photos.
|
||
*/
|
||
|
||
import { PrismaClient, Prisma } from '@prisma/client';
|
||
import * as XLSX from 'xlsx';
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import * as crypto from 'node:crypto';
|
||
import { parsePouzlovoySection, type WeldingNodeData } from '../src/modules/estimates/smeta-pouzlovoy-parser.js';
|
||
import { extractAllSheetImages, type AnchoredImage } from '../src/modules/estimates/smeta-node-image-extractor.js';
|
||
import { nodePhotoAbsPath, nodePhotoRelPath } from '../src/config/storage.js';
|
||
|
||
const prisma = new PrismaClient();
|
||
|
||
// ─── CLI args ──────────────────────────────────────────────────────
|
||
function parseArgs() {
|
||
const args = process.argv.slice(2);
|
||
let filePath: string | null = null;
|
||
let smetaId: number | null = null;
|
||
let apply = false;
|
||
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === '--file' && args[i + 1]) { filePath = args[++i]; continue; }
|
||
if (args[i] === '--smeta' && args[i + 1]) { smetaId = parseInt(args[++i], 10); continue; }
|
||
if (args[i] === '--apply') { apply = true; continue; }
|
||
}
|
||
|
||
if (!filePath || !smetaId || isNaN(smetaId)) {
|
||
console.error('Usage: npx tsx scripts/backfill-labor-nodes.ts --file <chemin.xlsx> --smeta <smetaId> [--apply]');
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!fs.existsSync(filePath)) {
|
||
console.error(`❌ Fichier introuvable : ${filePath}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
return { filePath, smetaId, apply };
|
||
}
|
||
|
||
// ─── Photo matching (replicated from SmetaImportService — private methods) ──
|
||
const MAX_DISTANCE = 5;
|
||
|
||
function findClosestImage(sortedImages: AnchoredImage[], headerRow: number): AnchoredImage | null {
|
||
let best: AnchoredImage | null = null;
|
||
let bestDist = Infinity;
|
||
for (const img of sortedImages) {
|
||
const dist = Math.abs(img.anchor.row - headerRow);
|
||
if (dist < bestDist && dist <= MAX_DISTANCE) {
|
||
bestDist = dist;
|
||
best = img;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function matchAndSaveNodePhotos(
|
||
smetaId: number,
|
||
xlsxBuffer: Buffer,
|
||
nodes: WeldingNodeData[],
|
||
): Map<number, { relPath: string; mime: string }> {
|
||
const result = new Map<number, { relPath: string; mime: string }>();
|
||
const images = extractAllSheetImages(xlsxBuffer);
|
||
if (images.length === 0) return result;
|
||
|
||
const sortedImages = [...images].sort((a, b) => a.anchor.row - b.anchor.row);
|
||
|
||
for (const node of nodes) {
|
||
const best = findClosestImage(sortedImages, node.headerRowIndex);
|
||
if (!best) continue;
|
||
|
||
try {
|
||
const ext = best.filename.split('.').pop()?.toLowerCase() ?? 'png';
|
||
const absPath = nodePhotoAbsPath(smetaId, node.position, ext);
|
||
const relPath = nodePhotoRelPath(smetaId, node.position, ext);
|
||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||
fs.writeFileSync(absPath, best.buffer);
|
||
result.set(node.position, { relPath, mime: best.mime });
|
||
} catch (e) {
|
||
console.warn(` ⚠ Photo node ${node.position} non sauvée :`, e);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ─── Invariance snapshot ────────────────────────────────────────────
|
||
interface InvarianceSnapshot {
|
||
smetaLineHash: string;
|
||
smetaTotals: string;
|
||
financialTracking: string | null;
|
||
}
|
||
|
||
async function captureSnapshot(smetaId: number, orderId: number | null): Promise<InvarianceSnapshot> {
|
||
const lines = await prisma.smetaLine.findMany({
|
||
where: { smetaId },
|
||
orderBy: { position: 'asc' },
|
||
select: { lineType: true, lineCategory: true, description: true, quantity: true, unitPrice: true, totalPrice: true, position: true },
|
||
});
|
||
const smetaLineHash = crypto.createHash('sha256').update(JSON.stringify(lines)).digest('hex');
|
||
|
||
const smeta = await prisma.smeta.findUnique({
|
||
where: { id: smetaId },
|
||
select: { totalServices: true, totalMaterials: true, totalLabor: true, supplyOverhead: true, margin: true, tradeSurcharge: true, sellingPrice: true },
|
||
});
|
||
const smetaTotals = crypto.createHash('sha256').update(JSON.stringify(smeta)).digest('hex');
|
||
|
||
let financialTracking: string | null = null;
|
||
if (orderId) {
|
||
const ft = await prisma.financialTracking.findUnique({
|
||
where: { orderId },
|
||
select: { budgetServices: true, budgetMaterials: true, budgetLabor: true, budgetSupply: true },
|
||
});
|
||
financialTracking = ft ? crypto.createHash('sha256').update(JSON.stringify(ft)).digest('hex') : null;
|
||
}
|
||
|
||
return { smetaLineHash, smetaTotals, financialTracking };
|
||
}
|
||
|
||
function snapshotsMatch(before: InvarianceSnapshot, after: InvarianceSnapshot): boolean {
|
||
if (before.smetaLineHash !== after.smetaLineHash) return false;
|
||
if (before.smetaTotals !== after.smetaTotals) return false;
|
||
if (before.financialTracking !== after.financialTracking) return false;
|
||
return true;
|
||
}
|
||
|
||
// ─── Main ──────────────────────────────────────────────────────────
|
||
async function main() {
|
||
const { filePath, smetaId, apply } = parseArgs();
|
||
|
||
// 1. Display target DB
|
||
const dbUrl = process.env.DATABASE_URL ?? '(non défini)';
|
||
const masked = dbUrl.replace(/:([^@]+)@/, ':***@');
|
||
console.log('╔══════════════════════════════════════════════════════════');
|
||
console.log(`║ BACKFILL LABOR NODES — ${apply ? '⚡ MODE APPLY' : '🔍 MODE DRY-RUN'}`);
|
||
console.log(`║ DB cible : ${masked}`);
|
||
console.log(`║ Smeta ID : ${smetaId}`);
|
||
console.log(`║ Fichier : ${filePath}`);
|
||
console.log('╚══════════════════════════════════════════════════════════');
|
||
|
||
// 2. Verify smeta exists
|
||
const smeta = await prisma.smeta.findUnique({
|
||
where: { id: smetaId },
|
||
include: { order: true },
|
||
});
|
||
if (!smeta) {
|
||
console.error(`❌ Smeta ${smetaId} introuvable.`);
|
||
process.exit(1);
|
||
}
|
||
|
||
const order = smeta.order;
|
||
console.log(`\n📋 Smeta trouvée :`);
|
||
console.log(` Version : ${smeta.version}`);
|
||
console.log(` Statut : ${smeta.status}`);
|
||
console.log(` isLocked : ${smeta.isLocked}`);
|
||
console.log(` importedFile : ${smeta.importedFile ?? '(aucun)'}`);
|
||
if (order) {
|
||
console.log(` Commande : ${order.orderCode} (id=${order.id}, status=${order.status})`);
|
||
}
|
||
|
||
// Warn if filename mismatch
|
||
const providedName = path.basename(filePath);
|
||
if (smeta.importedFile && smeta.importedFile !== providedName) {
|
||
console.warn(`\n⚠ ATTENTION : importedFile en base = "${smeta.importedFile}"`);
|
||
console.warn(` fichier fourni = "${providedName}"`);
|
||
console.warn(` Ceci peut être normal (même contenu, nom différent). Continuons.\n`);
|
||
}
|
||
|
||
// Check existing labor nodes
|
||
const existingCount = await prisma.smetaLaborNode.count({ where: { smetaId } });
|
||
if (existingCount > 0) {
|
||
console.log(`\n📦 ${existingCount} узлы EXISTANTS en base (seront remplacés si --apply).`);
|
||
}
|
||
|
||
// 3. Parse Excel
|
||
const buffer = fs.readFileSync(filePath);
|
||
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||
const pouzlovoy = parsePouzlovoySection(workbook);
|
||
|
||
if (!pouzlovoy.found || pouzlovoy.nodes.length === 0) {
|
||
console.log('\n⚠ Aucune section ПОУЗЛОВОЙ trouvée dans cet Excel.');
|
||
console.log(' 0 узел à écrire. Fin du script.');
|
||
await prisma.$disconnect();
|
||
process.exit(0);
|
||
}
|
||
|
||
// 4. Display summary
|
||
console.log(`\n🔧 ПОУЗЛОВОЙ parsé : ${pouzlovoy.nodes.length} узлы trouvés\n`);
|
||
console.log(' Pos | Nom | K-во | Сб. цена | Св. цена | Итого цена');
|
||
console.log(' ----+-----------------------------+------+------------+------------+-----------');
|
||
for (const n of pouzlovoy.nodes) {
|
||
const name = (n.nodeName ?? '').padEnd(27).substring(0, 27);
|
||
const cnt = String(n.nodeCount).padStart(4);
|
||
const aPrice = n.assemblyPrice != null ? n.assemblyPrice.toFixed(0).padStart(10) : ' -';
|
||
const wPrice = n.weldPrice != null ? n.weldPrice.toFixed(0).padStart(10) : ' -';
|
||
const tPrice = n.nodeTotalPrice != null ? n.nodeTotalPrice.toFixed(0).padStart(10) : ' -';
|
||
console.log(` ${String(n.position).padStart(3)} | ${name} | ${cnt} | ${aPrice} | ${wPrice} | ${tPrice}`);
|
||
}
|
||
|
||
// Check photos
|
||
const images = extractAllSheetImages(buffer);
|
||
console.log(`\n📸 Images dans la feuille Расчет : ${images.length}`);
|
||
|
||
// 5. Dry-run stop
|
||
if (!apply) {
|
||
console.log('\n✅ DRY-RUN terminé. Aucune écriture effectuée.');
|
||
console.log(' Pour appliquer : ajouter --apply');
|
||
await prisma.$disconnect();
|
||
return;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 6. APPLY MODE
|
||
// ═══════════════════════════════════════════════════════════════
|
||
console.log('\n⚡ MODE APPLY — Écriture en cours...');
|
||
|
||
// 6a. Snapshot AVANT
|
||
const orderId = order?.id ?? null;
|
||
const before = await captureSnapshot(smetaId, orderId);
|
||
console.log(' 📷 Snapshot AVANT capturé (SmetaLine + totaux + FinancialTracking)');
|
||
|
||
// 6b. Save photos (outside transaction — same pattern as importXlsx)
|
||
let nodePhotos = new Map<number, { relPath: string; mime: string }>();
|
||
try {
|
||
nodePhotos = matchAndSaveNodePhotos(smetaId, buffer, pouzlovoy.nodes);
|
||
console.log(` 📸 ${nodePhotos.size} photos sauvées sur disque`);
|
||
} catch (e) {
|
||
console.warn(' ⚠ Extraction photos échouée (non bloquant) :', e);
|
||
}
|
||
|
||
// 6c. Transaction: ONLY smeta_labor_nodes
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.smetaLaborNode.deleteMany({ where: { smetaId } });
|
||
|
||
await tx.smetaLaborNode.createMany({
|
||
data: pouzlovoy.nodes.map((node) => {
|
||
const photo = nodePhotos.get(node.position);
|
||
return {
|
||
smetaId,
|
||
position: node.position,
|
||
nodeName: node.nodeName,
|
||
nodeCount: node.nodeCount,
|
||
detailCount: node.detailCount,
|
||
assemblyNormMin: node.assemblyNormMin != null ? new Prisma.Decimal(node.assemblyNormMin) : null,
|
||
assemblyTimeH: node.assemblyTimeH != null ? new Prisma.Decimal(node.assemblyTimeH) : null,
|
||
assemblyRate: node.assemblyRate != null ? new Prisma.Decimal(node.assemblyRate) : null,
|
||
assemblyPrice: node.assemblyPrice != null ? new Prisma.Decimal(node.assemblyPrice) : null,
|
||
weldMeters: node.weldMeters != null ? new Prisma.Decimal(node.weldMeters) : null,
|
||
weldNormMin: node.weldNormMin != null ? new Prisma.Decimal(node.weldNormMin) : null,
|
||
weldTimeH: node.weldTimeH != null ? new Prisma.Decimal(node.weldTimeH) : null,
|
||
weldRate: node.weldRate != null ? new Prisma.Decimal(node.weldRate) : null,
|
||
weldPrice: node.weldPrice != null ? new Prisma.Decimal(node.weldPrice) : null,
|
||
nodeTotalTime: node.nodeTotalTime != null ? new Prisma.Decimal(node.nodeTotalTime) : null,
|
||
nodeTotalPrice: node.nodeTotalPrice != null ? new Prisma.Decimal(node.nodeTotalPrice) : null,
|
||
conductorTimeMin: node.conductorTimeMin != null ? new Prisma.Decimal(node.conductorTimeMin) : null,
|
||
photoPath: photo?.relPath ?? null,
|
||
photoMime: photo?.mime ?? null,
|
||
anchorRow: node.headerRowIndex,
|
||
};
|
||
}),
|
||
});
|
||
});
|
||
console.log(` ✅ ${pouzlovoy.nodes.length} узлы écrits en DB`);
|
||
|
||
// 6d. Snapshot APRÈS — garde d'invariance
|
||
const after = await captureSnapshot(smetaId, orderId);
|
||
|
||
if (!snapshotsMatch(before, after)) {
|
||
console.error('\n❌ GARDE D\'INVARIANCE ÉCHOUÉE !');
|
||
console.error(' Quelque chose a changé dans SmetaLine / totaux / FinancialTracking.');
|
||
console.error(' AVANT :', before);
|
||
console.error(' APRÈS :', after);
|
||
console.error(' ⚠ Les узлы ont été écrits (transaction commitée) mais les invariants sont cassés.');
|
||
console.error(' Investiguer immédiatement.');
|
||
await prisma.$disconnect();
|
||
process.exit(2);
|
||
}
|
||
|
||
console.log(' ✅ Garde d\'invariance OK :');
|
||
console.log(` SmetaLine hash : ${before.smetaLineHash.substring(0, 16)}... = identique`);
|
||
console.log(` Totaux smeta : ${before.smetaTotals.substring(0, 16)}... = identique`);
|
||
console.log(` FinancialTracking: ${before.financialTracking ? before.financialTracking.substring(0, 16) + '... = identique' : '(pas de FT pour cette commande)'}`);
|
||
|
||
// 6e. Verify in DB
|
||
const written = await prisma.smetaLaborNode.count({ where: { smetaId } });
|
||
console.log(`\n🎯 RÉSULTAT FINAL :`);
|
||
console.log(` ${written} узлы en base pour smetaId=${smetaId}`);
|
||
console.log(` ${nodePhotos.size} photos sous STORAGE_ROOT/smeta-nodes/${smetaId}/`);
|
||
console.log(` SmetaLine / totaux / FinancialTracking : INTACTS`);
|
||
console.log(' ✅ Backfill terminé avec succès.');
|
||
|
||
await prisma.$disconnect();
|
||
}
|
||
|
||
main().catch(async (e) => {
|
||
console.error('❌ Erreur fatale :', e);
|
||
await prisma.$disconnect();
|
||
process.exit(1);
|
||
});
|