metallkart-erp/scripts/backfill-labor-nodes.ts

310 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});