chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import { execFile as execFileCallback } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
export const MAX_ASSET_IMPORT_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_XLSX_UNCOMPRESSED_BYTES = 120 * 1024 * 1024;
|
||||
const MAX_XLSX_ENTRIES = 2500;
|
||||
const MAX_ROWS_PER_SHEET = 50_000;
|
||||
const MAX_COLUMNS = 120;
|
||||
|
||||
export interface UploadedImportFile {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface ParsedSheet {
|
||||
name: string;
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export interface ParsedWorkbook {
|
||||
kind: 'XLSX' | 'CSV';
|
||||
sheets: ParsedSheet[];
|
||||
}
|
||||
|
||||
function importFileError(code: string, message: string): BadRequestException {
|
||||
return new BadRequestException({ code, message });
|
||||
}
|
||||
|
||||
export function inspectImportFile(file: UploadedImportFile | undefined): { extension: '.xlsx' | '.csv'; mimeType: string } {
|
||||
if (!file?.buffer?.length) throw importFileError('IMPORT_FILE_REQUIRED', 'Seleccioná un archivo XLSX o CSV');
|
||||
if (file.buffer.length > MAX_ASSET_IMPORT_BYTES) throw importFileError('IMPORT_FILE_TOO_LARGE', 'El archivo supera el límite de 25 MB');
|
||||
const lower = file.originalname.toLowerCase();
|
||||
if (lower.endsWith('.xlsx')) {
|
||||
if (!(file.buffer[0] === 0x50 && file.buffer[1] === 0x4b)) {
|
||||
throw importFileError('INVALID_XLSX_FILE', 'El contenido no corresponde a un archivo XLSX válido');
|
||||
}
|
||||
return { extension: '.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' };
|
||||
}
|
||||
if (lower.endsWith('.csv')) {
|
||||
if (file.buffer.includes(0)) throw importFileError('INVALID_CSV_FILE', 'El CSV contiene datos binarios no admitidos');
|
||||
return { extension: '.csv', mimeType: 'text/csv' };
|
||||
}
|
||||
throw importFileError('UNSUPPORTED_IMPORT_FILE', 'Sólo se admiten archivos .xlsx y .csv');
|
||||
}
|
||||
|
||||
function decodeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
||||
.replace(/&#([0-9]+);/g, (_match, decimal: string) => String.fromCodePoint(Number.parseInt(decimal, 10)))
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function stripXmlText(xml: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const match of xml.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)) parts.push(decodeXml(match[1] ?? ''));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function columnIndex(reference: string): number {
|
||||
const match = /^([A-Z]+)\d+$/i.exec(reference);
|
||||
if (!match) return -1;
|
||||
let result = 0;
|
||||
for (const char of match[1]!.toUpperCase()) result = result * 26 + (char.charCodeAt(0) - 64);
|
||||
return result - 1;
|
||||
}
|
||||
|
||||
async function zipList(filePath: string): Promise<string[]> {
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await execFile('unzip', ['-Z1', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }));
|
||||
} catch {
|
||||
throw importFileError('XLSX_UNZIP_UNAVAILABLE', 'No se pudo inspeccionar el XLSX. Verificá que el archivo no esté dañado');
|
||||
}
|
||||
const entries = stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
|
||||
if (entries.length > MAX_XLSX_ENTRIES) throw importFileError('XLSX_TOO_COMPLEX', 'El XLSX contiene demasiados archivos internos');
|
||||
if (entries.some((entry) => entry.startsWith('/') || entry.split('/').includes('..'))) {
|
||||
throw importFileError('INVALID_XLSX_PATH', 'El XLSX contiene rutas internas inválidas');
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function assertZipSize(filePath: string): Promise<void> {
|
||||
try {
|
||||
const { stdout } = await execFile('unzip', ['-l', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
|
||||
const summary = stdout.split(/\r?\n/).reverse().find((line) => /\bfiles?\b/.test(line));
|
||||
const bytes = summary ? Number(/^\s*(\d+)/.exec(summary)?.[1] ?? 0) : 0;
|
||||
if (Number.isFinite(bytes) && bytes > MAX_XLSX_UNCOMPRESSED_BYTES) {
|
||||
throw importFileError('XLSX_UNCOMPRESSED_TOO_LARGE', 'El contenido descomprimido del XLSX supera el límite de seguridad');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) throw error;
|
||||
throw importFileError('INVALID_XLSX_FILE', 'No se pudo leer la estructura interna del XLSX');
|
||||
}
|
||||
}
|
||||
|
||||
async function zipEntry(filePath: string, entry: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFile('unzip', ['-p', filePath, entry], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: MAX_XLSX_UNCOMPRESSED_BYTES,
|
||||
});
|
||||
return stdout;
|
||||
} catch {
|
||||
throw importFileError('INVALID_XLSX_FILE', `No se pudo leer ${entry} dentro del XLSX`);
|
||||
}
|
||||
}
|
||||
|
||||
function workbookSheets(workbookXml: string, relationshipsXml: string): Array<{ name: string; path: string }> {
|
||||
const relationTargets = new Map<string, string>();
|
||||
for (const relation of relationshipsXml.matchAll(/<Relationship\b([^>]*)\/?\s*>/g)) {
|
||||
const attributes = relation[1] ?? '';
|
||||
const id = /\bId="([^"]+)"/.exec(attributes)?.[1];
|
||||
const target = /\bTarget="([^"]+)"/.exec(attributes)?.[1];
|
||||
if (id && target) relationTargets.set(id, target);
|
||||
}
|
||||
const result: Array<{ name: string; path: string }> = [];
|
||||
for (const sheet of workbookXml.matchAll(/<sheet\b([^>]*)\/?\s*>/g)) {
|
||||
const attributes = sheet[1] ?? '';
|
||||
const name = decodeXml(/\bname="([^"]+)"/.exec(attributes)?.[1] ?? 'Hoja');
|
||||
const relationId = /\br:id="([^"]+)"/.exec(attributes)?.[1];
|
||||
if (!relationId) continue;
|
||||
const target = relationTargets.get(relationId);
|
||||
if (!target) continue;
|
||||
const clean = target.replace(/^\//, '');
|
||||
const path = clean.startsWith('xl/') ? clean : `xl/${clean.replace(/^\.\//, '')}`;
|
||||
result.push({ name, path });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSharedStrings(xml: string): string[] {
|
||||
const result: string[] = [];
|
||||
for (const match of xml.matchAll(/<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g)) result.push(stripXmlText(match[1] ?? ''));
|
||||
return result;
|
||||
}
|
||||
|
||||
function cellValue(cellXml: string, cellType: string | undefined, sharedStrings: string[]): string {
|
||||
if (cellType === 'inlineStr') return stripXmlText(cellXml).trim();
|
||||
const raw = /<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(cellXml)?.[1] ?? '';
|
||||
const value = decodeXml(raw);
|
||||
if (cellType === 's') {
|
||||
const index = Number.parseInt(value, 10);
|
||||
return Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
|
||||
}
|
||||
if (cellType === 'b') return value === '1' ? 'TRUE' : 'FALSE';
|
||||
if (cellType === 'str') return value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSheetXml(xml: string, sharedStrings: string[]): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let count = 0;
|
||||
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
|
||||
if (++count > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `La hoja supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`);
|
||||
const values: string[] = [];
|
||||
const rowXml = rowMatch[1] ?? '';
|
||||
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||
const attributes = cellMatch[1] ?? '';
|
||||
const reference = /\br="([A-Z]+\d+)"/i.exec(attributes)?.[1];
|
||||
if (!reference) continue;
|
||||
const index = columnIndex(reference);
|
||||
if (index < 0 || index >= MAX_COLUMNS) continue;
|
||||
const type = /\bt="([^"]+)"/.exec(attributes)?.[1];
|
||||
values[index] = cellValue(cellMatch[2] ?? '', type, sharedStrings).trim();
|
||||
}
|
||||
while (values.length && !values[values.length - 1]) values.pop();
|
||||
rows.push(values.map((value) => value ?? ''));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function detectDelimiter(firstLines: string[]): ',' | ';' | '\t' {
|
||||
const candidates: Array<',' | ';' | '\t'> = [',', ';', '\t'];
|
||||
let best: ',' | ';' | '\t' = ',';
|
||||
let score = -1;
|
||||
for (const candidate of candidates) {
|
||||
const current = firstLines.reduce((sum, line) => sum + line.split(candidate).length - 1, 0);
|
||||
if (current > score) { score = current; best = candidate; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function parseCsv(text: string): string[][] {
|
||||
const normalized = text.replace(/^\uFEFF/, '');
|
||||
const sample = normalized.split(/\r?\n/).slice(0, 8);
|
||||
const delimiter = detectDelimiter(sample);
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let value = '';
|
||||
let quoted = false;
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
const char = normalized[i]!;
|
||||
if (char === '"') {
|
||||
if (quoted && normalized[i + 1] === '"') { value += '"'; i += 1; }
|
||||
else quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (!quoted && char === delimiter) { row.push(value.trim()); value = ''; continue; }
|
||||
if (!quoted && (char === '\n' || char === '\r')) {
|
||||
if (char === '\r' && normalized[i + 1] === '\n') i += 1;
|
||||
row.push(value.trim()); value = '';
|
||||
if (row.some(Boolean)) rows.push(row);
|
||||
row = [];
|
||||
if (rows.length > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `El archivo supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`);
|
||||
continue;
|
||||
}
|
||||
value += char;
|
||||
}
|
||||
if (value.length || row.length) { row.push(value.trim()); if (row.some(Boolean)) rows.push(row); }
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function parseImportWorkbook(filePath: string, extension: '.xlsx' | '.csv'): Promise<ParsedWorkbook> {
|
||||
if (extension === '.csv') {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const text = await readFile(filePath, 'utf8');
|
||||
return { kind: 'CSV', sheets: [{ name: 'CSV', rows: parseCsv(text) }] };
|
||||
}
|
||||
await assertZipSize(filePath);
|
||||
const entries = await zipList(filePath);
|
||||
if (!entries.includes('xl/workbook.xml') || !entries.includes('xl/_rels/workbook.xml.rels')) {
|
||||
throw importFileError('INVALID_XLSX_FILE', 'El archivo no contiene una estructura XLSX compatible');
|
||||
}
|
||||
const [workbookXml, relationshipsXml] = await Promise.all([
|
||||
zipEntry(filePath, 'xl/workbook.xml'),
|
||||
zipEntry(filePath, 'xl/_rels/workbook.xml.rels'),
|
||||
]);
|
||||
const sharedStrings = entries.includes('xl/sharedStrings.xml')
|
||||
? parseSharedStrings(await zipEntry(filePath, 'xl/sharedStrings.xml'))
|
||||
: [];
|
||||
const sheets = workbookSheets(workbookXml, relationshipsXml);
|
||||
if (!sheets.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'El XLSX no contiene hojas legibles');
|
||||
const parsed: ParsedSheet[] = [];
|
||||
for (const sheet of sheets.slice(0, 30)) {
|
||||
if (!entries.includes(sheet.path)) continue;
|
||||
const xml = await zipEntry(filePath, sheet.path);
|
||||
parsed.push({ name: sheet.name, rows: parseSheetXml(xml, sharedStrings) });
|
||||
}
|
||||
if (!parsed.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'No se pudo leer ninguna hoja del XLSX');
|
||||
return { kind: 'XLSX', sheets: parsed };
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export type AssetImportPlanEntityKind = 'DEPARTMENT' | 'ORGANIZATION' | 'AREA' | 'AREA_DEPARTMENT_RELATION' | 'FIELD' | 'OPERATOR_RELATION' | 'LEGAL_RIGHT' | 'LEGAL_RIGHT_ORGANIZATION' | 'INSTALLATION' | 'LOCAL_STRUCTURE' | 'TECHNICAL_ASSET';
|
||||
export type AssetImportPlanAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
|
||||
export type AssetImportPlanItemStatus = 'PLANNED' | 'MATCHED' | 'REVIEW' | 'IGNORED' | 'APPLIED' | 'ROLLED_BACK' | 'FAILED';
|
||||
export type AssetImportPlanStatus = 'REVIEW_REQUIRED' | 'READY' | 'APPLIED' | 'ROLLED_BACK' | 'SUPERSEDED' | 'FAILED';
|
||||
|
||||
export interface AssetImportPlanDraftItem {
|
||||
entityKey: string;
|
||||
entityKind: AssetImportPlanEntityKind;
|
||||
action: AssetImportPlanAction;
|
||||
status: AssetImportPlanItemStatus;
|
||||
assetTypeCode: string | null;
|
||||
displayName: string;
|
||||
generatedCode: string | null;
|
||||
parentEntityKey: string | null;
|
||||
matchedAssetId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
sourceRowNumbers: number[];
|
||||
reviewCodes: string[];
|
||||
}
|
||||
|
||||
export function plainImportKey(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function organizationImportKey(value: unknown): string {
|
||||
const tokens = plainImportKey(value).split(' ').filter(Boolean);
|
||||
const output: string[] = [];
|
||||
for (let index = 0; index < tokens.length;) {
|
||||
if (tokens[index]!.length !== 1) { output.push(tokens[index]!); index += 1; continue; }
|
||||
const letters: string[] = [];
|
||||
let cursor = index;
|
||||
while (cursor < tokens.length && tokens[cursor]!.length === 1) { letters.push(tokens[cursor]!); cursor += 1; }
|
||||
output.push(letters.length >= 2 ? letters.join('') : letters[0]!);
|
||||
index = cursor;
|
||||
}
|
||||
return output.join(' ');
|
||||
}
|
||||
|
||||
|
||||
export function isExplicitlyUnassignedOperator(value: unknown): boolean {
|
||||
return plainImportKey(value) === 'sin empresa operadora';
|
||||
}
|
||||
|
||||
|
||||
export type NormalizedLegalRightType = 'EXPLOITATION_CONCESSION' | 'EXPLORATION_PERMIT' | 'TRANSPORT_CONCESSION' | 'OTHER';
|
||||
|
||||
export function normalizedLegalRightType(value: unknown): NormalizedLegalRightType | null {
|
||||
const normalized = plainImportKey(value);
|
||||
if (normalized === 'explotacion') return 'EXPLOITATION_CONCESSION';
|
||||
if (normalized === 'exploracion') return 'EXPLORATION_PERMIT';
|
||||
if (normalized === 'transporte' || normalized === 'concesion de transporte') return 'TRANSPORT_CONCESSION';
|
||||
if (!normalized) return null;
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
export function legalRightTypeLabel(value: NormalizedLegalRightType): string {
|
||||
if (value === 'EXPLOITATION_CONCESSION') return 'Concesión de explotación';
|
||||
if (value === 'EXPLORATION_PERMIT') return 'Permiso de exploración';
|
||||
if (value === 'TRANSPORT_CONCESSION') return 'Concesión de transporte';
|
||||
return 'Otro derecho';
|
||||
}
|
||||
|
||||
export function departmentCode(value: unknown): string {
|
||||
const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 70);
|
||||
return normalized || 'SIN-DEPARTAMENTO';
|
||||
}
|
||||
|
||||
export function externalIdNamespace(input: string | null | undefined, originalName: string): string {
|
||||
const seed = (input?.trim() || originalName.replace(/\.[^.]+$/, '').split(/[-_]/)[0] || 'IMPORT').normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
const normalized = seed.toUpperCase().replace(/[^A-Z0-9._/-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
||||
if (normalized.length >= 2 && /^[A-Z0-9]/.test(normalized)) return normalized;
|
||||
return 'IMPORT';
|
||||
}
|
||||
|
||||
export function generatedImportCode(planId: string, typeCode: string, entityKey: string): string {
|
||||
const prefix = typeCode.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'ACT';
|
||||
const digest = createHash('sha256').update(`${planId}\u001f${entityKey}`).digest('hex').slice(0, 12).toUpperCase();
|
||||
return `IMP-${prefix}-${digest}`;
|
||||
}
|
||||
|
||||
export function technicalFamilyTypeCode(family: unknown, subtype: unknown): string {
|
||||
const normalizedFamily = plainImportKey(family).replace(/ /g, '_');
|
||||
const normalizedSubtype = plainImportKey(subtype).replace(/ /g, '_');
|
||||
const direct = new Set(['tanque','separador','bomba','caldera','antorcha','colector','filtro','calentador','ducto','pozo']);
|
||||
if (direct.has(normalizedFamily)) return normalizedFamily;
|
||||
if (normalizedFamily === 'pileta') return 'pileta_api';
|
||||
if (normalizedFamily === 'defensa_incendios') return 'sistema_defensa_incendios';
|
||||
if (normalizedFamily === 'instalacion' && normalizedSubtype === 'planta') return 'planta';
|
||||
if (normalizedFamily === 'instalacion' && normalizedSubtype === 'bateria') return 'bateria';
|
||||
return 'equipo';
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface SourceLocalStructureSuggestion {
|
||||
displayName: string;
|
||||
sourcePath: string[];
|
||||
sourceInstallation: string | null;
|
||||
sourceSubInstallation: string | null;
|
||||
concreteFromLocation: boolean;
|
||||
sourceGroupOnly: boolean;
|
||||
}
|
||||
|
||||
export function sourceLocalStructureSuggestion(
|
||||
areaOrField: unknown,
|
||||
installation: unknown,
|
||||
subInstallation: unknown,
|
||||
location: unknown,
|
||||
): SourceLocalStructureSuggestion | null {
|
||||
const areaText = String(areaOrField ?? '').trim();
|
||||
const installationText = String(installation ?? '').trim();
|
||||
const subInstallationText = String(subInstallation ?? '').trim();
|
||||
const locationText = String(location ?? '').trim();
|
||||
const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value));
|
||||
const sourcePath = locationText.split('/').map((segment) => segment.trim()).filter((segment) => !unusable(segment));
|
||||
const areaKey = plainImportKey(areaText);
|
||||
const installationKey = plainImportKey(installationText);
|
||||
const subInstallationKey = plainImportKey(subInstallationText);
|
||||
const genericProvinceKeys = new Set(['mendoza', 'provincia de mendoza']);
|
||||
const genericLocationKeys = new Set([
|
||||
'yacimiento','planta','energia','edilicio','transporte','repositorio','repositorios','op digitales',
|
||||
'bateria','set','pta','ptc','em','pcg','et','oficina','estacion de servicio','taller','almacen',
|
||||
]);
|
||||
|
||||
const contextualSegments = sourcePath.filter((segment, index) => {
|
||||
const key = plainImportKey(segment);
|
||||
if (!key) return false;
|
||||
if (index === 0 && genericProvinceKeys.has(key)) return false;
|
||||
if (areaKey && key === areaKey) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const concrete = [...contextualSegments].reverse().find((segment) => {
|
||||
const key = plainImportKey(segment);
|
||||
if (!key) return false;
|
||||
if (installationKey && key === installationKey) return false;
|
||||
if (subInstallationKey && key === subInstallationKey) return false;
|
||||
if (/^pozo\b/.test(key)) return false;
|
||||
return !genericLocationKeys.has(key);
|
||||
}) ?? null;
|
||||
|
||||
if (concrete) {
|
||||
return {
|
||||
displayName: concrete.slice(0, 200),
|
||||
sourcePath,
|
||||
sourceInstallation: unusable(installationText) ? null : installationText,
|
||||
sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText,
|
||||
concreteFromLocation: true,
|
||||
sourceGroupOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
const categoryParts = [installationText, subInstallationText]
|
||||
.filter((value) => !unusable(value))
|
||||
.filter((value, index, values) => values.findIndex((candidate) => plainImportKey(candidate) === plainImportKey(value)) === index);
|
||||
if (!categoryParts.length) return null;
|
||||
return {
|
||||
displayName: categoryParts.join(' / ').slice(0, 200),
|
||||
sourcePath,
|
||||
sourceInstallation: unusable(installationText) ? null : installationText,
|
||||
sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText,
|
||||
concreteFromLocation: false,
|
||||
sourceGroupOnly: true,
|
||||
};
|
||||
}
|
||||
export function sourceContainerTypeCode(installation: unknown, subInstallation?: unknown): string {
|
||||
const parent = plainImportKey(installation);
|
||||
const sub = plainImportKey(subInstallation);
|
||||
if (sub === 'bateria') return 'bateria';
|
||||
if (['pta', 'ptc', 'pcg'].includes(sub)) return 'planta';
|
||||
if (sub === 'estacion de servicio') return 'estacion';
|
||||
if (/\bplanta\b/.test(parent)) return 'planta';
|
||||
if (/\bbateria\b/.test(parent)) return 'bateria';
|
||||
if (/\bsubestacion\b/.test(parent)) return 'subestacion';
|
||||
if (/\bestacion\b/.test(parent)) return 'estacion';
|
||||
if (/\blocacion\b/.test(parent)) return 'locacion';
|
||||
return 'instalacion';
|
||||
}
|
||||
|
||||
export function sourceContainerName(installation: unknown, subInstallation: unknown, location?: unknown): string | null {
|
||||
const sub = String(subInstallation ?? '').trim();
|
||||
const parent = String(installation ?? '').trim();
|
||||
const locationText = String(location ?? '').trim();
|
||||
const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value));
|
||||
const genericSub = new Set(['bateria','set','pta','ptc','em','pcg','et','transporte','oficina','repositorio','repositorios','estacion de servicio','taller','op digitales','almacen']);
|
||||
const subKey = plainImportKey(sub);
|
||||
if (!unusable(sub) && !genericSub.has(subKey)) return sub;
|
||||
|
||||
const segments = locationText.split('/').map((segment) => segment.trim()).filter(Boolean);
|
||||
const prefixes: Record<string, RegExp> = {
|
||||
bateria: /^BAT[A-Z0-9-]/i,
|
||||
pta: /^PTA[A-Z0-9-]/i,
|
||||
ptc: /^(PTC[A-Z0-9-]|ESTACION DE BOMBEO)/i,
|
||||
pcg: /^PCG[A-Z0-9-]/i,
|
||||
};
|
||||
const prefix = prefixes[subKey];
|
||||
if (prefix) {
|
||||
const candidate = segments.find((segment) => plainImportKey(segment) !== subKey && prefix.test(segment));
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
|
||||
if (!unusable(parent) && !['planta','bateria','estacion','subestacion','yacimiento','locacion','energia','edilicio'].includes(plainImportKey(parent))) return parent;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function planItemHash(items: Array<Pick<AssetImportPlanDraftItem, 'entityKey' | 'entityKind' | 'action' | 'status' | 'assetTypeCode' | 'displayName' | 'generatedCode' | 'parentEntityKey' | 'matchedAssetId' | 'payload' | 'sourceRowNumbers' | 'reviewCodes'>>): string {
|
||||
const canonical = items
|
||||
.map((item) => ({
|
||||
entityKey: item.entityKey,
|
||||
entityKind: item.entityKind,
|
||||
action: item.action,
|
||||
status: item.status,
|
||||
assetTypeCode: item.assetTypeCode,
|
||||
displayName: item.displayName,
|
||||
generatedCode: item.generatedCode,
|
||||
parentEntityKey: item.parentEntityKey,
|
||||
matchedAssetId: item.matchedAssetId,
|
||||
payload: item.payload,
|
||||
sourceRowNumbers: [...item.sourceRowNumbers].sort((a, b) => a - b),
|
||||
reviewCodes: [...item.reviewCodes].sort(),
|
||||
}))
|
||||
.sort((a, b) => a.entityKey.localeCompare(b.entityKey));
|
||||
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
||||
}
|
||||
|
||||
export const PLAN_DEPENDENCY_REVIEW_CODES = new Set([
|
||||
'PLAN_DEPARTMENT_REVIEW_REQUIRED',
|
||||
'PLAN_LEGAL_RIGHT_REVIEW_REQUIRED',
|
||||
'PLAN_AREA_REVIEW_REQUIRED',
|
||||
'PLAN_ORGANIZATION_REVIEW_REQUIRED',
|
||||
'PLAN_TERRITORY_CONTEXT_REQUIRED',
|
||||
'PLAN_CONTEXT_DECISION_REQUIRED',
|
||||
'PLAN_CONTAINER_REVIEW_REQUIRED',
|
||||
'PLAN_PARENT_NOT_RESOLVED',
|
||||
]);
|
||||
|
||||
export function isPlanDependencyReview(item: Pick<AssetImportPlanDraftItem, 'action' | 'reviewCodes'>): boolean {
|
||||
return item.action === 'REVIEW'
|
||||
&& item.reviewCodes.length > 0
|
||||
&& item.reviewCodes.every((code) => PLAN_DEPENDENCY_REVIEW_CODES.has(code));
|
||||
}
|
||||
|
||||
export function planDependencyKeys(item: Pick<AssetImportPlanDraftItem, 'entityKey' | 'parentEntityKey' | 'payload'>): string[] {
|
||||
const keys = new Set<string>();
|
||||
if (item.parentEntityKey) keys.add(item.parentEntityKey);
|
||||
for (const key of ['areaEntityKey','organizationEntityKey','departmentEntityKey','legalRightEntityKey','operationalAreaEntityKey','operatorEntityKey','localStructureEntityKey']) {
|
||||
const value = item.payload[key];
|
||||
if (typeof value === 'string' && value.trim()) keys.add(value.trim());
|
||||
}
|
||||
keys.delete(item.entityKey);
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function safePlanItems<T extends Pick<AssetImportPlanDraftItem, 'entityKey' | 'parentEntityKey' | 'payload' | 'action' | 'status'>>(items: T[]): T[] {
|
||||
const byKey = new Map(items.map((item) => [item.entityKey, item]));
|
||||
const memo = new Map<string, boolean>();
|
||||
const visiting = new Set<string>();
|
||||
const isSafe = (item: T): boolean => {
|
||||
if (item.status === 'APPLIED') return true;
|
||||
if (item.action === 'REVIEW') return false;
|
||||
const cached = memo.get(item.entityKey);
|
||||
if (cached !== undefined) return cached;
|
||||
if (visiting.has(item.entityKey)) return false;
|
||||
visiting.add(item.entityKey);
|
||||
const safe = planDependencyKeys(item).every((key) => {
|
||||
const dependency = byKey.get(key);
|
||||
return !dependency || isSafe(dependency);
|
||||
});
|
||||
visiting.delete(item.entityKey);
|
||||
memo.set(item.entityKey, safe);
|
||||
return safe;
|
||||
};
|
||||
return items.filter((item) => isSafe(item));
|
||||
}
|
||||
|
||||
export function planStatusForItems(items: Array<Pick<AssetImportPlanDraftItem, 'action'>>): AssetImportPlanStatus {
|
||||
return items.some((item) => item.action === 'REVIEW') ? 'REVIEW_REQUIRED' : 'READY';
|
||||
}
|
||||
|
||||
export function summarizePlanItems(items: AssetImportPlanDraftItem[]): Record<string, unknown> {
|
||||
const actionCounts = { create: 0, match: 0, review: 0, ignore: 0 };
|
||||
let directReviewItems = 0;
|
||||
let dependencyReviewItems = 0;
|
||||
let appliedCreateItems = 0;
|
||||
let pendingCreateItems = 0;
|
||||
const byKind: Record<string, { create: number; match: number; review: number; ignore: number; total: number }> = {};
|
||||
for (const item of items) {
|
||||
const action = item.action.toLowerCase() as keyof typeof actionCounts;
|
||||
actionCounts[action] += 1;
|
||||
if (item.action === 'CREATE') {
|
||||
if (item.status === 'APPLIED') appliedCreateItems += 1;
|
||||
else pendingCreateItems += 1;
|
||||
}
|
||||
if (item.action === 'REVIEW') {
|
||||
if (isPlanDependencyReview(item)) dependencyReviewItems += 1;
|
||||
else directReviewItems += 1;
|
||||
}
|
||||
const current = byKind[item.entityKind] ?? { create: 0, match: 0, review: 0, ignore: 0, total: 0 };
|
||||
current[action] += 1;
|
||||
current.total += 1;
|
||||
byKind[item.entityKind] = current;
|
||||
}
|
||||
const safeCreateItems = safePlanItems(items).filter((item) => item.action === 'CREATE' && item.status !== 'APPLIED').length;
|
||||
return {
|
||||
totalItems: items.length,
|
||||
createItems: actionCounts.create,
|
||||
matchItems: actionCounts.match,
|
||||
reviewItems: actionCounts.review,
|
||||
directReviewItems,
|
||||
dependencyReviewItems,
|
||||
ignoreItems: actionCounts.ignore,
|
||||
appliedCreateItems,
|
||||
pendingCreateItems,
|
||||
safeCreateItems,
|
||||
blockedCreateItems: Math.max(0, pendingCreateItems - safeCreateItems),
|
||||
partialApplied: appliedCreateItems > 0 && actionCounts.review > 0,
|
||||
byKind,
|
||||
blocked: actionCounts.review > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ParsedSheet, ParsedWorkbook } from './asset-import-parser';
|
||||
|
||||
export type AssetImportProfileCode = 'MENDOZA_INVENTORY_V1' | 'MENDOZA_YACIMIENTOS_V1' | 'UNKNOWN';
|
||||
export type AssetImportRowStatus = 'READY' | 'WARNING' | 'CONFLICT' | 'IGNORED';
|
||||
export type AssetImportSuggestedAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
|
||||
|
||||
export interface ImportProfileDetection {
|
||||
profileCode: AssetImportProfileCode;
|
||||
confidence: number;
|
||||
sheetName: string;
|
||||
headerRow: number;
|
||||
columnMap: Record<string, number>;
|
||||
detectedHeaders: string[];
|
||||
}
|
||||
|
||||
export interface ImportNormalizedRow {
|
||||
profileCode: AssetImportProfileCode;
|
||||
rowNumber: number;
|
||||
sheetName: string;
|
||||
raw: Record<string, string>;
|
||||
normalized: Record<string, unknown>;
|
||||
status: AssetImportRowStatus;
|
||||
suggestedAction: AssetImportSuggestedAction;
|
||||
issues: string[];
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export interface ImportAnalysis {
|
||||
detection: ImportProfileDetection;
|
||||
rows: ImportNormalizedRow[];
|
||||
summary: {
|
||||
totalRows: number;
|
||||
readyRows: number;
|
||||
warningRows: number;
|
||||
conflictRows: number;
|
||||
ignoredRows: number;
|
||||
issueCounts: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
function plain(value: string): string {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function compact(value: string): string {
|
||||
return plain(value).replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
const inventoryAliases: Record<string, string[]> = {
|
||||
item: ['item', 'n item', 'numero item'],
|
||||
areaOrField: ['area yacimiento', 'area/yacimiento', 'area y yacimiento', 'area yacimiento '],
|
||||
installation: ['instalacion', 'intalacion'],
|
||||
subInstallation: ['sub instalacion', 'subinstalacion'],
|
||||
equipment: ['equipo'],
|
||||
equipmentDenomination: ['denominacion del equipo', 'denominacion equipo'],
|
||||
location: ['ubicacion del equipo o instalacion', 'ubicacion equipo o instalacion', 'ubicacion'],
|
||||
inventoryId: ['n id inventario', 'n° id inventario', 'nº id inventario', 'id inventario', 'numero id inventario'],
|
||||
quantity: ['cantidad'],
|
||||
technicalSpecs: ['especificaciones tecnicas', 'especificacion tecnica'],
|
||||
sourceStatus: ['estado en servicio fuera de servicio', 'estado', 'estado servicio'],
|
||||
};
|
||||
|
||||
const fieldAliases: Record<string, string[]> = {
|
||||
field: ['yacimiento', 'nombre yacimiento'],
|
||||
area: ['area', 'area hidrocarburifera'],
|
||||
department: ['departamento'],
|
||||
rightType: ['tipo concesion', 'tipo de concesion', 'tipo permiso', 'tipo'],
|
||||
operator: ['operadora', 'operador', 'empresa operadora'],
|
||||
};
|
||||
|
||||
function aliasScore(header: string, aliases: string[]): number {
|
||||
const normalized = plain(header);
|
||||
const normalizedCompact = compact(header);
|
||||
let best = 0;
|
||||
for (const alias of aliases) {
|
||||
const a = plain(alias);
|
||||
const ac = compact(alias);
|
||||
if (normalized === a || normalizedCompact === ac) best = Math.max(best, 10 + a.length / 100);
|
||||
else if (normalized.includes(a) || a.includes(normalized)) best = Math.max(best, 6 + Math.min(a.length, normalized.length) / 100);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function mapHeaders(headers: string[], aliases: Record<string, string[]>): { map: Record<string, number>; score: number } {
|
||||
const candidates: Array<{ field: string; index: number; score: number }> = [];
|
||||
headers.forEach((header, index) => {
|
||||
Object.entries(aliases).forEach(([field, values]) => {
|
||||
const score = aliasScore(header, values);
|
||||
if (score > 0) candidates.push({ field, index, score });
|
||||
});
|
||||
});
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
const usedFields = new Set<string>();
|
||||
const usedIndexes = new Set<number>();
|
||||
const map: Record<string, number> = {};
|
||||
let score = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (usedFields.has(candidate.field) || usedIndexes.has(candidate.index)) continue;
|
||||
usedFields.add(candidate.field);
|
||||
usedIndexes.add(candidate.index);
|
||||
map[candidate.field] = candidate.index;
|
||||
score += candidate.score;
|
||||
}
|
||||
return { map, score };
|
||||
}
|
||||
|
||||
function bestHeader(sheet: ParsedSheet, aliases: Record<string, string[]>): { row: number; map: Record<string, number>; score: number; headers: string[] } {
|
||||
let best = { row: 0, map: {} as Record<string, number>, score: -1, headers: [] as string[] };
|
||||
for (let index = 0; index < Math.min(sheet.rows.length, 30); index += 1) {
|
||||
const headers = sheet.rows[index] ?? [];
|
||||
const mapped = mapHeaders(headers, aliases);
|
||||
if (mapped.score > best.score) best = { row: index + 1, map: mapped.map, score: mapped.score, headers };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function detectImportProfile(workbook: ParsedWorkbook): ImportProfileDetection {
|
||||
let best: ImportProfileDetection = { profileCode: 'UNKNOWN', confidence: 0, sheetName: workbook.sheets[0]?.name ?? 'Hoja', headerRow: 1, columnMap: {}, detectedHeaders: [] };
|
||||
for (const sheet of workbook.sheets) {
|
||||
const inventory = bestHeader(sheet, inventoryAliases);
|
||||
const inventoryRequired = ['areaOrField', 'installation', 'equipment', 'inventoryId'];
|
||||
const inventoryHits = inventoryRequired.filter((field) => inventory.map[field] !== undefined).length;
|
||||
const inventoryConfidence = Math.min(100, Math.round((inventory.score / 95) * 100));
|
||||
if (inventoryHits >= 3 && inventoryConfidence > best.confidence) {
|
||||
best = { profileCode: 'MENDOZA_INVENTORY_V1', confidence: inventoryConfidence, sheetName: sheet.name, headerRow: inventory.row, columnMap: inventory.map, detectedHeaders: inventory.headers };
|
||||
}
|
||||
|
||||
const fields = bestHeader(sheet, fieldAliases);
|
||||
const fieldRequired = ['field', 'area', 'operator'];
|
||||
const fieldHits = fieldRequired.filter((field) => fields.map[field] !== undefined).length;
|
||||
const fieldConfidence = Math.min(100, Math.round((fields.score / 55) * 100));
|
||||
if (fieldHits >= 2 && fieldConfidence > best.confidence) {
|
||||
best = { profileCode: 'MENDOZA_YACIMIENTOS_V1', confidence: fieldConfidence, sheetName: sheet.name, headerRow: fields.row, columnMap: fields.map, detectedHeaders: fields.headers };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function text(row: string[], index: number | undefined): string {
|
||||
return index === undefined ? '' : (row[index] ?? '').trim();
|
||||
}
|
||||
|
||||
function asNumber(value: string): number | null {
|
||||
if (!value.trim()) return null;
|
||||
const normalized = value.trim().replace(/\s+/g, '').replace(',', '.');
|
||||
const number = Number(normalized);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function inventoryStatusSuggestion(value: string): { operationalStatus?: string; conditionStatus?: string; ambiguous: boolean } {
|
||||
const normalized = plain(value);
|
||||
if (!normalized) return { ambiguous: false };
|
||||
if (['en servicio', 'servicio', 'operativo', 'operativa'].includes(normalized)) return { operationalStatus: 'IN_SERVICE', ambiguous: false };
|
||||
if (['fuera de servicio', 'f servicio', 'f serv', 'fs', 'f s'].includes(normalized)) return { operationalStatus: 'OUT_OF_SERVICE', ambiguous: false };
|
||||
if (['bueno', 'buena'].includes(normalized)) return { conditionStatus: 'GOOD', ambiguous: true };
|
||||
if (['regular'].includes(normalized)) return { conditionStatus: 'FAIR', ambiguous: true };
|
||||
if (['malo', 'mala'].includes(normalized)) return { conditionStatus: 'POOR', ambiguous: true };
|
||||
if (['si', 'no', 's', 'n'].includes(normalized)) return { ambiguous: true };
|
||||
return { ambiguous: true };
|
||||
}
|
||||
|
||||
function splitClassType(value: string): { sourceClass: string | null; sourceSubtype: string | null } {
|
||||
const classMatch = /clase\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||
const typeMatch = /tipo\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||
return { sourceClass: classMatch, sourceSubtype: typeMatch };
|
||||
}
|
||||
|
||||
function splitManufacturerModel(value: string): { manufacturer: string | null; model: string | null } {
|
||||
const manufacturer = /fabricante\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||
const model = /modelo\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||
if (manufacturer || model) return { manufacturer, model };
|
||||
const parts = value.split('|').map((part) => part.trim()).filter(Boolean);
|
||||
if (parts.length >= 3) return { manufacturer: parts[1] ?? null, model: parts[2] ?? null };
|
||||
return { manufacturer: null, model: null };
|
||||
}
|
||||
|
||||
export interface TechnicalNormalizationSuggestion {
|
||||
family: string | null;
|
||||
subtype: string | null;
|
||||
}
|
||||
|
||||
function technicalNormalizationSuggestion(
|
||||
equipment: string,
|
||||
denomination: string,
|
||||
sourceClass: string | null,
|
||||
sourceSubtype: string | null,
|
||||
): TechnicalNormalizationSuggestion {
|
||||
const source = plain(`${sourceClass ?? ''} ${sourceSubtype ?? ''} ${equipment} ${denomination}`);
|
||||
const classCode = plain(sourceClass ?? '');
|
||||
const subtypeCode = plain(sourceSubtype ?? '');
|
||||
|
||||
if (/rectificador/.test(plain(equipment))) return { family: 'equipo_electrico', subtype: 'rectificador' };
|
||||
const equipmentOnly = plain(equipment);
|
||||
if (/^psv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'seguridad' };
|
||||
if (/^vpsv$|^vpv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'presion_vacio' };
|
||||
if (/^aib$/.test(equipmentOnly)) return { family: 'sistema_extraccion', subtype: 'aib' };
|
||||
if (/seccionador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'seccionador' };
|
||||
if (/interruptor/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'interruptor' };
|
||||
if (/reconectador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'reconectador' };
|
||||
if (/^colector$/.test(equipmentOnly)) return { family: 'colector', subtype: null };
|
||||
if (/punto de medicion/.test(equipmentOnly)) return { family: 'instrumentacion', subtype: 'punto_medicion' };
|
||||
if (/sistema rci|^rci$/.test(equipmentOnly)) return { family: 'defensa_incendios', subtype: null };
|
||||
if (/aeroenfriador/.test(equipmentOnly)) return { family: 'aeroenfriador', subtype: null };
|
||||
if (/^bateria$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'bateria' };
|
||||
if (/^planta$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'planta' };
|
||||
if (classCode === 'bba' || /\bbomba\b/.test(source)) {
|
||||
if (/\bcen\b|centrifug/.test(source)) return { family: 'bomba', subtype: 'centrifuga' };
|
||||
if (/\btx\b|triplex|quintuplex|alternativa/.test(source)) return { family: 'bomba', subtype: 'alternativa' };
|
||||
if (/tornillo/.test(source)) return { family: 'bomba', subtype: 'tornillo' };
|
||||
if (/diafragma/.test(source)) return { family: 'bomba', subtype: 'diafragma' };
|
||||
return { family: 'bomba', subtype: null };
|
||||
}
|
||||
if (classCode === 'tk' || /\btanque\b|\btk\b/.test(source)) return { family: 'tanque', subtype: null };
|
||||
if (classCode === 'sep' || /separador/.test(source)) {
|
||||
if (/sep b|bifasic/.test(source)) return { family: 'separador', subtype: 'bifasico' };
|
||||
if (/sep g|gas/.test(source)) return { family: 'separador', subtype: 'gas' };
|
||||
return { family: 'separador', subtype: null };
|
||||
}
|
||||
if (classCode === 'cald' || /\bcaldera\b/.test(source)) return { family: 'caldera', subtype: null };
|
||||
if (classCode === 'cal' || /calentador|hot oil/.test(source)) return { family: 'calentador', subtype: null };
|
||||
if (classCode === 'ant' || /antorcha|flare/.test(source)) return { family: 'antorcha', subtype: /frio/.test(source) ? 'venteo_frio' : null };
|
||||
if (classCode === 'fil' || /\bfiltro\b/.test(source)) return { family: 'filtro', subtype: /arena/.test(source) ? 'arena' : null };
|
||||
if (classCode === 'tra' || /transformador/.test(source)) return { family: 'equipo_electrico', subtype: 'transformador' };
|
||||
if (classCode === 'moe' || /motor electr/.test(source)) return { family: 'motor', subtype: 'electrico' };
|
||||
if (classCode === 'moex' || /motor de combustion|motor a explosion/.test(source)) return { family: 'motor', subtype: 'combustion' };
|
||||
if (classCode === 'com' || /compresor|soplador/.test(source)) return { family: 'compresor', subtype: null };
|
||||
if (classCode === 'va' || /valvula/.test(source)) {
|
||||
if (/vpv|presion y vacio/.test(source)) return { family: 'valvula', subtype: 'presion_vacio' };
|
||||
if (/\bvs\b|seguridad/.test(source)) return { family: 'valvula', subtype: 'seguridad' };
|
||||
if (/\bvr\b|reguladora/.test(source)) return { family: 'valvula', subtype: 'reguladora' };
|
||||
return { family: 'valvula', subtype: null };
|
||||
}
|
||||
if (classCode === 'caud' || /caudalimetro/.test(source)) return { family: 'instrumentacion', subtype: 'caudalimetro' };
|
||||
if (classCode === 'eg' || /generador|motogenerador/.test(source)) return { family: 'generador', subtype: null };
|
||||
if (classCode === 'cel' || /\bcelda\b/.test(source)) return { family: 'equipo_electrico', subtype: 'celda' };
|
||||
if (classCode === 'pil' || /pileta/.test(source)) return { family: 'pileta', subtype: null };
|
||||
if (classCode === 'aib' || /aparato individual de bombeo|rotaflex/.test(source)) {
|
||||
const subtype = /rotaflex/.test(source) ? 'rotaflex' : /mark ii/.test(source) ? 'mark_ii' : /convencional/.test(source) ? 'convencional' : null;
|
||||
return { family: 'sistema_extraccion', subtype };
|
||||
}
|
||||
if (classCode === 'pcp' || /\bpcp\b/.test(source)) return { family: 'sistema_extraccion', subtype: 'pcp' };
|
||||
if (/\bbes\b|electrosumerg/.test(source)) return { family: 'sistema_extraccion', subtype: 'bes' };
|
||||
if (/\bpozo\b/.test(source)) return { family: 'pozo', subtype: null };
|
||||
if (/oleoducto/.test(source)) return { family: 'ducto', subtype: 'oleoducto' };
|
||||
if (/gasoducto/.test(source)) return { family: 'ducto', subtype: 'gasoducto' };
|
||||
if (/acueducto/.test(source)) return { family: 'ducto', subtype: 'acueducto' };
|
||||
if (/caneria/.test(source)) return { family: 'ducto', subtype: 'caneria' };
|
||||
return { family: null, subtype: null };
|
||||
}
|
||||
function rawObject(headers: string[], row: string[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((header, index) => {
|
||||
if (!header.trim() && !row[index]?.trim()) return;
|
||||
result[header.trim() || `Columna ${index + 1}`] = row[index]?.trim() ?? '';
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function fingerprint(value: Record<string, unknown>): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeInventory(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] {
|
||||
const headers = sheet.rows[detection.headerRow - 1] ?? [];
|
||||
const result: ImportNormalizedRow[] = [];
|
||||
const map = detection.columnMap;
|
||||
for (let index = detection.headerRow; index < sheet.rows.length; index += 1) {
|
||||
const row = sheet.rows[index] ?? [];
|
||||
if (!row.some((value) => value.trim())) continue;
|
||||
const areaOrField = text(row, map.areaOrField);
|
||||
const installation = text(row, map.installation);
|
||||
const subInstallation = text(row, map.subInstallation);
|
||||
const equipment = text(row, map.equipment);
|
||||
const denomination = text(row, map.equipmentDenomination);
|
||||
const location = text(row, map.location);
|
||||
const inventoryId = text(row, map.inventoryId);
|
||||
const quantityRaw = text(row, map.quantity);
|
||||
const technicalSpecs = text(row, map.technicalSpecs);
|
||||
const sourceStatus = text(row, map.sourceStatus);
|
||||
const item = text(row, map.item);
|
||||
const isHeaderRepeat = compact(areaOrField) === compact(headers[map.areaOrField ?? -1] ?? '') && compact(equipment) === compact(headers[map.equipment ?? -1] ?? '');
|
||||
if (isHeaderRepeat) continue;
|
||||
const issues: string[] = [];
|
||||
if (!areaOrField) issues.push('MISSING_AREA_OR_YACIMIENTO');
|
||||
if (!equipment && !denomination) issues.push('MISSING_EQUIPMENT_DESCRIPTION');
|
||||
if (!inventoryId) issues.push('MISSING_INVENTORY_ID');
|
||||
const quantity = asNumber(quantityRaw);
|
||||
if (quantity !== null && (!Number.isInteger(quantity) || quantity <= 0)) issues.push('INVALID_QUANTITY');
|
||||
if (quantity !== null && quantity > 1) issues.push('GROUPED_QUANTITY');
|
||||
const sourceState = inventoryStatusSuggestion(sourceStatus);
|
||||
if (sourceState.ambiguous && sourceStatus) issues.push('SOURCE_STATUS_REQUIRES_MAPPING');
|
||||
const classType = splitClassType(denomination);
|
||||
const manufacturerModel = splitManufacturerModel(technicalSpecs);
|
||||
const technical = technicalNormalizationSuggestion(equipment, denomination, classType.sourceClass, classType.sourceSubtype);
|
||||
const normalized: Record<string, unknown> = {
|
||||
item: item || null,
|
||||
areaOrField: areaOrField || null,
|
||||
installation: installation || null,
|
||||
subInstallation: subInstallation || null,
|
||||
equipment: equipment || null,
|
||||
sourceClassification: denomination || null,
|
||||
location: location || null,
|
||||
inventoryId: inventoryId || null,
|
||||
quantity: quantity ?? (quantityRaw || null),
|
||||
technicalSpecs: technicalSpecs || null,
|
||||
sourceStatus: sourceStatus || null,
|
||||
operationalStatusSuggestion: sourceState.operationalStatus ?? null,
|
||||
conditionStatusSuggestion: sourceState.conditionStatus ?? null,
|
||||
sourceClass: classType.sourceClass,
|
||||
sourceSubtype: classType.sourceSubtype,
|
||||
manufacturer: manufacturerModel.manufacturer,
|
||||
model: manufacturerModel.model,
|
||||
familySuggestion: technical.family,
|
||||
normalizedFamily: technical.family,
|
||||
normalizedSubtype: technical.subtype,
|
||||
};
|
||||
const conflict = issues.includes('MISSING_EQUIPMENT_DESCRIPTION') || issues.includes('INVALID_QUANTITY');
|
||||
const status: AssetImportRowStatus = conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY';
|
||||
result.push({
|
||||
profileCode: 'MENDOZA_INVENTORY_V1',
|
||||
rowNumber: index + 1,
|
||||
sheetName: sheet.name,
|
||||
raw: rawObject(headers, row),
|
||||
normalized,
|
||||
status,
|
||||
suggestedAction: conflict ? 'REVIEW' : 'CREATE',
|
||||
issues,
|
||||
fingerprint: fingerprint(normalized),
|
||||
});
|
||||
}
|
||||
return applyBatchDuplicateRules(result);
|
||||
}
|
||||
|
||||
function applyBatchDuplicateRules(rows: ImportNormalizedRow[]): ImportNormalizedRow[] {
|
||||
const groups = new Map<string, ImportNormalizedRow[]>();
|
||||
for (const row of rows) {
|
||||
const id = String(row.normalized.inventoryId ?? '').trim().toLowerCase();
|
||||
if (!id) continue;
|
||||
const area = plain(String(row.normalized.areaOrField ?? ''));
|
||||
const key = `${area}|${id}`;
|
||||
const current = groups.get(key) ?? [];
|
||||
current.push(row);
|
||||
groups.set(key, current);
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
if (group.length < 2) continue;
|
||||
const locations = new Set(group.map((row) => plain(`${row.normalized.installation ?? ''}|${row.normalized.subInstallation ?? ''}|${row.normalized.location ?? ''}`)));
|
||||
const issue = locations.size > 1 ? 'INVENTORY_ID_MULTIPLE_LOCATIONS' : 'DUPLICATE_INVENTORY_ID_IN_BATCH';
|
||||
for (const row of group) {
|
||||
if (!row.issues.includes(issue)) row.issues.push(issue);
|
||||
if (issue === 'INVENTORY_ID_MULTIPLE_LOCATIONS') {
|
||||
row.status = 'CONFLICT';
|
||||
row.suggestedAction = 'REVIEW';
|
||||
} else if (row.status === 'READY') {
|
||||
row.status = 'WARNING';
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function normalizeFields(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] {
|
||||
const headers = sheet.rows[detection.headerRow - 1] ?? [];
|
||||
const result: ImportNormalizedRow[] = [];
|
||||
const map = detection.columnMap;
|
||||
for (let index = detection.headerRow; index < sheet.rows.length; index += 1) {
|
||||
const row = sheet.rows[index] ?? [];
|
||||
if (!row.some((value) => value.trim())) continue;
|
||||
const field = text(row, map.field);
|
||||
const area = text(row, map.area);
|
||||
const department = text(row, map.department);
|
||||
const rightType = text(row, map.rightType);
|
||||
const operator = text(row, map.operator);
|
||||
const issues: string[] = [];
|
||||
if (!field) issues.push('MISSING_FIELD');
|
||||
if (!area) issues.push('MISSING_AREA');
|
||||
if (!operator) issues.push('MISSING_OPERATOR');
|
||||
const normalized: Record<string, unknown> = {
|
||||
field: field || null,
|
||||
area: area || null,
|
||||
department: department || null,
|
||||
rightType: rightType || null,
|
||||
operator: operator || null,
|
||||
};
|
||||
const conflict = !field || !area;
|
||||
result.push({
|
||||
profileCode: 'MENDOZA_YACIMIENTOS_V1',
|
||||
rowNumber: index + 1,
|
||||
sheetName: sheet.name,
|
||||
raw: rawObject(headers, row),
|
||||
normalized,
|
||||
status: conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY',
|
||||
suggestedAction: conflict ? 'REVIEW' : 'CREATE',
|
||||
issues,
|
||||
fingerprint: fingerprint(normalized),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function summarize(rows: ImportNormalizedRow[]): ImportAnalysis['summary'] {
|
||||
const issueCounts: Record<string, number> = {};
|
||||
rows.forEach((row) => row.issues.forEach((issue) => { issueCounts[issue] = (issueCounts[issue] ?? 0) + 1; }));
|
||||
return {
|
||||
totalRows: rows.length,
|
||||
readyRows: rows.filter((row) => row.status === 'READY').length,
|
||||
warningRows: rows.filter((row) => row.status === 'WARNING').length,
|
||||
conflictRows: rows.filter((row) => row.status === 'CONFLICT').length,
|
||||
ignoredRows: rows.filter((row) => row.status === 'IGNORED').length,
|
||||
issueCounts,
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeImportWorkbook(workbook: ParsedWorkbook, forcedProfile?: AssetImportProfileCode): ImportAnalysis {
|
||||
const detected = detectImportProfile(workbook);
|
||||
const profileCode = forcedProfile && forcedProfile !== 'UNKNOWN' ? forcedProfile : detected.profileCode;
|
||||
if (profileCode === 'UNKNOWN') {
|
||||
return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } };
|
||||
}
|
||||
const aliases = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? fieldAliases : inventoryAliases;
|
||||
let selected: { sheet: ParsedSheet; header: ReturnType<typeof bestHeader> } | null = null;
|
||||
for (const candidate of workbook.sheets) {
|
||||
const header = bestHeader(candidate, aliases);
|
||||
if (!selected || header.score > selected.header.score) selected = { sheet: candidate, header };
|
||||
}
|
||||
if (!selected) {
|
||||
return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } };
|
||||
}
|
||||
const denominator = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? 55 : 95;
|
||||
const confidence = Math.min(100, Math.max(0, Math.round((selected.header.score / denominator) * 100)));
|
||||
const detection: ImportProfileDetection = { profileCode, confidence, sheetName: selected.sheet.name, headerRow: selected.header.row, columnMap: selected.header.map, detectedHeaders: selected.header.headers };
|
||||
const rows = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? normalizeFields(selected.sheet, detection) : normalizeInventory(selected.sheet, detection);
|
||||
return { detection, rows, summary: summarize(rows) };
|
||||
}
|
||||
|
||||
export function profileLabel(code: AssetImportProfileCode): string {
|
||||
if (code === 'MENDOZA_INVENTORY_V1') return 'Inventario de instalaciones · Mendoza';
|
||||
if (code === 'MENDOZA_YACIMIENTOS_V1') return 'Tabla Área / Yacimiento · Mendoza';
|
||||
return 'Formato no reconocido';
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { AssetImportsService, MAX_ASSET_IMPORT_BYTES, type UploadedImportFile } from './asset-imports.service';
|
||||
import { ApplyAssetImportPlanDto } from './dto/apply-asset-import-plan.dto';
|
||||
import { CancelAssetImportDto } from './dto/cancel-asset-import.dto';
|
||||
import { CreateAssetImportPlanDto } from './dto/create-asset-import-plan.dto';
|
||||
import { ResolveAssetImportPlanItemDto } from './dto/resolve-asset-import-plan-item.dto';
|
||||
import { RollbackAssetImportPlanDto } from './dto/rollback-asset-import-plan.dto';
|
||||
import { ListAssetImportPlanItemsQueryDto } from './dto/list-asset-import-plan-items-query.dto';
|
||||
import { ListAssetImportReviewsQueryDto } from './dto/list-asset-import-reviews-query.dto';
|
||||
import { ListAssetImportRowsQueryDto } from './dto/list-asset-import-rows-query.dto';
|
||||
import { ListAssetImportsQueryDto } from './dto/list-asset-imports-query.dto';
|
||||
import { UploadAssetImportDto } from './dto/upload-asset-import.dto';
|
||||
|
||||
@Controller('asset-imports')
|
||||
export class AssetImportsController {
|
||||
constructor(private readonly imports: AssetImportsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('asset_imports.read')
|
||||
list(@Query() query: ListAssetImportsQueryDto) {
|
||||
return this.imports.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('asset_imports.manage')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ASSET_IMPORT_BYTES, files: 1 } }))
|
||||
upload(
|
||||
@Body() dto: UploadAssetImportDto,
|
||||
@UploadedFile() file: UploadedImportFile | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.upload(dto, file, principal, request);
|
||||
}
|
||||
|
||||
@Get('context/organizations')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
organizations(@Query('search') search?: string) {
|
||||
return this.imports.organizationOptions(search);
|
||||
}
|
||||
|
||||
@Get('reviews')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
reviews(@Query() query: ListAssetImportReviewsQueryDto) {
|
||||
return this.imports.reviews(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.imports.get(id);
|
||||
}
|
||||
|
||||
@Get(':id/plan')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
plan(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.imports.plan(id);
|
||||
}
|
||||
|
||||
@Get(':id/plan/items')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
planItems(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Query() query: ListAssetImportPlanItemsQueryDto,
|
||||
) {
|
||||
return this.imports.planItems(id, query);
|
||||
}
|
||||
|
||||
@Post(':id/plan')
|
||||
@RequirePermissions('asset_imports.manage')
|
||||
generatePlan(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: CreateAssetImportPlanDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.generatePlan(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/plan/items/:itemId/resolve')
|
||||
@RequirePermissions('asset_imports.manage')
|
||||
resolvePlanItem(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Param('itemId', new ParseUUIDPipe({ version: '4' })) itemId: string,
|
||||
@Body() dto: ResolveAssetImportPlanItemDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.resolvePlanItem(id, itemId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/apply-safe')
|
||||
@RequirePermissions('asset_imports.apply')
|
||||
applySafePlan(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ApplyAssetImportPlanDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.applySafePlan(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/apply')
|
||||
@RequirePermissions('asset_imports.apply')
|
||||
applyPlan(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ApplyAssetImportPlanDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.applyPlan(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/rollback')
|
||||
@RequirePermissions('asset_imports.apply')
|
||||
rollbackPlan(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: RollbackAssetImportPlanDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.rollbackPlan(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id/rows')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
rows(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Query() query: ListAssetImportRowsQueryDto,
|
||||
) {
|
||||
return this.imports.rows(id, query);
|
||||
}
|
||||
|
||||
@Post(':id/reconcile')
|
||||
@RequirePermissions('asset_imports.manage')
|
||||
reconcile(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.reconcile(id, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@RequirePermissions('asset_imports.manage')
|
||||
cancel(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: CancelAssetImportDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.cancel(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AssetImportsController } from './asset-imports.controller';
|
||||
import { AssetImportsService } from './asset-imports.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [AssetImportsController],
|
||||
providers: [AssetImportsService],
|
||||
})
|
||||
export class AssetImportsModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
export class ApplyAssetImportPlanDto {
|
||||
@IsString()
|
||||
@Matches(/^[0-9a-f]{64}$/)
|
||||
planHash!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CancelAssetImportDto {
|
||||
@IsString() @MinLength(5) @MaxLength(500) reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsOptional, IsString, IsUUID, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateAssetImportPlanDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
operatorAssetId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
@Matches(/^[A-Z0-9][A-Z0-9._/-]{1,79}$/i)
|
||||
externalIdNamespace?: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
const entityKinds = [
|
||||
'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION',
|
||||
'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET',
|
||||
] as const;
|
||||
|
||||
export class ListAssetImportPlanItemsQueryDto {
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50;
|
||||
@IsOptional() @IsIn(entityKinds) entityKind?: typeof entityKinds[number];
|
||||
@IsOptional() @IsIn(['CREATE','MATCH','REVIEW','IGNORE']) action?: 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListAssetImportReviewsQueryDto {
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 30;
|
||||
@IsOptional() @IsIn(['ALL','DIRECT','DEPENDENCY']) kind: 'ALL' | 'DIRECT' | 'DEPENDENCY' = 'ALL';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class ListAssetImportRowsQueryDto {
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50;
|
||||
@IsOptional() @IsIn(['READY','WARNING','CONFLICT','IGNORED']) status?: string;
|
||||
@IsOptional() @IsString() @MaxLength(160) search?: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListAssetImportsQueryDto {
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 20;
|
||||
@IsOptional() @IsIn(['ANALYZED','REVIEW_REQUIRED','CANCELLED','FAILED']) status?: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsIn, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateIf } from 'class-validator';
|
||||
|
||||
export class ResolveAssetImportPlanItemDto {
|
||||
@IsIn(['CREATE', 'MATCH', 'IGNORE'])
|
||||
action!: 'CREATE' | 'MATCH' | 'IGNORE';
|
||||
|
||||
@ValidateIf((value: ResolveAssetImportPlanItemDto) => value.action === 'MATCH')
|
||||
@IsUUID('4')
|
||||
matchedAssetId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
@MaxLength(1000)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class RollbackAssetImportPlanDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(1000)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import type { AssetImportProfileCode } from '../asset-import-profiles';
|
||||
|
||||
export class UploadAssetImportDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(240)
|
||||
sourceLabel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['MENDOZA_INVENTORY_V1', 'MENDOZA_YACIMIENTOS_V1'])
|
||||
profileCode?: Exclude<AssetImportProfileCode, 'UNKNOWN'>;
|
||||
}
|
||||
Reference in New Issue
Block a user