1622 lines
146 KiB
TypeScript
1622 lines
146 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto';
|
|
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
|
import { isAbsolute, parse, resolve } from 'node:path';
|
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { DataSource, EntityManager } from 'typeorm';
|
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
|
import { AuditAction, SourceDocumentType } from '../database/entities';
|
|
import { analyzeImportWorkbook, profileLabel, type AssetImportProfileCode, type ImportNormalizedRow } from './asset-import-profiles';
|
|
import { inspectImportFile, MAX_ASSET_IMPORT_BYTES, parseImportWorkbook, type UploadedImportFile } from './asset-import-parser';
|
|
import type { ApplyAssetImportPlanDto } from './dto/apply-asset-import-plan.dto';
|
|
import type { CancelAssetImportDto } from './dto/cancel-asset-import.dto';
|
|
import type { CreateAssetImportPlanDto } from './dto/create-asset-import-plan.dto';
|
|
import type { ResolveAssetImportPlanItemDto } from './dto/resolve-asset-import-plan-item.dto';
|
|
import type { RollbackAssetImportPlanDto } from './dto/rollback-asset-import-plan.dto';
|
|
import type { ListAssetImportPlanItemsQueryDto } from './dto/list-asset-import-plan-items-query.dto';
|
|
import type { ListAssetImportReviewsQueryDto } from './dto/list-asset-import-reviews-query.dto';
|
|
import type { ListAssetImportRowsQueryDto } from './dto/list-asset-import-rows-query.dto';
|
|
import type { ListAssetImportsQueryDto } from './dto/list-asset-imports-query.dto';
|
|
import type { UploadAssetImportDto } from './dto/upload-asset-import.dto';
|
|
import {
|
|
departmentCode,
|
|
externalIdNamespace,
|
|
generatedImportCode,
|
|
isExplicitlyUnassignedOperator,
|
|
legalRightTypeLabel,
|
|
normalizedLegalRightType,
|
|
organizationImportKey,
|
|
planItemHash,
|
|
planStatusForItems,
|
|
safePlanItems,
|
|
plainImportKey,
|
|
sourceContainerName,
|
|
sourceContainerTypeCode,
|
|
sourceLocalStructureSuggestion,
|
|
summarizePlanItems,
|
|
technicalFamilyTypeCode,
|
|
type AssetImportPlanAction,
|
|
type AssetImportPlanDraftItem,
|
|
type AssetImportPlanEntityKind,
|
|
type AssetImportPlanItemStatus,
|
|
} from './asset-import-plan';
|
|
|
|
export { MAX_ASSET_IMPORT_BYTES, type UploadedImportFile } from './asset-import-parser';
|
|
|
|
export interface ImportBatchRow {
|
|
id: string;
|
|
originalName: string;
|
|
mimeType: string;
|
|
sizeBytes: number;
|
|
sha256: string;
|
|
profileCode: string;
|
|
profileConfidence: number;
|
|
worksheetName: string | null;
|
|
headerRow: number | null;
|
|
totalRows: number;
|
|
readyRows: number;
|
|
warningRows: number;
|
|
conflictRows: number;
|
|
ignoredRows: number;
|
|
status: string;
|
|
sourceDocumentId: string | null;
|
|
sourceLabel: string | null;
|
|
notes: string | null;
|
|
analysis: Record<string, unknown>;
|
|
uploadedBy: string | null;
|
|
uploadedByUsername: string | null;
|
|
analyzedAt: Date;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
|
|
export interface ImportReconciliationSummary {
|
|
totalRows: number;
|
|
createRows: number;
|
|
matchRows: number;
|
|
reviewRows: number;
|
|
ignoreRows: number;
|
|
exactExternalIdMatches: number;
|
|
exactCodeMatches: number;
|
|
exactTerritoryMatches: number;
|
|
exactOrganizationMatches: number;
|
|
multipleMatches: number;
|
|
reconciledAt: string;
|
|
}
|
|
|
|
export interface ImportPlanRow {
|
|
id: string;
|
|
batchId: string;
|
|
revision: number;
|
|
status: string;
|
|
externalIdNamespace: string | null;
|
|
operatorAssetId: string | null;
|
|
summary: Record<string, unknown>;
|
|
planHash: string;
|
|
generatedBy: string | null;
|
|
generatedAt: Date;
|
|
appliedBy: string | null;
|
|
appliedAt: Date | null;
|
|
applicationSummary: Record<string, unknown> | null;
|
|
rolledBackBy: string | null;
|
|
rolledBackAt: Date | null;
|
|
rollbackSummary: Record<string, unknown> | null;
|
|
supersededAt: Date | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
export interface PlanItemRow {
|
|
id: string;
|
|
planId: string;
|
|
itemOrder: number;
|
|
entityKey: string;
|
|
entityKind: AssetImportPlanEntityKind;
|
|
action: AssetImportPlanAction;
|
|
status: AssetImportPlanItemStatus;
|
|
assetTypeCode: string | null;
|
|
displayName: string;
|
|
generatedCode: string | null;
|
|
parentEntityKey: string | null;
|
|
matchedAssetId: string | null;
|
|
appliedAssetId: string | null;
|
|
appliedObjectId: string | null;
|
|
payload: Record<string, unknown>;
|
|
sourceRowNumbers: number[];
|
|
reviewCodes: string[];
|
|
resolutionNote: string | null;
|
|
resolvedBy: string | null;
|
|
resolvedAt: Date | null;
|
|
}
|
|
|
|
interface ImportSourceRow {
|
|
id: string;
|
|
rowNumber: number;
|
|
status: string;
|
|
suggestedAction: string;
|
|
normalizedData: Record<string, unknown>;
|
|
issues: string[];
|
|
matchedAssetId: string | null;
|
|
}
|
|
|
|
interface AssetMatch {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
typeCode: string;
|
|
parentId: string | null;
|
|
operationalAreaId: string | null;
|
|
operatorCompanyId: string | null;
|
|
}
|
|
|
|
function importNotFound(): NotFoundException {
|
|
return new NotFoundException({ code: 'ASSET_IMPORT_NOT_FOUND', message: 'Lote de importación no encontrado' });
|
|
}
|
|
|
|
@Injectable()
|
|
export class AssetImportsService {
|
|
private readonly storageRoot: string;
|
|
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly audit: AuditService,
|
|
config: ConfigService,
|
|
) {
|
|
const configured = config.get<string>('ASSET_IMPORT_ROOT') ?? '/app/storage/asset-media/imports';
|
|
if (!isAbsolute(configured)) throw new Error('ASSET_IMPORT_ROOT must be an absolute path');
|
|
this.storageRoot = resolve(configured);
|
|
if (this.storageRoot === parse(this.storageRoot).root) throw new Error('ASSET_IMPORT_ROOT cannot be the filesystem root');
|
|
}
|
|
|
|
async list(query: ListAssetImportsQueryDto) {
|
|
const parameters: unknown[] = [];
|
|
const conditions: string[] = [];
|
|
if (query.status) { parameters.push(query.status); conditions.push(`batch.status = $${parameters.length}::asset_import_batch_status`); }
|
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
const [count] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_import_batches batch ${where}`, parameters)) as Array<{ total: number }>;
|
|
parameters.push(query.pageSize); const limit = `$${parameters.length}`;
|
|
parameters.push((query.page - 1) * query.pageSize); const offset = `$${parameters.length}`;
|
|
const data = (await this.dataSource.query(`${this.batchSelect()} ${where} ORDER BY batch.created_at DESC LIMIT ${limit} OFFSET ${offset}`, parameters)) as ImportBatchRow[];
|
|
const total = Number(count?.total ?? 0);
|
|
return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } };
|
|
}
|
|
|
|
async reviews(query: ListAssetImportReviewsQueryDto) {
|
|
const dependencyCodes = '["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"]';
|
|
const params: unknown[] = [];
|
|
const conditions = ["plan.superseded_at IS NULL", "plan.status='REVIEW_REQUIRED'", "item.action='REVIEW'", "batch.status<>'CANCELLED'"];
|
|
if (query.kind === 'DIRECT') conditions.push(`NOT (item.review_codes <@ '${dependencyCodes}'::jsonb)`);
|
|
if (query.kind === 'DEPENDENCY') conditions.push(`item.review_codes <@ '${dependencyCodes}'::jsonb`);
|
|
const where = `WHERE ${conditions.join(' AND ')}`;
|
|
const [count] = (await this.dataSource.query(`
|
|
SELECT COUNT(*)::integer AS total
|
|
FROM asset_import_plan_items item
|
|
JOIN asset_import_plans plan ON plan.id=item.plan_id
|
|
JOIN asset_import_batches batch ON batch.id=plan.batch_id
|
|
${where}
|
|
`, params)) as Array<{ total: number }>;
|
|
params.push(query.pageSize); const limit = `$${params.length}`;
|
|
params.push((query.page - 1) * query.pageSize); const offset = `$${params.length}`;
|
|
const data = await this.dataSource.query(`
|
|
SELECT item.id,item.plan_id AS "planId",item.item_order AS "itemOrder",item.entity_key AS "entityKey",item.entity_kind AS "entityKind",
|
|
item.action,item.status,item.asset_type_code AS "assetTypeCode",item.display_name AS "displayName",item.generated_code AS "generatedCode",
|
|
item.parent_entity_key AS "parentEntityKey",item.matched_asset_id AS "matchedAssetId",item.applied_asset_id AS "appliedAssetId",
|
|
item.applied_object_id AS "appliedObjectId",item.payload,item.source_row_numbers AS "sourceRowNumbers",item.review_codes AS "reviewCodes",
|
|
item.resolution_note AS "resolutionNote",item.resolved_by AS "resolvedBy",item.resolved_at AS "resolvedAt",
|
|
batch.id AS "batchId",batch.original_name AS "batchName",batch.source_label AS "sourceLabel",batch.profile_code AS "profileCode",
|
|
plan.revision AS "planRevision",plan.status AS "planStatus",
|
|
(item.review_codes <@ '${dependencyCodes}'::jsonb) AS dependency
|
|
FROM asset_import_plan_items item
|
|
JOIN asset_import_plans plan ON plan.id=item.plan_id
|
|
JOIN asset_import_batches batch ON batch.id=plan.batch_id
|
|
${where}
|
|
ORDER BY dependency ASC,batch.created_at DESC,item.item_order
|
|
LIMIT ${limit} OFFSET ${offset}
|
|
`, params);
|
|
const total = Number(count?.total ?? 0);
|
|
return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } };
|
|
}
|
|
|
|
async get(id: string) {
|
|
const batch = await this.loadBatch(this.dataSource.manager, id);
|
|
const topIssues = await this.dataSource.query(`
|
|
SELECT issue AS code, COUNT(*)::integer AS count
|
|
FROM asset_import_rows row, jsonb_array_elements_text(row.issue_codes) issue
|
|
WHERE row.batch_id=$1
|
|
GROUP BY issue ORDER BY COUNT(*) DESC, issue LIMIT 20
|
|
`, [id]);
|
|
const duplicateFiles = await this.dataSource.query(`
|
|
SELECT id, original_name AS "originalName", created_at AS "createdAt", status
|
|
FROM asset_import_batches WHERE sha256=$1 AND id<>$2 ORDER BY created_at DESC LIMIT 10
|
|
`, [batch.sha256, id]);
|
|
return { ...batch, topIssues, duplicateFiles };
|
|
}
|
|
|
|
async rows(id: string, query: ListAssetImportRowsQueryDto) {
|
|
await this.loadBatch(this.dataSource.manager, id);
|
|
const parameters: unknown[] = [id];
|
|
const conditions = ['row.batch_id=$1'];
|
|
if (query.status) { parameters.push(query.status); conditions.push(`row.status=$${parameters.length}::asset_import_row_status`); }
|
|
if (query.search?.trim()) {
|
|
parameters.push(`%${query.search.trim()}%`);
|
|
const p = `$${parameters.length}`;
|
|
conditions.push(`(row.normalized_data::text ILIKE ${p} OR row.raw_data::text ILIKE ${p})`);
|
|
}
|
|
const where = `WHERE ${conditions.join(' AND ')}`;
|
|
const [count] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_import_rows row ${where}`, parameters)) as Array<{ total: number }>;
|
|
parameters.push(query.pageSize); const limit = `$${parameters.length}`;
|
|
parameters.push((query.page - 1) * query.pageSize); const offset = `$${parameters.length}`;
|
|
const data = await this.dataSource.query(`
|
|
SELECT row.id,row.worksheet_name AS "worksheetName",row.row_number AS "rowNumber",row.status,
|
|
row.suggested_action AS "suggestedAction",row.raw_data AS "rawData",row.normalized_data AS "normalizedData",
|
|
row.issue_codes AS issues,row.fingerprint,row.matched_asset_id AS "matchedAssetId",row.imported_asset_id AS "importedAssetId",
|
|
row.created_at AS "createdAt",row.updated_at AS "updatedAt"
|
|
FROM asset_import_rows row ${where}
|
|
ORDER BY row.row_number LIMIT ${limit} OFFSET ${offset}
|
|
`, parameters);
|
|
const total = Number(count?.total ?? 0);
|
|
return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } };
|
|
}
|
|
|
|
async organizationOptions(search?: string): Promise<{ data: Array<{ id: string; code: string; name: string; legalName: string | null }> }> {
|
|
const parameters: unknown[] = [];
|
|
let filter = '';
|
|
if (search?.trim()) {
|
|
parameters.push(`%${search.trim()}%`);
|
|
filter = `AND (asset.code ILIKE $1 OR asset.name ILIKE $1 OR profile.legal_name ILIKE $1)`;
|
|
}
|
|
const data = (await this.dataSource.query(`
|
|
SELECT asset.id,asset.code,asset.name,profile.legal_name AS "legalName"
|
|
FROM assets asset
|
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
|
LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id
|
|
WHERE type.operational_role='COMPANY' AND type.is_active=true AND asset.information_status<>'INACTIVE'
|
|
${filter}
|
|
ORDER BY asset.name,asset.code LIMIT 500
|
|
`, parameters)) as Array<{ id: string; code: string; name: string; legalName: string | null }>;
|
|
return { data };
|
|
}
|
|
|
|
async plan(id: string): Promise<(ImportPlanRow & { previewItems: PlanItemRow[]; reviewItems: PlanItemRow[]; masterStateStale: boolean; masterStateHashAvailable: boolean; masterStateCheckedAt: string }) | null> {
|
|
await this.loadBatch(this.dataSource.manager, id);
|
|
const plan = await this.loadActivePlan(this.dataSource.manager, id);
|
|
if (!plan) return null;
|
|
const previewItems = await this.loadPlanItems(this.dataSource.manager, plan.id, `ORDER BY item_order LIMIT 80`);
|
|
const reviewItems = await this.loadPlanItems(this.dataSource.manager, plan.id, `AND action='REVIEW' ORDER BY CASE WHEN review_codes <@ '["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"]'::jsonb THEN 1 ELSE 0 END, item_order LIMIT 80`);
|
|
const masterState = await this.planMasterState(this.dataSource.manager, plan);
|
|
return { ...plan, previewItems, reviewItems, ...masterState };
|
|
}
|
|
|
|
async planItems(id: string, query: ListAssetImportPlanItemsQueryDto) {
|
|
await this.loadBatch(this.dataSource.manager, id);
|
|
const plan = await this.loadActivePlan(this.dataSource.manager, id);
|
|
if (!plan) throw new NotFoundException({ code: 'ASSET_IMPORT_PLAN_NOT_FOUND', message: 'Plan de importación no encontrado' });
|
|
const params: unknown[] = [plan.id];
|
|
const conditions = ['plan_id=$1'];
|
|
if (query.entityKind) { params.push(query.entityKind); conditions.push(`entity_kind=$${params.length}`); }
|
|
if (query.action) { params.push(query.action); conditions.push(`action=$${params.length}`); }
|
|
const where = `WHERE ${conditions.join(' AND ')}`;
|
|
const [count] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_import_plan_items ${where}`, params)) as Array<{ total: number }>;
|
|
params.push(query.pageSize); const limit = `$${params.length}`;
|
|
params.push((query.page - 1) * query.pageSize); const offset = `$${params.length}`;
|
|
const data = (await this.dataSource.query(`
|
|
SELECT id,plan_id AS "planId",item_order AS "itemOrder",entity_key AS "entityKey",entity_kind AS "entityKind",action,status,
|
|
asset_type_code AS "assetTypeCode",display_name AS "displayName",generated_code AS "generatedCode",parent_entity_key AS "parentEntityKey",
|
|
matched_asset_id AS "matchedAssetId",applied_asset_id AS "appliedAssetId",applied_object_id AS "appliedObjectId",payload,
|
|
source_row_numbers AS "sourceRowNumbers",review_codes AS "reviewCodes",resolution_note AS "resolutionNote",resolved_by AS "resolvedBy",resolved_at AS "resolvedAt"
|
|
FROM asset_import_plan_items ${where} ORDER BY item_order LIMIT ${limit} OFFSET ${offset}
|
|
`, params)) as PlanItemRow[];
|
|
const total = Number(count?.total ?? 0);
|
|
return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total ? Math.ceil(total / query.pageSize) : 0 } };
|
|
}
|
|
|
|
async generatePlan(id: string, dto: CreateAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const batch = await this.loadBatch(manager, id, true);
|
|
if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' });
|
|
if (!batch.analysis?.reconciliation) throw new ConflictException({ code: 'ASSET_IMPORT_RECONCILIATION_REQUIRED', message: 'Conciliá el lote con el Maestro antes de generar el plan' });
|
|
const current = await this.loadActivePlan(manager, id, true);
|
|
if (current?.status === 'APPLIED') throw new ConflictException({ code: 'ASSET_IMPORT_ALREADY_APPLIED', message: 'El lote ya tiene un plan aplicado. Revertí el lote antes de generar otro plan.' });
|
|
if (current) {
|
|
await manager.query(`UPDATE asset_import_plans SET status='SUPERSEDED',superseded_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [current.id]);
|
|
}
|
|
const [revisionRow] = (await manager.query(`SELECT COALESCE(MAX(revision),0)::integer+1 AS revision FROM asset_import_plans WHERE batch_id=$1`, [id])) as Array<{ revision: number }>;
|
|
const revision = Number(revisionRow?.revision ?? 1);
|
|
const planId = randomUUID();
|
|
const namespace = batch.profileCode === 'MENDOZA_INVENTORY_V1' ? externalIdNamespace(dto.externalIdNamespace, batch.originalName) : null;
|
|
let operatorAssetId: string | null = null;
|
|
if (batch.profileCode === 'MENDOZA_INVENTORY_V1') {
|
|
if (!dto.operatorAssetId) throw new BadRequestException({ code: 'ASSET_IMPORT_OPERATOR_REQUIRED', message: 'Seleccioná la organización operadora para planificar este inventario' });
|
|
await this.requireActiveOrganization(manager, dto.operatorAssetId);
|
|
operatorAssetId = dto.operatorAssetId;
|
|
}
|
|
const masterStateBefore = await this.masterStateFingerprint(manager);
|
|
const rows = await this.loadSourceRows(manager, id);
|
|
const items = batch.profileCode === 'MENDOZA_YACIMIENTOS_V1'
|
|
? await this.buildTerritoryPlan(manager, planId, batch, rows)
|
|
: batch.profileCode === 'MENDOZA_INVENTORY_V1'
|
|
? await this.buildInventoryPlan(manager, planId, batch, rows, operatorAssetId!, namespace!)
|
|
: [this.reviewPlanItem(planId, 'unsupported-profile', 'TECHNICAL_ASSET', 'Formato no soportado', rows.map((row) => row.rowNumber), ['PLAN_PROFILE_UNSUPPORTED'])];
|
|
const masterStateHash = await this.masterStateFingerprint(manager);
|
|
if (masterStateBefore !== masterStateHash) throw new ConflictException({ code: 'ASSET_IMPORT_MASTER_CHANGED_DURING_PLAN', message: 'El Maestro cambió mientras se generaba el plan. Volvé a generarlo para trabajar sobre un estado consistente.' });
|
|
const summary = {
|
|
...summarizePlanItems(items),
|
|
masterStateHash,
|
|
sourceRows: rows.length,
|
|
sourceReadyRows: batch.readyRows,
|
|
sourceWarningRows: batch.warningRows,
|
|
sourceConflictRows: batch.conflictRows,
|
|
relationalTerritoryPlan: batch.profileCode === 'MENDOZA_YACIMIENTOS_V1',
|
|
normalizedDepartments: items.filter((item) => item.entityKind === 'DEPARTMENT').length,
|
|
normalizedLegalRightTypes: [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT').map((item) => String(item.payload.rightType ?? '')).filter(Boolean))].length,
|
|
areaDepartmentRelations: items.filter((item) => item.entityKind === 'AREA_DEPARTMENT_RELATION').length,
|
|
legalRights: items.filter((item) => item.entityKind === 'LEGAL_RIGHT').length,
|
|
legalRightOrganizationRelations: items.filter((item) => item.entityKind === 'LEGAL_RIGHT_ORGANIZATION').length,
|
|
explicitUnassignedOperatorFields: items.filter((item) => item.entityKind === 'FIELD' && item.payload.operatorAssignmentStatus === 'UNASSIGNED_SOURCE').length,
|
|
externalIdNamespace: namespace,
|
|
operatorAssetId,
|
|
generatedAt: new Date().toISOString(),
|
|
};
|
|
const status = planStatusForItems(items);
|
|
const planHash = planItemHash(items);
|
|
await manager.query(`
|
|
INSERT INTO asset_import_plans (id,batch_id,revision,status,external_id_namespace,operator_asset_id,summary,plan_hash,generated_by)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9)
|
|
`, [planId,id,revision,status,namespace,operatorAssetId,JSON.stringify(summary),planHash,principal.userId]);
|
|
await this.insertPlanItems(manager, planId, items);
|
|
await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importPlan}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, JSON.stringify({ planId, revision, status, planHash, ...summary })]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.ASSET_IMPORT_PLAN_GENERATED,
|
|
entityType: 'asset_import_plan', entityId: planId,
|
|
afterData: { revision, status, planHash, ...summary },
|
|
metadata: { batchId: id, masterModified: false },
|
|
}, manager);
|
|
return this.planView(manager, planId);
|
|
});
|
|
}
|
|
|
|
async resolvePlanItem(id: string, itemId: string, dto: ResolveAssetImportPlanItemDto, principal: AuthPrincipal, request: RequestWithContext) {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const plan = await this.requireMutableActivePlan(manager, id);
|
|
const [item] = (await manager.query(`
|
|
SELECT id,entity_key AS "entityKey",action,status,entity_kind AS "entityKind",asset_type_code AS "assetTypeCode",matched_asset_id AS "matchedAssetId",review_codes AS "reviewCodes",payload
|
|
FROM asset_import_plan_items WHERE id=$1 AND plan_id=$2 FOR UPDATE
|
|
`, [itemId, plan.id])) as Array<{ id: string; entityKey: string; action: AssetImportPlanAction; status: AssetImportPlanItemStatus; entityKind: AssetImportPlanEntityKind; assetTypeCode: string | null; matchedAssetId: string | null; reviewCodes: string[]; payload: Record<string, unknown> }>;
|
|
if (!item) throw new NotFoundException({ code: 'ASSET_IMPORT_PLAN_ITEM_NOT_FOUND', message: 'Ítem del plan no encontrado' });
|
|
if (item.action !== 'REVIEW') throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_ITEM_NOT_REVIEW', message: 'Sólo se resuelven manualmente los ítems pendientes de revisión' });
|
|
let matchedAssetId: string | null = null;
|
|
let status: AssetImportPlanItemStatus;
|
|
if (dto.action === 'MATCH') {
|
|
if (item.payload.manualMatchAllowed === false) throw new ConflictException({ code: 'ASSET_IMPORT_MATCH_CONTEXT_BLOCKED', message: 'La coincidencia encontrada contradice el contexto operativo de la fuente. Corregí el Maestro o la fuente y regenerá el plan.' });
|
|
if (!item.assetTypeCode || item.entityKind === 'OPERATOR_RELATION') throw new ConflictException({ code: 'ASSET_IMPORT_MATCH_NOT_SUPPORTED', message: 'Este ítem no admite vinculación manual a un activo. Corregí el contexto y regenerá el plan.' });
|
|
if (!dto.matchedAssetId) throw new BadRequestException({ code: 'ASSET_IMPORT_MATCH_REQUIRED', message: 'Seleccioná el activo del Maestro para resolver como coincidencia' });
|
|
const [asset] = (await manager.query(`SELECT asset.id,type.code AS "typeCode" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=$1 AND asset.information_status<>'INACTIVE'`, [dto.matchedAssetId])) as Array<{ id: string; typeCode: string }>;
|
|
if (!asset) throw new BadRequestException({ code: 'ASSET_IMPORT_MATCH_INVALID', message: 'El activo seleccionado no existe o está inactivo' });
|
|
if (item.assetTypeCode && asset.typeCode !== item.assetTypeCode) throw new BadRequestException({ code: 'ASSET_IMPORT_MATCH_TYPE_INVALID', message: `La coincidencia debe ser del tipo ${item.assetTypeCode}` });
|
|
if (item.payload.inventoryContextDecision === true) {
|
|
const options = Array.isArray(item.payload.candidateOptions) ? item.payload.candidateOptions as Array<Record<string, unknown>> : [];
|
|
if (!options.some((option) => String(option.id ?? '') === dto.matchedAssetId)) throw new BadRequestException({ code: 'ASSET_IMPORT_CONTEXT_MATCH_INVALID', message: 'El contexto elegido no pertenece a las alternativas seguras detectadas para este inventario.' });
|
|
const contextLookup = String(item.payload.contextLookup ?? '').trim();
|
|
if (!contextLookup) throw new ConflictException({ code: 'ASSET_IMPORT_CONTEXT_KEY_REQUIRED', message: 'No se pudo identificar la clave territorial de esta decisión.' });
|
|
await manager.query(`
|
|
UPDATE asset_import_batches
|
|
SET analysis=jsonb_set(
|
|
COALESCE(analysis,'{}'::jsonb),
|
|
'{inventoryContextOverrides}',
|
|
COALESCE(analysis->'inventoryContextOverrides','{}'::jsonb) || jsonb_build_object($2::text,$3::text),
|
|
true
|
|
),updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=$1
|
|
`,[id,contextLookup,dto.matchedAssetId]);
|
|
}
|
|
matchedAssetId = dto.matchedAssetId;
|
|
status = 'MATCHED';
|
|
} else if (dto.action === 'IGNORE') {
|
|
if (item.entityKind !== 'TECHNICAL_ASSET') throw new ConflictException({ code: 'ASSET_IMPORT_STRUCTURAL_ITEM_REQUIRED', message: 'Las entidades estructurales no se omiten aisladamente. Vinculalas o aceptá su creación para preservar la jerarquía.' });
|
|
const [dependent] = (await manager.query(`SELECT 1 FROM asset_import_plan_items WHERE plan_id=$1 AND parent_entity_key=$2 AND action<>'IGNORE' LIMIT 1`, [plan.id, item.entityKey])) as unknown[];
|
|
if (dependent) throw new ConflictException({ code: 'ASSET_IMPORT_STRUCTURAL_PARENT_REQUIRED', message: 'Este ítem es padre de otras operaciones del plan. Vinculalo o aceptá su creación antes de continuar.' });
|
|
status = 'IGNORED';
|
|
} else {
|
|
if (item.payload.manualCreateAllowed !== true || !item.assetTypeCode) throw new ConflictException({ code: 'ASSET_IMPORT_MANUAL_CREATE_BLOCKED', message: 'Este conflicto no puede resolverse creando automáticamente. Corregí el contexto o elegí una coincidencia.' });
|
|
const inventoryId = String(item.payload.inventoryId ?? '').trim().toLowerCase();
|
|
if (inventoryId && item.reviewCodes.includes('DUPLICATE_INVENTORY_ID_IN_BATCH')) {
|
|
const [otherDuplicate] = (await manager.query(`
|
|
SELECT id FROM asset_import_plan_items
|
|
WHERE plan_id=$1 AND id<>$2 AND entity_kind='TECHNICAL_ASSET' AND action<>'IGNORE'
|
|
AND lower(btrim(COALESCE(payload->>'inventoryId','')))=$3
|
|
LIMIT 1
|
|
`, [plan.id,item.id,inventoryId])) as Array<{ id: string }>;
|
|
if (otherDuplicate) throw new ConflictException({ code: 'ASSET_IMPORT_DUPLICATE_ID_UNRESOLVED', message: 'Este ID aparece en más de una fila. Primero vinculá o ignorá las otras filas duplicadas y dejá una sola candidata a creación.' });
|
|
}
|
|
status = 'PLANNED';
|
|
}
|
|
const generatedCode = dto.action === 'CREATE' && item.assetTypeCode ? generatedImportCode(plan.id, item.assetTypeCode, item.entityKey) : null;
|
|
await manager.query(`
|
|
UPDATE asset_import_plan_items SET action=$3,status=$4,matched_asset_id=$5,generated_code=$6,resolution_note=$7,resolved_by=$8,resolved_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=$1 AND plan_id=$2
|
|
`, [itemId,plan.id,dto.action,status,matchedAssetId,generatedCode,dto.reason.trim(),principal.userId]);
|
|
const updated = await this.refreshPlan(manager, plan.id);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.ASSET_IMPORT_PLAN_ITEM_RESOLVED,
|
|
entityType: 'asset_import_plan_item', entityId: itemId,
|
|
beforeData: { action: item.action, status: item.status, reviewCodes: item.reviewCodes },
|
|
afterData: { action: dto.action, status, matchedAssetId, reason: dto.reason.trim() },
|
|
metadata: { batchId: id, planId: plan.id, masterModified: false },
|
|
}, manager);
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
|
|
async applySafePlan(id: string, dto: ApplyAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const batch = await this.loadBatch(manager, id, true);
|
|
if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' });
|
|
const plan = await this.loadActivePlan(manager, id, true);
|
|
if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_REQUIRED', message: 'Generá el plan de importación antes de aplicar el lote' });
|
|
if (plan.status !== 'REVIEW_REQUIRED') throw new ConflictException({ code: 'ASSET_IMPORT_PARTIAL_NOT_REQUIRED', message: 'La aplicación parcial se utiliza cuando existen revisiones pendientes' });
|
|
if (plan.planHash !== dto.planHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El plan cambió desde la confirmación. Recargá la vista antes de aplicar.' });
|
|
const plannedMasterStateHash = String(plan.summary?.masterStateHash ?? '').trim();
|
|
const currentMasterStateHash = await this.masterStateFingerprint(manager);
|
|
if (!plannedMasterStateHash || plannedMasterStateHash !== currentMasterStateHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El Maestro cambió desde que se generó este plan. Regenerá el plan antes de aplicar.' });
|
|
const allItems = await this.loadPlanItems(manager, plan.id, `ORDER BY item_order FOR UPDATE`);
|
|
const safeItems = safePlanItems(allItems);
|
|
const pendingWrites = safeItems.filter((item) => item.action === 'CREATE' && item.status !== 'APPLIED');
|
|
if (!pendingWrites.length) throw new ConflictException({ code: 'ASSET_IMPORT_NO_SAFE_ITEMS', message: 'No quedan registros independientes listos para importar. Resolvé alguna revisión para continuar.' });
|
|
await this.revalidateMatchedAssets(manager, safeItems);
|
|
await this.revalidateMatchedRelationalObjects(manager, safeItems);
|
|
if (plan.operatorAssetId) await this.requireActiveOrganization(manager, plan.operatorAssetId);
|
|
await this.revalidateExternalIdentifiers(manager, plan, safeItems);
|
|
const result = await this.applyPlanItems(manager, batch, plan, safeItems, principal, request);
|
|
const nextMasterStateHash = await this.masterStateFingerprint(manager);
|
|
const partialAt = new Date().toISOString();
|
|
const refreshed = await this.refreshPlan(manager, plan.id, { masterStateHash: nextMasterStateHash, lastPartialAppliedAt: partialAt });
|
|
const progress = await this.batchApplicationProgress(manager, id);
|
|
const partialSummary = {
|
|
status: 'PARTIAL', planId: plan.id, planHash: refreshed.planHash,
|
|
appliedCreateItems: progress.appliedCreateItems,
|
|
importedRows: progress.importedRows,
|
|
pendingCreateItems: Number(refreshed.summary.pendingCreateItems ?? 0),
|
|
reviewItems: Number(refreshed.summary.reviewItems ?? 0),
|
|
directReviewItems: Number(refreshed.summary.directReviewItems ?? 0),
|
|
dependencyReviewItems: Number(refreshed.summary.dependencyReviewItems ?? 0),
|
|
lastResult: result, appliedAt: partialAt,
|
|
};
|
|
await manager.query(`UPDATE asset_import_plans SET application_summary=$2::jsonb,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [plan.id, JSON.stringify(partialSummary)]);
|
|
await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importExecution}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, JSON.stringify(partialSummary)]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.ASSET_IMPORT_PLAN_APPLIED,
|
|
entityType: 'asset_import_plan', entityId: plan.id,
|
|
afterData: partialSummary,
|
|
metadata: { batchId: id, planHash: refreshed.planHash, transactional: true, partial: true },
|
|
}, manager);
|
|
return this.planView(manager, plan.id);
|
|
});
|
|
}
|
|
|
|
async applyPlan(id: string, dto: ApplyAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const batch = await this.loadBatch(manager, id, true);
|
|
if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' });
|
|
const plan = await this.loadActivePlan(manager, id, true);
|
|
if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_REQUIRED', message: 'Generá el plan de importación antes de aplicar el lote' });
|
|
if (plan.status === 'APPLIED') return this.planView(manager, plan.id);
|
|
if (plan.status !== 'READY') throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_BLOCKED', message: 'El plan tiene revisiones pendientes o no está listo para aplicarse' });
|
|
if (plan.planHash !== dto.planHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El plan cambió desde la confirmación. Recargá la vista antes de aplicar.' });
|
|
const plannedMasterStateHash = String(plan.summary?.masterStateHash ?? '').trim();
|
|
const currentMasterStateHash = await this.masterStateFingerprint(manager);
|
|
if (!plannedMasterStateHash || plannedMasterStateHash !== currentMasterStateHash) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'El Maestro cambió desde que se generó este plan. Conciliá y regenerá el plan antes de aplicar para evitar duplicados.' });
|
|
const items = await this.loadPlanItems(manager, plan.id, `ORDER BY item_order FOR UPDATE`);
|
|
if (items.some((item) => item.action === 'REVIEW')) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_BLOCKED', message: 'Existen ítems pendientes de revisión' });
|
|
await this.revalidateMatchedAssets(manager, items);
|
|
await this.revalidateMatchedRelationalObjects(manager, items);
|
|
if (plan.operatorAssetId) await this.requireActiveOrganization(manager, plan.operatorAssetId);
|
|
await this.revalidateExternalIdentifiers(manager, plan, items);
|
|
const result = await this.applyPlanItems(manager, batch, plan, items, principal, request);
|
|
const progress = await this.batchApplicationProgress(manager, id);
|
|
const appliedAt = new Date().toISOString();
|
|
const finalSummary = { status: 'APPLIED', planId: plan.id, planHash: plan.planHash, appliedCreateItems: progress.appliedCreateItems, importedRows: progress.importedRows, lastResult: result, appliedAt };
|
|
await manager.query(`
|
|
UPDATE asset_import_plans SET status='APPLIED',applied_by=$2,applied_at=CURRENT_TIMESTAMP,application_summary=$3::jsonb,updated_at=CURRENT_TIMESTAMP WHERE id=$1
|
|
`, [plan.id,principal.userId,JSON.stringify(finalSummary)]);
|
|
await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importExecution}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, JSON.stringify(finalSummary)]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.ASSET_IMPORT_PLAN_APPLIED,
|
|
entityType: 'asset_import_plan', entityId: plan.id,
|
|
afterData: finalSummary,
|
|
metadata: { batchId: id, planHash: plan.planHash, transactional: true },
|
|
}, manager);
|
|
return this.planView(manager, plan.id);
|
|
});
|
|
}
|
|
|
|
async rollbackPlan(id: string, dto: RollbackAssetImportPlanDto, principal: AuthPrincipal, request: RequestWithContext) {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const plan = await this.loadActivePlan(manager, id, true);
|
|
if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_NOT_APPLIED', message: 'No existe una importación activa para revertir' });
|
|
const items = (await manager.query(`
|
|
SELECT item.id,item.plan_id AS "planId",item.item_order AS "itemOrder",item.entity_key AS "entityKey",item.entity_kind AS "entityKind",item.action,item.status,
|
|
item.asset_type_code AS "assetTypeCode",item.display_name AS "displayName",item.generated_code AS "generatedCode",item.parent_entity_key AS "parentEntityKey",
|
|
item.matched_asset_id AS "matchedAssetId",item.applied_asset_id AS "appliedAssetId",item.applied_object_id AS "appliedObjectId",item.payload,
|
|
item.source_row_numbers AS "sourceRowNumbers",item.review_codes AS "reviewCodes",item.resolution_note AS "resolutionNote",item.resolved_by AS "resolvedBy",item.resolved_at AS "resolvedAt"
|
|
FROM asset_import_plan_items item
|
|
JOIN asset_import_plans history ON history.id=item.plan_id
|
|
WHERE history.batch_id=$1 AND item.action='CREATE' AND item.status='APPLIED'
|
|
ORDER BY history.revision DESC,item.item_order DESC
|
|
FOR UPDATE OF item
|
|
`,[id])) as PlanItemRow[];
|
|
if (!items.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_NOT_APPLIED', message: 'Este lote todavía no incorporó registros al Maestro' });
|
|
const createdAssetIds = [...new Set(items.filter((item) => item.appliedAssetId).map((item) => item.appliedAssetId!))];
|
|
await this.assertRollbackSafe(manager, items, createdAssetIds);
|
|
const reason = dto.reason.trim();
|
|
let endedOperatorRelations = 0;
|
|
let endedAreaDepartmentRelations = 0;
|
|
let endedLegalRightOrganizationRelations = 0;
|
|
let revokedLegalRights = 0;
|
|
let deactivatedDepartments = 0;
|
|
let endedIdentifiers = 0;
|
|
const operatorRelationIds = [...new Set(items.filter((item) => item.entityKind === 'OPERATOR_RELATION' && item.appliedObjectId).map((item) => item.appliedObjectId!))];
|
|
const areaDepartmentRelationIds = [...new Set(items.filter((item) => item.entityKind === 'AREA_DEPARTMENT_RELATION' && item.appliedObjectId).map((item) => item.appliedObjectId!))];
|
|
const legalRightIds = [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT' && item.appliedObjectId).map((item) => item.appliedObjectId!))];
|
|
const legalRightOrganizationIds = [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT_ORGANIZATION' && item.appliedObjectId).map((item) => item.appliedObjectId!))];
|
|
const departmentIds = [...new Set(items.filter((item) => item.entityKind === 'DEPARTMENT' && item.appliedObjectId).map((item) => item.appliedObjectId!))];
|
|
const roleRows = createdAssetIds.length ? (await manager.query(`
|
|
SELECT asset.id,type.operational_role AS role FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=ANY($1::uuid[])
|
|
`, [createdAssetIds])) as Array<{ id: string; role: 'AREA' | 'COMPANY' | 'GENERIC' }> : [];
|
|
const genericIds = roleRows.filter((row) => row.role === 'GENERIC').map((row) => row.id);
|
|
const anchorIds = roleRows.filter((row) => row.role !== 'GENERIC').map((row) => row.id);
|
|
if (createdAssetIds.length) {
|
|
const result = await manager.query(`UPDATE asset_external_identifiers SET valid_until=CURRENT_TIMESTAMP,end_reason=$2,ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE asset_id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [createdAssetIds,`Reversión de importación: ${reason}`,principal.userId]);
|
|
endedIdentifiers = result.length;
|
|
}
|
|
if (genericIds.length) await manager.query(`UPDATE assets SET information_status='INACTIVE',operational_area_id=NULL,operator_company_id=NULL,updated_by=$2,updated_at=CURRENT_TIMESTAMP,source_notes=concat_ws(E'\n',source_notes,$3),current_version=current_version+1 WHERE id=ANY($1::uuid[])`, [genericIds,principal.userId,`Revertido por lote ${id}: ${reason}`]);
|
|
if (legalRightOrganizationIds.length) {
|
|
const result = await manager.query(`UPDATE area_legal_right_organizations SET valid_until=CURRENT_DATE,end_reason=$2,ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [legalRightOrganizationIds,`Reversión de importación: ${reason}`,principal.userId]);
|
|
endedLegalRightOrganizationRelations = result.length;
|
|
}
|
|
if (legalRightIds.length) {
|
|
const result = await manager.query(`UPDATE area_legal_rights SET status='REVOKED',valid_until=COALESCE(valid_until,CURRENT_DATE),notes=concat_ws(E'\n',notes,$2),updated_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND status IN ('ACTIVE','PENDING') RETURNING id`, [legalRightIds,`Revertido por lote ${id}: ${reason}`,principal.userId]);
|
|
revokedLegalRights = result.length;
|
|
}
|
|
if (areaDepartmentRelationIds.length) {
|
|
const result = await manager.query(`UPDATE area_department_relations SET valid_until=CURRENT_DATE,notes=concat_ws(E'\n',notes,$2),ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [areaDepartmentRelationIds,`Reversión de importación: ${reason}`,principal.userId]);
|
|
endedAreaDepartmentRelations = result.length;
|
|
}
|
|
if (operatorRelationIds.length) {
|
|
const result = await manager.query(`UPDATE area_company_relations SET valid_until=CURRENT_TIMESTAMP,end_reason=$2,ended_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND valid_until IS NULL RETURNING id`, [operatorRelationIds,`Reversión de importación: ${reason}`,principal.userId]);
|
|
endedOperatorRelations = result.length;
|
|
}
|
|
if (departmentIds.length) {
|
|
const result = await manager.query(`UPDATE administrative_departments SET is_active=false,updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[]) AND is_active=true RETURNING id`, [departmentIds,principal.userId]);
|
|
deactivatedDepartments = result.length;
|
|
}
|
|
if (anchorIds.length) await manager.query(`UPDATE assets SET information_status='INACTIVE',updated_by=$2,updated_at=CURRENT_TIMESTAMP,source_notes=concat_ws(E'\n',source_notes,$3),current_version=current_version+1 WHERE id=ANY($1::uuid[])`, [anchorIds,principal.userId,`Revertido por lote ${id}: ${reason}`]);
|
|
if (createdAssetIds.length) await this.insertRollbackVersions(manager, createdAssetIds, principal, request);
|
|
await manager.query(`UPDATE asset_import_rows SET imported_asset_id=NULL,updated_at=CURRENT_TIMESTAMP WHERE batch_id=$1`, [id]);
|
|
const itemIds = items.map((item) => item.id);
|
|
await manager.query(`UPDATE asset_import_plan_items SET status='ROLLED_BACK',updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[])`, [itemIds]);
|
|
const historyPlanIds = [...new Set([...items.map((item) => item.planId), plan.id])];
|
|
const summary = { assetsInactivated: createdAssetIds.length, operatorRelationsEnded: endedOperatorRelations, areaDepartmentRelationsEnded: endedAreaDepartmentRelations, legalRightOrganizationRelationsEnded: endedLegalRightOrganizationRelations, legalRightsRevoked: revokedLegalRights, departmentsDeactivated: deactivatedDepartments, identifiersEnded: endedIdentifiers, reason, rolledBackAt: new Date().toISOString() };
|
|
await manager.query(`UPDATE asset_import_plans SET status='ROLLED_BACK',rolled_back_by=$2,rolled_back_at=CURRENT_TIMESTAMP,rollback_summary=$3::jsonb,updated_at=CURRENT_TIMESTAMP WHERE id=ANY($1::uuid[])`, [historyPlanIds,principal.userId,JSON.stringify(summary)]);
|
|
await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importExecution}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id,JSON.stringify({ status: 'ROLLED_BACK',planId:plan.id,...summary })]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.ASSET_IMPORT_PLAN_ROLLED_BACK,
|
|
entityType: 'asset_import_plan', entityId: plan.id,
|
|
beforeData: { status: plan.status }, afterData: { status: 'ROLLED_BACK', ...summary },
|
|
metadata: { batchId: id, logicalRollback: true, relationalTerritory: true, planRevisions: historyPlanIds.length },
|
|
}, manager);
|
|
return this.planView(manager, plan.id);
|
|
});
|
|
}
|
|
|
|
async upload(dto: UploadAssetImportDto, file: UploadedImportFile | undefined, principal: AuthPrincipal, request: RequestWithContext) {
|
|
const inspected = inspectImportFile(file);
|
|
const id = randomUUID();
|
|
const storedName = `${id}${inspected.extension}`;
|
|
const filePath = resolve(this.storageRoot, storedName);
|
|
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
|
await mkdir(this.storageRoot, { recursive: true, mode: 0o700 });
|
|
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
|
|
|
|
try {
|
|
const workbook = await parseImportWorkbook(filePath, inspected.extension);
|
|
const analysis = analyzeImportWorkbook(workbook, dto.profileCode as AssetImportProfileCode | undefined);
|
|
const status = analysis.detection.profileCode === 'UNKNOWN' || analysis.summary.conflictRows > 0 ? 'REVIEW_REQUIRED' : 'ANALYZED';
|
|
return await this.dataSource.transaction(async (manager) => {
|
|
const [document] = await manager.query(`
|
|
INSERT INTO source_documents (document_type,title,issuer,external_reference,notes,created_by,updated_by)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$6)
|
|
RETURNING id
|
|
`, [SourceDocumentType.SPREADSHEET, `Archivo de inventario: ${file!.originalname}`, dto.sourceLabel?.trim() || null, `sha256:${sha256}`, dto.notes?.trim() || null, principal.userId]);
|
|
const sourceDocumentId = String(document.id);
|
|
await manager.query(`
|
|
INSERT INTO asset_import_batches (
|
|
id,original_name,stored_name,mime_type,size_bytes,sha256,profile_code,profile_confidence,worksheet_name,header_row,
|
|
total_rows,ready_rows,warning_rows,conflict_rows,ignored_rows,status,source_document_id,source_label,notes,analysis,uploaded_by
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20::jsonb,$21)
|
|
`, [
|
|
id,file!.originalname,storedName,inspected.mimeType,file!.buffer.length,sha256,analysis.detection.profileCode,analysis.detection.confidence,
|
|
analysis.detection.sheetName,analysis.detection.headerRow,analysis.summary.totalRows,analysis.summary.readyRows,analysis.summary.warningRows,
|
|
analysis.summary.conflictRows,analysis.summary.ignoredRows,status,sourceDocumentId,dto.sourceLabel?.trim() || null,dto.notes?.trim() || null,
|
|
JSON.stringify({ profileLabel: profileLabel(analysis.detection.profileCode), detectedHeaders: analysis.detection.detectedHeaders, columnMap: analysis.detection.columnMap, issueCounts: analysis.summary.issueCounts, workbookKind: workbook.kind, sheetCount: workbook.sheets.length }),
|
|
principal.userId,
|
|
]);
|
|
await this.insertRows(manager, id, analysis.rows);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.ASSET_IMPORT_BATCH_ANALYZED,
|
|
entityType: 'asset_import_batch',
|
|
entityId: id,
|
|
afterData: { originalName: file!.originalname, profileCode: analysis.detection.profileCode, status, ...analysis.summary },
|
|
metadata: { sha256, sourceDocumentId },
|
|
}, manager);
|
|
return this.loadBatch(manager, id);
|
|
});
|
|
} catch (error) {
|
|
await unlink(filePath).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async reconcile(id: string, principal: AuthPrincipal, request: RequestWithContext): Promise<ImportBatchRow> {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const batch = await this.loadBatch(manager, id, true);
|
|
if (batch.status === 'CANCELLED') throw new ConflictException({ code: 'ASSET_IMPORT_CANCELLED', message: 'El lote está cancelado' });
|
|
const activePlan = await this.loadActivePlan(manager, id, true);
|
|
if (activePlan?.status === 'APPLIED') throw new ConflictException({ code: 'ASSET_IMPORT_ALREADY_APPLIED', message: 'El lote ya fue aplicado. Revertí la importación antes de volver a conciliar.' });
|
|
if (await this.batchHasAppliedItems(manager, id)) throw new ConflictException({ code: 'ASSET_IMPORT_PARTIAL_RECONCILE_BLOCKED', message: 'El lote ya tiene registros importados parcialmente. Regenerá el plan directamente o revertí la importación antes de volver a conciliar.' });
|
|
if (activePlan) await manager.query(`UPDATE asset_import_plans SET status='SUPERSEDED',superseded_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [activePlan.id]);
|
|
|
|
const rows = (await manager.query(`
|
|
SELECT id,row_number AS "rowNumber",status,suggested_action AS "suggestedAction",
|
|
normalized_data AS "normalizedData",issue_codes AS issues
|
|
FROM asset_import_rows WHERE batch_id=$1 ORDER BY row_number
|
|
`, [id])) as Array<{
|
|
id: string; rowNumber: number; status: string; suggestedAction: string;
|
|
normalizedData: Record<string, unknown>; issues: string[];
|
|
}>;
|
|
|
|
const inventoryIds = [...new Set(rows.map((row) => String(row.normalizedData.inventoryId ?? '').trim().toLowerCase()).filter(Boolean))];
|
|
const exactByInventory = new Map<string, Set<string>>();
|
|
let exactExternalIdMatches = 0;
|
|
let exactCodeMatches = 0;
|
|
if (inventoryIds.length) {
|
|
const external = (await manager.query(`
|
|
SELECT lower(value) AS lookup,asset_id AS "assetId"
|
|
FROM asset_external_identifiers
|
|
WHERE valid_until IS NULL AND lower(value)=ANY($1::text[])
|
|
`, [inventoryIds])) as Array<{ lookup: string; assetId: string }>;
|
|
for (const item of external) {
|
|
const set = exactByInventory.get(item.lookup) ?? new Set<string>(); set.add(item.assetId); exactByInventory.set(item.lookup, set); exactExternalIdMatches += 1;
|
|
}
|
|
const codes = (await manager.query(`SELECT lower(code) AS lookup,id AS "assetId" FROM assets WHERE lower(code)=ANY($1::text[])`, [inventoryIds])) as Array<{ lookup: string; assetId: string }>;
|
|
for (const item of codes) {
|
|
const set = exactByInventory.get(item.lookup) ?? new Set<string>(); set.add(item.assetId); exactByInventory.set(item.lookup, set); exactCodeMatches += 1;
|
|
}
|
|
}
|
|
|
|
const areaNames = [...new Set(rows.map((row) => String(row.normalizedData.area ?? '').trim().toLowerCase()).filter(Boolean))];
|
|
const fieldNames = [...new Set(rows.map((row) => String(row.normalizedData.field ?? '').trim().toLowerCase()).filter(Boolean))];
|
|
const territoryMatches = new Map<string, Set<string>>();
|
|
let exactTerritoryMatches = 0;
|
|
if (areaNames.length && fieldNames.length) {
|
|
const territories = (await manager.query(`
|
|
SELECT lower(area.name) AS area,lower(field.name) AS field,field.id AS "assetId"
|
|
FROM assets field
|
|
JOIN assets area ON area.id=field.parent_id
|
|
JOIN asset_types area_type ON area_type.id=area.asset_type_id
|
|
WHERE area_type.operational_role='AREA'
|
|
AND lower(area.name)=ANY($1::text[]) AND lower(field.name)=ANY($2::text[])
|
|
`, [areaNames, fieldNames])) as Array<{ area: string; field: string; assetId: string }>;
|
|
for (const item of territories) {
|
|
const key = `${item.area}\u001f${item.field}`;
|
|
const set = territoryMatches.get(key) ?? new Set<string>(); set.add(item.assetId); territoryMatches.set(key, set); exactTerritoryMatches += 1;
|
|
}
|
|
}
|
|
|
|
const operatorNames = [...new Set(rows.map((row) => String(row.normalizedData.operator ?? '').trim().toLowerCase()).filter(Boolean))];
|
|
const organizationMatches = new Map<string, Set<string>>();
|
|
let exactOrganizationMatches = 0;
|
|
if (operatorNames.length) {
|
|
const organizations = (await manager.query(`
|
|
SELECT lookup,"assetId" FROM (
|
|
SELECT lower(asset.name) AS lookup,asset.id AS "assetId"
|
|
FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id
|
|
WHERE type.operational_role='COMPANY' AND lower(asset.name)=ANY($1::text[])
|
|
UNION
|
|
SELECT lower(profile.legal_name) AS lookup,profile.asset_id AS "assetId"
|
|
FROM organization_profiles profile WHERE lower(profile.legal_name)=ANY($1::text[])
|
|
) candidates
|
|
`, [operatorNames])) as Array<{ lookup: string; assetId: string }>;
|
|
for (const item of organizations) {
|
|
const set = organizationMatches.get(item.lookup) ?? new Set<string>();
|
|
set.add(item.assetId);
|
|
organizationMatches.set(item.lookup, set);
|
|
exactOrganizationMatches += 1;
|
|
}
|
|
}
|
|
|
|
let createRows = 0; let matchRows = 0; let reviewRows = 0; let ignoreRows = 0; let multipleMatches = 0;
|
|
const updates: Array<{ id: string; matchedAssetId: string | null; action: string; status: string; issues: string[]; reconciliation: Record<string, unknown> }> = [];
|
|
for (const row of rows) {
|
|
const existingIssues = Array.isArray(row.issues) ? [...row.issues] : [];
|
|
let candidates = new Set<string>();
|
|
let matchMethod: string | null = null;
|
|
const inventoryId = String(row.normalizedData.inventoryId ?? '').trim().toLowerCase();
|
|
if (inventoryId) { candidates = exactByInventory.get(inventoryId) ?? new Set<string>(); if (candidates.size) matchMethod = 'INVENTORY_ID_OR_CODE_EXACT'; }
|
|
if (!candidates.size && batch.profileCode === 'MENDOZA_YACIMIENTOS_V1') {
|
|
const key = `${String(row.normalizedData.area ?? '').trim().toLowerCase()}\u001f${String(row.normalizedData.field ?? '').trim().toLowerCase()}`;
|
|
candidates = territoryMatches.get(key) ?? new Set<string>(); if (candidates.size) matchMethod = 'AREA_FIELD_EXACT';
|
|
}
|
|
|
|
const operatorKey = String(row.normalizedData.operator ?? '').trim().toLowerCase();
|
|
const operatorCandidates = operatorKey ? (organizationMatches.get(operatorKey) ?? new Set<string>()) : new Set<string>();
|
|
const operatorMatchedAssetId = operatorCandidates.size === 1 ? ([...operatorCandidates][0] ?? null) : null;
|
|
if (operatorCandidates.size > 1 && !existingIssues.includes('MATCH_MULTIPLE_ORGANIZATIONS')) existingIssues.push('MATCH_MULTIPLE_ORGANIZATIONS');
|
|
|
|
let action = row.suggestedAction;
|
|
let status = row.status;
|
|
let matchedAssetId: string | null = null;
|
|
if (status === 'IGNORED') { action = 'IGNORE'; ignoreRows += 1; }
|
|
else if (status === 'CONFLICT' || status === 'WARNING') {
|
|
action = 'REVIEW'; reviewRows += 1;
|
|
if (candidates.size > 1) multipleMatches += 1;
|
|
}
|
|
else if (candidates.size === 1) { matchedAssetId = [...candidates][0] ?? null; action = 'MATCH'; matchRows += 1; }
|
|
else if (candidates.size > 1) {
|
|
action = 'REVIEW'; status = 'CONFLICT'; reviewRows += 1; multipleMatches += 1;
|
|
if (!existingIssues.includes('MATCH_MULTIPLE_ASSETS')) existingIssues.push('MATCH_MULTIPLE_ASSETS');
|
|
} else if (operatorCandidates.size > 1) { action = 'REVIEW'; status = 'CONFLICT'; reviewRows += 1; multipleMatches += 1; }
|
|
else { action = 'CREATE'; createRows += 1; }
|
|
|
|
updates.push({ id: row.id, matchedAssetId, action, status, issues: existingIssues, reconciliation: { matchMethod, candidateCount: candidates.size, matchedAssetId, operatorMatchedAssetId, operatorCandidateCount: operatorCandidates.size, operatorMatchMethod: operatorCandidates.size ? 'ORGANIZATION_NAME_EXACT' : null } });
|
|
}
|
|
|
|
for (let start = 0; start < updates.length; start += 250) {
|
|
const chunk = updates.slice(start, start + 250);
|
|
const params: unknown[] = [];
|
|
const values = chunk.map((item) => {
|
|
const base = params.length;
|
|
params.push(item.id,item.matchedAssetId,item.action,item.status,JSON.stringify(item.issues),JSON.stringify(item.reconciliation));
|
|
return `($${base+1}::uuid,$${base+2}::uuid,$${base+3}::text,$${base+4}::text,$${base+5}::jsonb,$${base+6}::jsonb)`;
|
|
});
|
|
await manager.query(`
|
|
UPDATE asset_import_rows row SET
|
|
matched_asset_id=v.matched_asset_id,
|
|
suggested_action=v.action::asset_import_suggested_action,
|
|
status=v.status::asset_import_row_status,
|
|
issue_codes=v.issues,
|
|
normalized_data=jsonb_set(row.normalized_data,'{reconciliation}',v.reconciliation,true),
|
|
updated_at=CURRENT_TIMESTAMP
|
|
FROM (VALUES ${values.join(',')}) AS v(id,matched_asset_id,action,status,issues,reconciliation)
|
|
WHERE row.id=v.id
|
|
`, params);
|
|
}
|
|
|
|
const summary: ImportReconciliationSummary = {
|
|
totalRows: rows.length, createRows, matchRows, reviewRows, ignoreRows,
|
|
exactExternalIdMatches, exactCodeMatches, exactTerritoryMatches, exactOrganizationMatches, multipleMatches,
|
|
reconciledAt: new Date().toISOString(),
|
|
};
|
|
await manager.query(`
|
|
UPDATE asset_import_batches SET
|
|
analysis=jsonb_set(analysis,'{reconciliation}',$2::jsonb,true),
|
|
status=CASE WHEN $3::integer>0 THEN 'REVIEW_REQUIRED'::asset_import_batch_status ELSE 'ANALYZED'::asset_import_batch_status END,
|
|
updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=$1
|
|
`, [id, JSON.stringify(summary), reviewRows]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.ASSET_IMPORT_BATCH_RECONCILED,
|
|
entityType: 'asset_import_batch', entityId: id,
|
|
afterData: { ...summary },
|
|
metadata: { conservativeMatching: true, masterModified: false },
|
|
}, manager);
|
|
return this.loadBatch(manager, id);
|
|
});
|
|
}
|
|
|
|
async cancel(id: string, dto: CancelAssetImportDto, principal: AuthPrincipal, request: RequestWithContext) {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const batch = await this.loadBatch(manager, id, true);
|
|
if (batch.status === 'CANCELLED') return batch;
|
|
const activePlan = await this.loadActivePlan(manager, id, true);
|
|
if (activePlan?.status === 'APPLIED' || await this.batchHasAppliedItems(manager, id)) throw new ConflictException({ code: 'ASSET_IMPORT_APPLIED_CANNOT_CANCEL', message: 'El lote ya incorporó registros al Maestro. Revertí primero la importación antes de cancelarlo.' });
|
|
if (activePlan) await manager.query(`UPDATE asset_import_plans SET status='SUPERSEDED',superseded_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [activePlan.id]);
|
|
await manager.query(`UPDATE asset_import_batches SET status='CANCELLED', notes=concat_ws(E'\n',notes,$2),updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, `Cancelado: ${dto.reason.trim()}`]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.ASSET_IMPORT_BATCH_CANCELLED,
|
|
entityType: 'asset_import_batch', entityId: id,
|
|
beforeData: { status: batch.status }, afterData: { status: 'CANCELLED' }, metadata: { reason: dto.reason.trim() },
|
|
}, manager);
|
|
return this.loadBatch(manager, id);
|
|
});
|
|
}
|
|
|
|
private async loadSourceRows(manager: EntityManager, batchId: string): Promise<ImportSourceRow[]> {
|
|
return (await manager.query(`
|
|
SELECT id,row_number AS "rowNumber",status,suggested_action AS "suggestedAction",
|
|
normalized_data AS "normalizedData",issue_codes AS issues,matched_asset_id AS "matchedAssetId"
|
|
FROM asset_import_rows WHERE batch_id=$1 ORDER BY row_number
|
|
`, [batchId])) as ImportSourceRow[];
|
|
}
|
|
|
|
private async requireActiveOrganization(manager: EntityManager, assetId: string): Promise<void> {
|
|
const [row] = (await manager.query(`
|
|
SELECT 1 FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id
|
|
WHERE asset.id=$1 AND type.operational_role='COMPANY' AND type.is_active=true AND asset.information_status<>'INACTIVE'
|
|
`, [assetId])) as unknown[];
|
|
if (!row) throw new BadRequestException({ code: 'ASSET_IMPORT_OPERATOR_INVALID', message: 'La organización operadora seleccionada no existe o está inactiva' });
|
|
}
|
|
|
|
private async loadActivePlan(manager: EntityManager, batchId: string, lock = false): Promise<ImportPlanRow | null> {
|
|
const rows = (await manager.query(`
|
|
SELECT id,batch_id AS "batchId",revision,status,external_id_namespace AS "externalIdNamespace",operator_asset_id AS "operatorAssetId",
|
|
summary,plan_hash AS "planHash",generated_by AS "generatedBy",generated_at AS "generatedAt",applied_by AS "appliedBy",applied_at AS "appliedAt",
|
|
application_summary AS "applicationSummary",rolled_back_by AS "rolledBackBy",rolled_back_at AS "rolledBackAt",rollback_summary AS "rollbackSummary",
|
|
superseded_at AS "supersededAt",created_at AS "createdAt",updated_at AS "updatedAt"
|
|
FROM asset_import_plans WHERE batch_id=$1 AND superseded_at IS NULL
|
|
ORDER BY revision DESC LIMIT 1${lock ? ' FOR UPDATE' : ''}
|
|
`, [batchId])) as ImportPlanRow[];
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
private async loadPlanById(manager: EntityManager, planId: string): Promise<ImportPlanRow> {
|
|
const rows = (await manager.query(`
|
|
SELECT id,batch_id AS "batchId",revision,status,external_id_namespace AS "externalIdNamespace",operator_asset_id AS "operatorAssetId",
|
|
summary,plan_hash AS "planHash",generated_by AS "generatedBy",generated_at AS "generatedAt",applied_by AS "appliedBy",applied_at AS "appliedAt",
|
|
application_summary AS "applicationSummary",rolled_back_by AS "rolledBackBy",rolled_back_at AS "rolledBackAt",rollback_summary AS "rollbackSummary",
|
|
superseded_at AS "supersededAt",created_at AS "createdAt",updated_at AS "updatedAt"
|
|
FROM asset_import_plans WHERE id=$1
|
|
`, [planId])) as ImportPlanRow[];
|
|
if (!rows[0]) throw new NotFoundException({ code: 'ASSET_IMPORT_PLAN_NOT_FOUND', message: 'Plan de importación no encontrado' });
|
|
return rows[0];
|
|
}
|
|
|
|
private async loadPlanItems(manager: EntityManager, planId: string, suffix = 'ORDER BY item_order'): Promise<PlanItemRow[]> {
|
|
return (await manager.query(`
|
|
SELECT id,plan_id AS "planId",item_order AS "itemOrder",entity_key AS "entityKey",entity_kind AS "entityKind",action,status,
|
|
asset_type_code AS "assetTypeCode",display_name AS "displayName",generated_code AS "generatedCode",parent_entity_key AS "parentEntityKey",
|
|
matched_asset_id AS "matchedAssetId",applied_asset_id AS "appliedAssetId",applied_object_id AS "appliedObjectId",payload,
|
|
source_row_numbers AS "sourceRowNumbers",review_codes AS "reviewCodes",resolution_note AS "resolutionNote",resolved_by AS "resolvedBy",resolved_at AS "resolvedAt"
|
|
FROM asset_import_plan_items WHERE plan_id=$1 ${suffix}
|
|
`, [planId])) as PlanItemRow[];
|
|
}
|
|
|
|
private async planView(manager: EntityManager, planId: string) {
|
|
const plan = await this.loadPlanById(manager, planId);
|
|
const previewItems = await this.loadPlanItems(manager, plan.id, `ORDER BY item_order LIMIT 80`);
|
|
const reviewItems = await this.loadPlanItems(manager, plan.id, `AND action='REVIEW' ORDER BY CASE WHEN review_codes <@ '["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"]'::jsonb THEN 1 ELSE 0 END, item_order LIMIT 80`);
|
|
const masterState = await this.planMasterState(manager, plan);
|
|
return { ...plan, previewItems, reviewItems, ...masterState };
|
|
}
|
|
|
|
private async planMasterState(manager: EntityManager, plan: ImportPlanRow): Promise<{ masterStateStale: boolean; masterStateHashAvailable: boolean; masterStateCheckedAt: string }> {
|
|
const planned = String(plan.summary?.masterStateHash ?? '').trim();
|
|
if (!['READY','REVIEW_REQUIRED'].includes(plan.status)) return { masterStateStale: false, masterStateHashAvailable: Boolean(planned), masterStateCheckedAt: new Date().toISOString() };
|
|
const current = await this.masterStateFingerprint(manager);
|
|
return { masterStateStale: !planned || planned !== current, masterStateHashAvailable: Boolean(planned), masterStateCheckedAt: new Date().toISOString() };
|
|
}
|
|
|
|
private async masterStateFingerprint(manager: EntityManager): Promise<string> {
|
|
const [state] = (await manager.query(`
|
|
SELECT
|
|
(SELECT COUNT(*)::text FROM assets WHERE information_status<>'INACTIVE') AS "assetsCount",
|
|
COALESCE((SELECT MAX(updated_at)::text FROM assets),'') AS "assetsUpdatedAt",
|
|
(SELECT COUNT(*)::text FROM organization_profiles) AS "organizationsCount",
|
|
COALESCE((SELECT MAX(updated_at)::text FROM organization_profiles),'') AS "organizationsUpdatedAt",
|
|
(SELECT COUNT(*)::text FROM administrative_departments WHERE is_active=true) AS "departmentsCount",
|
|
COALESCE((SELECT MAX(updated_at)::text FROM administrative_departments),'') AS "departmentsUpdatedAt",
|
|
(SELECT COUNT(*)::text FROM area_department_relations WHERE valid_until IS NULL) AS "areaDepartmentsCount",
|
|
COALESCE((SELECT MAX(updated_at)::text FROM area_department_relations),'') AS "areaDepartmentsUpdatedAt",
|
|
(SELECT COUNT(*)::text FROM area_company_relations WHERE valid_until IS NULL) AS "areaOrganizationsCount",
|
|
COALESCE((SELECT MAX(updated_at)::text FROM area_company_relations),'') AS "areaOrganizationsUpdatedAt",
|
|
(SELECT COUNT(*)::text FROM area_legal_rights WHERE status IN ('ACTIVE','PENDING')) AS "legalRightsCount",
|
|
COALESCE((SELECT MAX(updated_at)::text FROM area_legal_rights),'') AS "legalRightsUpdatedAt",
|
|
(SELECT COUNT(*)::text FROM area_legal_right_organizations WHERE valid_until IS NULL) AS "legalRightOrganizationsCount",
|
|
COALESCE((SELECT MAX(updated_at)::text FROM area_legal_right_organizations),'') AS "legalRightOrganizationsUpdatedAt",
|
|
(SELECT COUNT(*)::text FROM asset_external_identifiers WHERE valid_until IS NULL) AS "externalIdentifiersCount",
|
|
COALESCE((SELECT MAX(updated_at)::text FROM asset_external_identifiers),'') AS "externalIdentifiersUpdatedAt"
|
|
`)) as Array<Record<string, string>>;
|
|
return createHash('sha256').update(JSON.stringify(state ?? {})).digest('hex');
|
|
}
|
|
|
|
private async batchApplicationProgress(manager: EntityManager, batchId: string): Promise<{ appliedCreateItems: number; importedRows: number }> {
|
|
const [row] = (await manager.query(`
|
|
SELECT
|
|
(SELECT COUNT(*)::integer FROM asset_import_plan_items item JOIN asset_import_plans plan ON plan.id=item.plan_id WHERE plan.batch_id=$1 AND item.action='CREATE' AND item.status='APPLIED') AS "appliedCreateItems",
|
|
(SELECT COUNT(*)::integer FROM asset_import_rows WHERE batch_id=$1 AND imported_asset_id IS NOT NULL) AS "importedRows"
|
|
`,[batchId])) as Array<{ appliedCreateItems: number; importedRows: number }>;
|
|
return { appliedCreateItems: Number(row?.appliedCreateItems ?? 0), importedRows: Number(row?.importedRows ?? 0) };
|
|
}
|
|
|
|
private async batchHasAppliedItems(manager: EntityManager, batchId: string): Promise<boolean> {
|
|
const progress = await this.batchApplicationProgress(manager, batchId);
|
|
return progress.appliedCreateItems > 0;
|
|
}
|
|
|
|
private async requireMutableActivePlan(manager: EntityManager, batchId: string): Promise<ImportPlanRow> {
|
|
const plan = await this.loadActivePlan(manager, batchId, true);
|
|
if (!plan) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_REQUIRED', message: 'Generá primero el plan de importación' });
|
|
if (!['READY','REVIEW_REQUIRED'].includes(plan.status)) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_LOCKED', message: 'El plan ya no admite resoluciones manuales' });
|
|
return plan;
|
|
}
|
|
|
|
private reviewPlanItem(
|
|
planId: string,
|
|
entityKey: string,
|
|
entityKind: AssetImportPlanEntityKind,
|
|
displayName: string,
|
|
sourceRowNumbers: number[],
|
|
reviewCodes: string[],
|
|
payload: Record<string, unknown> = {},
|
|
): AssetImportPlanDraftItem {
|
|
return {
|
|
entityKey,entityKind,action:'REVIEW',status:'REVIEW',assetTypeCode:null,displayName,generatedCode:null,parentEntityKey:null,matchedAssetId:null,
|
|
payload: { ...payload, manualCreateAllowed: false, planId },
|
|
sourceRowNumbers:[...new Set(sourceRowNumbers)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviewCodes)],
|
|
};
|
|
}
|
|
|
|
private async buildTerritoryPlan(manager: EntityManager, planId: string, batch: ImportBatchRow, rows: ImportSourceRow[]): Promise<AssetImportPlanDraftItem[]> {
|
|
type Aggregate = { name: string; rows: number[]; issues: Set<string>; values: Set<string> };
|
|
type FieldAggregate = Aggregate & {
|
|
areaKey: string;
|
|
departmentKeys: Set<string>;
|
|
rightKeys: Set<string>;
|
|
operatorKeys: Set<string>;
|
|
operatorUnassigned: boolean;
|
|
operatorSourceValues: Set<string>;
|
|
};
|
|
const departments = new Map<string, Aggregate>();
|
|
const organizations = new Map<string, Aggregate>();
|
|
const areas = new Map<string, Aggregate>();
|
|
const fields = new Map<string, FieldAggregate>();
|
|
const areaDepartments = new Map<string, { areaKey: string; departmentKey: string; rows: number[]; issues: Set<string> }>();
|
|
const operatorRelations = new Map<string, { areaKey: string; organizationKey: string; rows: number[]; issues: Set<string>; rightTypes: Set<string> }>();
|
|
const legalRights = new Map<string, { areaKey: string; rightType: string; sourceRightTypes: Set<string>; rows: number[]; issues: Set<string> }>();
|
|
const legalRightOrganizations = new Map<string, { legalRightKey: string; organizationKey: string; rows: number[]; issues: Set<string> }>();
|
|
|
|
const add = (map: Map<string, Aggregate>, key: string, name: string, row: ImportSourceRow, value?: string) => {
|
|
const current = map.get(key) ?? { name, rows: [], issues: new Set<string>(), values: new Set<string>() };
|
|
current.rows.push(row.rowNumber);
|
|
for (const issue of row.issues ?? []) current.issues.add(issue);
|
|
if (row.status !== 'READY') current.issues.add(`SOURCE_ROW_${row.status}`);
|
|
if (value) current.values.add(value);
|
|
map.set(key, current);
|
|
};
|
|
const addIssues = (target: Set<string>, row: ImportSourceRow) => {
|
|
for (const issue of row.issues ?? []) target.add(issue);
|
|
if (row.status !== 'READY') target.add(`SOURCE_ROW_${row.status}`);
|
|
};
|
|
|
|
for (const row of rows) {
|
|
const area = String(row.normalizedData.area ?? '').trim();
|
|
const field = String(row.normalizedData.field ?? '').trim();
|
|
const operator = String(row.normalizedData.operator ?? '').trim();
|
|
const department = String(row.normalizedData.department ?? '').trim();
|
|
const sourceRightType = String(row.normalizedData.rightType ?? '').trim();
|
|
const areaKey = plainImportKey(area);
|
|
const fieldKeyPart = plainImportKey(field);
|
|
const departmentKey = plainImportKey(department);
|
|
const operatorUnassigned = isExplicitlyUnassignedOperator(operator);
|
|
const operatorKey = operatorUnassigned ? '' : organizationImportKey(operator);
|
|
const normalizedRight = normalizedLegalRightType(sourceRightType);
|
|
const rightKey = normalizedRight ? `${areaKey}\u001f${normalizedRight}` : '';
|
|
|
|
if (departmentKey) add(departments, departmentKey, department, row);
|
|
if (areaKey) add(areas, areaKey, area, row);
|
|
if (operatorKey) add(organizations, operatorKey, operator, row);
|
|
|
|
if (areaKey && departmentKey) {
|
|
const key = `${areaKey}\u001f${departmentKey}`;
|
|
const current = areaDepartments.get(key) ?? { areaKey, departmentKey, rows: [], issues: new Set<string>() };
|
|
current.rows.push(row.rowNumber); addIssues(current.issues, row); areaDepartments.set(key, current);
|
|
}
|
|
|
|
if (areaKey && normalizedRight) {
|
|
const current = legalRights.get(rightKey) ?? { areaKey, rightType: normalizedRight, sourceRightTypes: new Set<string>(), rows: [], issues: new Set<string>() };
|
|
current.rows.push(row.rowNumber); addIssues(current.issues, row); if (sourceRightType) current.sourceRightTypes.add(sourceRightType);
|
|
if (normalizedRight === 'OTHER') current.issues.add('PLAN_LEGAL_RIGHT_TYPE_UNRECOGNIZED');
|
|
legalRights.set(rightKey, current);
|
|
} else if (areaKey) {
|
|
const syntheticKey = `${areaKey}\u001fmissing`;
|
|
const current = legalRights.get(syntheticKey) ?? { areaKey, rightType: 'OTHER', sourceRightTypes: new Set<string>(), rows: [], issues: new Set<string>() };
|
|
current.rows.push(row.rowNumber); addIssues(current.issues, row); current.issues.add('PLAN_LEGAL_RIGHT_TYPE_MISSING');
|
|
legalRights.set(syntheticKey, current);
|
|
}
|
|
|
|
if (areaKey && fieldKeyPart) {
|
|
const key = `${areaKey}\u001f${fieldKeyPart}`;
|
|
const current = fields.get(key) ?? {
|
|
name: field, areaKey, rows: [], issues: new Set<string>(), values: new Set<string>(), departmentKeys: new Set<string>(), rightKeys: new Set<string>(), operatorKeys: new Set<string>(), operatorUnassigned: false, operatorSourceValues: new Set<string>(),
|
|
};
|
|
current.rows.push(row.rowNumber); addIssues(current.issues, row);
|
|
if (departmentKey) current.departmentKeys.add(departmentKey);
|
|
if (rightKey) current.rightKeys.add(rightKey);
|
|
if (operatorKey) current.operatorKeys.add(operatorKey);
|
|
if (operator) current.operatorSourceValues.add(operator);
|
|
if (operatorUnassigned) current.operatorUnassigned = true;
|
|
fields.set(key, current);
|
|
}
|
|
|
|
if (areaKey && operatorKey) {
|
|
const key = `${areaKey}\u001f${operatorKey}`;
|
|
const current = operatorRelations.get(key) ?? { areaKey, organizationKey: operatorKey, rows: [], issues: new Set<string>(), rightTypes: new Set<string>() };
|
|
current.rows.push(row.rowNumber); addIssues(current.issues, row); if (sourceRightType) current.rightTypes.add(sourceRightType); operatorRelations.set(key, current);
|
|
if (rightKey) {
|
|
const legalOrgKey = `${rightKey}\u001f${operatorKey}`;
|
|
const legalOrg = legalRightOrganizations.get(legalOrgKey) ?? { legalRightKey: rightKey, organizationKey: operatorKey, rows: [], issues: new Set<string>() };
|
|
legalOrg.rows.push(row.rowNumber); addIssues(legalOrg.issues, row); legalRightOrganizations.set(legalOrgKey, legalOrg);
|
|
}
|
|
}
|
|
}
|
|
|
|
const areaNames = [...areas.keys()];
|
|
const orgNames = [...organizations.keys()];
|
|
const fieldNames = [...new Set([...fields.values()].map((item) => plainImportKey(item.name)))];
|
|
const existingAreas = await this.assetNameMatches(manager, 'AREA', areaNames);
|
|
const existingOrganizations = await this.organizationNameMatches(manager, orgNames);
|
|
|
|
const existingDepartments = new Map<string, { id: string; name: string; code: string }[]>();
|
|
if (departments.size) {
|
|
const found = (await manager.query(`SELECT id,name,code,normalized_name AS "normalizedName" FROM administrative_departments WHERE province_code='MENDOZA' AND is_active=true AND normalized_name=ANY($1::text[])`, [[...departments.keys()]])) as Array<{id:string;name:string;code:string;normalizedName:string}>;
|
|
for (const item of found) { const list = existingDepartments.get(item.normalizedName) ?? []; list.push(item); existingDepartments.set(item.normalizedName, list); }
|
|
}
|
|
|
|
const existingFields = new Map<string, AssetMatch[]>();
|
|
if (areaNames.length && fieldNames.length) {
|
|
const wanted = new Set(fields.keys());
|
|
const result = (await manager.query(`
|
|
SELECT field.id,field.code,field.name,type.code AS "typeCode",field.parent_id AS "parentId",field.operational_area_id AS "operationalAreaId",field.operator_company_id AS "operatorCompanyId",area.name AS "areaName"
|
|
FROM assets field JOIN asset_types type ON type.id=field.asset_type_id JOIN assets area ON area.id=field.parent_id
|
|
WHERE type.code='yacimiento' AND field.information_status<>'INACTIVE' AND area.information_status<>'INACTIVE'
|
|
`)) as Array<AssetMatch & { areaName: string }>;
|
|
for (const item of result) { const key = `${plainImportKey(item.areaName)}\u001f${plainImportKey(item.name)}`; if (!wanted.has(key)) continue; const list = existingFields.get(key) ?? []; list.push(item); existingFields.set(key, list); }
|
|
}
|
|
|
|
const items: AssetImportPlanDraftItem[] = [];
|
|
const departmentItemByKey = new Map<string, AssetImportPlanDraftItem>();
|
|
const areaItemByKey = new Map<string, AssetImportPlanDraftItem>();
|
|
const orgItemByKey = new Map<string, AssetImportPlanDraftItem>();
|
|
const legalRightItemByKey = new Map<string, AssetImportPlanDraftItem>();
|
|
|
|
for (const [key, aggregate] of [...departments.entries()].sort(([a],[b]) => a.localeCompare(b))) {
|
|
const matches = existingDepartments.get(key) ?? [];
|
|
const reviews = [...aggregate.issues]; if (matches.length > 1) reviews.push('PLAN_MULTIPLE_DEPARTMENTS');
|
|
const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : matches.length === 1 ? 'MATCH' : 'CREATE';
|
|
const entityKey = `department:${key}`;
|
|
const item: AssetImportPlanDraftItem = {
|
|
entityKey, entityKind: 'DEPARTMENT', action, status: action === 'REVIEW' ? 'REVIEW' : action === 'MATCH' ? 'MATCHED' : 'PLANNED', assetTypeCode: null,
|
|
displayName: aggregate.name, generatedCode: null, parentEntityKey: null, matchedAssetId: null,
|
|
payload: { provinceCode: 'MENDOZA', departmentCode: departmentCode(aggregate.name), normalizedName: key, matchedDepartmentId: matches.length === 1 ? matches[0]!.id : null, sourceDocumentId: batch.sourceDocumentId, manualCreateAllowed: matches.length <= 1 },
|
|
sourceRowNumbers: [...new Set(aggregate.rows)].sort((a,b)=>a-b), reviewCodes: [...new Set(reviews)],
|
|
};
|
|
items.push(item); departmentItemByKey.set(key, item);
|
|
}
|
|
|
|
for (const [key, aggregate] of [...organizations.entries()].sort(([a],[b]) => a.localeCompare(b))) {
|
|
const matches = existingOrganizations.get(key) ?? [];
|
|
const reviews = [...aggregate.issues]; if (matches.length > 1) reviews.push('PLAN_MULTIPLE_ORGANIZATIONS');
|
|
const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : matches.length === 1 ? 'MATCH' : 'CREATE';
|
|
const entityKey = `organization:${key}`;
|
|
const item: AssetImportPlanDraftItem = { entityKey, entityKind:'ORGANIZATION', action, status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED', assetTypeCode:'empresa', displayName:aggregate.name, generatedCode:action==='CREATE'?generatedImportCode(planId,'empresa',entityKey):null, parentEntityKey:null, matchedAssetId:matches.length===1?matches[0]!.id:null, payload:{legalName:aggregate.name,organizationKind:/^UTE\b/i.test(aggregate.name.trim())?'UTE':'COMPANY',sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:matches.length<=1}, sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b), reviewCodes:[...new Set(reviews)] };
|
|
items.push(item); orgItemByKey.set(key,item);
|
|
}
|
|
|
|
for (const [key, aggregate] of [...areas.entries()].sort(([a],[b]) => a.localeCompare(b))) {
|
|
const matches = existingAreas.get(key) ?? [];
|
|
const reviews = [...aggregate.issues]; if (matches.length > 1) reviews.push('PLAN_MULTIPLE_AREAS');
|
|
const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : matches.length === 1 ? 'MATCH' : 'CREATE';
|
|
const entityKey = `area:${key}`;
|
|
const item: AssetImportPlanDraftItem = { entityKey,entityKind:'AREA',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:'area',displayName:aggregate.name,generatedCode:action==='CREATE'?generatedImportCode(planId,'area',entityKey):null,parentEntityKey:null,matchedAssetId:matches.length===1?matches[0]!.id:null,payload:{sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:matches.length<=1},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)] };
|
|
items.push(item); areaItemByKey.set(key,item);
|
|
}
|
|
|
|
const matchedAreaIds = [...areaItemByKey.values()].map(i=>i.matchedAssetId).filter((v):v is string=>Boolean(v));
|
|
const matchedOrgIds = [...orgItemByKey.values()].map(i=>i.matchedAssetId).filter((v):v is string=>Boolean(v));
|
|
const matchedDepartmentIds = [...departmentItemByKey.values()].map(item=>String(item.payload.matchedDepartmentId??'')).filter(Boolean);
|
|
|
|
const existingAreaDepartmentPairs = new Map<string,string>();
|
|
if (matchedAreaIds.length && matchedDepartmentIds.length) {
|
|
const found = (await manager.query(`SELECT id,area_id AS "areaId",department_id AS "departmentId" FROM area_department_relations WHERE area_id=ANY($1::uuid[]) AND department_id=ANY($2::uuid[]) AND valid_until IS NULL`, [matchedAreaIds, matchedDepartmentIds])) as Array<{id:string;areaId:string;departmentId:string}>;
|
|
for (const relation of found) existingAreaDepartmentPairs.set(`${relation.areaId}\u001f${relation.departmentId}`, relation.id);
|
|
}
|
|
|
|
for (const [key, aggregate] of [...areaDepartments.entries()].sort(([a],[b])=>a.localeCompare(b))) {
|
|
const areaItem = areaItemByKey.get(aggregate.areaKey); const departmentItem = departmentItemByKey.get(aggregate.departmentKey); if (!areaItem || !departmentItem) continue;
|
|
const reviews = [...aggregate.issues]; if (areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED'); if (departmentItem.action==='REVIEW') reviews.push('PLAN_DEPARTMENT_REVIEW_REQUIRED');
|
|
const departmentId = String(departmentItem.payload.matchedDepartmentId ?? '') || null;
|
|
const relationId = areaItem.matchedAssetId && departmentId ? existingAreaDepartmentPairs.get(`${areaItem.matchedAssetId}\u001f${departmentId}`) ?? null : null;
|
|
const action: AssetImportPlanAction = reviews.length ? 'REVIEW' : relationId ? 'MATCH' : 'CREATE';
|
|
items.push({ entityKey:`area-department:${key}`, entityKind:'AREA_DEPARTMENT_RELATION', action, status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED', assetTypeCode:null, displayName:`${areaItem.displayName} ↔ ${departmentItem.displayName}`, generatedCode:null, parentEntityKey:null, matchedAssetId:null, payload:{areaEntityKey:areaItem.entityKey,departmentEntityKey:departmentItem.entityKey,matchedRelationId:relationId,sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0}, sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b), reviewCodes:[...new Set(reviews)] });
|
|
}
|
|
|
|
const existingOperatorPairs = new Map<string,string>();
|
|
if (matchedAreaIds.length && matchedOrgIds.length) {
|
|
const found = (await manager.query(`SELECT id,area_id AS "areaId",company_id AS "companyId" FROM area_company_relations WHERE area_id=ANY($1::uuid[]) AND company_id=ANY($2::uuid[]) AND relation_role='OPERATOR' AND valid_until IS NULL`,[matchedAreaIds,matchedOrgIds])) as Array<{id:string;areaId:string;companyId:string}>;
|
|
for(const relation of found) existingOperatorPairs.set(`${relation.areaId}\u001f${relation.companyId}`,relation.id);
|
|
}
|
|
for (const [key, aggregate] of [...operatorRelations.entries()].sort(([a],[b])=>a.localeCompare(b))) {
|
|
const areaItem=areaItemByKey.get(aggregate.areaKey); const orgItem=orgItemByKey.get(aggregate.organizationKey); if(!areaItem||!orgItem) continue;
|
|
const reviews=[...aggregate.issues]; if(areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED'); if(orgItem.action==='REVIEW') reviews.push('PLAN_ORGANIZATION_REVIEW_REQUIRED');
|
|
const relationId=areaItem.matchedAssetId&&orgItem.matchedAssetId?existingOperatorPairs.get(`${areaItem.matchedAssetId}\u001f${orgItem.matchedAssetId}`)??null:null;
|
|
const action:AssetImportPlanAction=reviews.length?'REVIEW':relationId?'MATCH':'CREATE';
|
|
items.push({entityKey:`operator-relation:${key}`,entityKind:'OPERATOR_RELATION',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:null,displayName:`${areaItem.displayName} ↔ ${orgItem.displayName}`,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{areaEntityKey:areaItem.entityKey,organizationEntityKey:orgItem.entityKey,matchedRelationId:relationId,rightTypes:[...aggregate.rightTypes].sort(),sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]});
|
|
}
|
|
|
|
const existingLegalRights = new Map<string,{id:string;areaId:string;rightType:string}[]>();
|
|
if (matchedAreaIds.length) {
|
|
const found = (await manager.query(`SELECT id,area_id AS "areaId",right_type AS "rightType" FROM area_legal_rights WHERE area_id=ANY($1::uuid[]) AND status IN ('ACTIVE','PENDING') AND (valid_until IS NULL OR valid_until>=CURRENT_DATE)`, [matchedAreaIds])) as Array<{id:string;areaId:string;rightType:string}>;
|
|
for (const right of found) { const key=`${right.areaId}\u001f${right.rightType}`; const list=existingLegalRights.get(key)??[]; list.push(right); existingLegalRights.set(key,list); }
|
|
}
|
|
for (const [key, aggregate] of [...legalRights.entries()].sort(([a],[b])=>a.localeCompare(b))) {
|
|
const areaItem=areaItemByKey.get(aggregate.areaKey); if(!areaItem) continue;
|
|
const reviews=[...aggregate.issues]; if(areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED');
|
|
const matches=areaItem.matchedAssetId ? (existingLegalRights.get(`${areaItem.matchedAssetId}\u001f${aggregate.rightType}`)??[]) : [];
|
|
if(matches.length>1) reviews.push('PLAN_MULTIPLE_LEGAL_RIGHTS');
|
|
const action:AssetImportPlanAction=reviews.length?'REVIEW':matches.length===1?'MATCH':'CREATE';
|
|
const entityKey=`legal-right:${key}`;
|
|
const sourceLabels=[...aggregate.sourceRightTypes].sort();
|
|
const displayName=`${legalRightTypeLabel(aggregate.rightType as 'EXPLOITATION_CONCESSION' | 'EXPLORATION_PERMIT' | 'TRANSPORT_CONCESSION' | 'OTHER')} · ${areaItem.displayName}`;
|
|
const item:AssetImportPlanDraftItem={entityKey,entityKind:'LEGAL_RIGHT',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:null,displayName,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{areaEntityKey:areaItem.entityKey,rightType:aggregate.rightType,sourceRightTypes:sourceLabels,legalRightName:displayName,status:'PENDING',matchedLegalRightId:matches.length===1?matches[0]!.id:null,sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]};
|
|
items.push(item); legalRightItemByKey.set(key,item);
|
|
}
|
|
|
|
const matchedLegalRightIds=[...legalRightItemByKey.values()].map(item=>String(item.payload.matchedLegalRightId??'')).filter(Boolean);
|
|
const existingLegalOrgPairs=new Map<string,string>();
|
|
if(matchedLegalRightIds.length&&matchedOrgIds.length){const found=(await manager.query(`SELECT id,right_id AS "rightId",organization_id AS "organizationId" FROM area_legal_right_organizations WHERE right_id=ANY($1::uuid[]) AND organization_id=ANY($2::uuid[]) AND role='OPERATOR' AND valid_until IS NULL`,[matchedLegalRightIds,matchedOrgIds])) as Array<{id:string;rightId:string;organizationId:string}>;for(const row of found)existingLegalOrgPairs.set(`${row.rightId}\u001f${row.organizationId}`,row.id);}
|
|
for(const [key, aggregate] of [...legalRightOrganizations.entries()].sort(([a],[b])=>a.localeCompare(b))){const rightItem=legalRightItemByKey.get(aggregate.legalRightKey);const orgItem=orgItemByKey.get(aggregate.organizationKey);if(!rightItem||!orgItem)continue;const reviews=[...aggregate.issues];if(rightItem.action==='REVIEW')reviews.push('PLAN_LEGAL_RIGHT_REVIEW_REQUIRED');if(orgItem.action==='REVIEW')reviews.push('PLAN_ORGANIZATION_REVIEW_REQUIRED');const rightId=String(rightItem.payload.matchedLegalRightId??'')||null;const relationId=rightId&&orgItem.matchedAssetId?existingLegalOrgPairs.get(`${rightId}\u001f${orgItem.matchedAssetId}`)??null:null;const action:AssetImportPlanAction=reviews.length?'REVIEW':relationId?'MATCH':'CREATE';items.push({entityKey:`legal-right-organization:${key}`,entityKind:'LEGAL_RIGHT_ORGANIZATION',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:null,displayName:`${rightItem.displayName} ↔ ${orgItem.displayName}`,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{legalRightEntityKey:rightItem.entityKey,organizationEntityKey:orgItem.entityKey,role:'OPERATOR',matchedRelationId:relationId,sourceDocumentId:batch.sourceDocumentId,manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]});}
|
|
|
|
for (const [key,aggregate] of [...fields.entries()].sort(([a],[b])=>a.localeCompare(b))) {
|
|
const areaItem=areaItemByKey.get(aggregate.areaKey); if(!areaItem) continue;
|
|
const fieldCandidates=areaItem.action==='MATCH'?(existingFields.get(key)??[]):[];
|
|
const reviews=[...aggregate.issues]; if(areaItem.action==='REVIEW') reviews.push('PLAN_AREA_REVIEW_REQUIRED');
|
|
const departmentItems=[...aggregate.departmentKeys].map(k=>departmentItemByKey.get(k)).filter((v):v is AssetImportPlanDraftItem=>Boolean(v));
|
|
if(departmentItems.some(item=>item.action==='REVIEW')) reviews.push('PLAN_DEPARTMENT_REVIEW_REQUIRED');
|
|
const rightItems=[...aggregate.rightKeys].map(k=>legalRightItemByKey.get(k)).filter((v):v is AssetImportPlanDraftItem=>Boolean(v));
|
|
if(rightItems.some(item=>item.action==='REVIEW')) reviews.push('PLAN_LEGAL_RIGHT_REVIEW_REQUIRED');
|
|
const operatorItems=[...aggregate.operatorKeys].map(operatorKey=>orgItemByKey.get(operatorKey)).filter((value):value is AssetImportPlanDraftItem=>Boolean(value));
|
|
const uniqueOperatorItems=[...new Map(operatorItems.map(item=>[item.entityKey,item])).values()];
|
|
const explicitlyUnassigned=aggregate.operatorUnassigned&&uniqueOperatorItems.length===0;
|
|
if(uniqueOperatorItems.length>1||(aggregate.operatorUnassigned&&uniqueOperatorItems.length>0))reviews.push('PLAN_FIELD_OPERATOR_AMBIGUOUS');
|
|
if(uniqueOperatorItems.length===0&&!explicitlyUnassigned)reviews.push('PLAN_FIELD_OPERATOR_MISSING');
|
|
if(uniqueOperatorItems[0]?.action==='REVIEW')reviews.push('PLAN_ORGANIZATION_REVIEW_REQUIRED');
|
|
let matches=fieldCandidates;
|
|
if(fieldCandidates.length){if(explicitlyUnassigned)matches=fieldCandidates.filter(candidate=>!candidate.operatorCompanyId);else if(uniqueOperatorItems.length===1&&uniqueOperatorItems[0]!.action==='MATCH'&&uniqueOperatorItems[0]!.matchedAssetId)matches=fieldCandidates.filter(candidate=>candidate.operatorCompanyId===uniqueOperatorItems[0]!.matchedAssetId);else matches=[];if(matches.length===0)reviews.push('PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH');}
|
|
if(matches.length>1)reviews.push('PLAN_MULTIPLE_FIELDS');
|
|
const action:AssetImportPlanAction=reviews.length?'REVIEW':matches.length===1?'MATCH':'CREATE';
|
|
const entityKey=`field:${key}`;const contextMismatch=reviews.includes('PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH');
|
|
items.push({entityKey,entityKind:'FIELD',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:'yacimiento',displayName:aggregate.name,generatedCode:action==='CREATE'?generatedImportCode(planId,'yacimiento',entityKey):null,parentEntityKey:areaItem.entityKey,matchedAssetId:matches.length===1?matches[0]!.id:null,payload:{areaEntityKey:areaItem.entityKey,operationalAreaEntityKey:explicitlyUnassigned?null:areaItem.entityKey,operatorEntityKey:uniqueOperatorItems[0]?.entityKey??null,operatorAssignmentStatus:explicitlyUnassigned?'UNASSIGNED_SOURCE':'ASSIGNED',operatorSourceValues:[...aggregate.operatorSourceValues].sort(),departmentEntityKeys:departmentItems.map(item=>item.entityKey),legalRightEntityKeys:rightItems.map(item=>item.entityKey),sourceDocumentId:batch.sourceDocumentId,manualMatchAllowed:!contextMismatch,manualCreateAllowed:reviews.every(code=>code.startsWith('SOURCE_ROW_'))},sourceRowNumbers:[...new Set(aggregate.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
private async buildInventoryPlan(manager: EntityManager, planId: string, batch: ImportBatchRow, rows: ImportSourceRow[], operatorAssetId: string, namespace: string): Promise<AssetImportPlanDraftItem[]> {
|
|
const operatedAreas=(await manager.query(`
|
|
SELECT area.id,area.code,area.name,type.code AS "typeCode",area.parent_id AS "parentId",area.operational_area_id AS "operationalAreaId",area.operator_company_id AS "operatorCompanyId"
|
|
FROM area_company_relations relation JOIN assets area ON area.id=relation.area_id JOIN asset_types type ON type.id=area.asset_type_id
|
|
WHERE relation.company_id=$1 AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL AND area.information_status<>'INACTIVE'
|
|
ORDER BY area.name
|
|
`,[operatorAssetId])) as AssetMatch[];
|
|
if(!operatedAreas.length) throw new ConflictException({code:'ASSET_IMPORT_OPERATOR_WITHOUT_AREAS',message:'La organización seleccionada no tiene áreas operadas activas. Importá o configurá primero la relación Área ↔ Operadora.'});
|
|
const areaIds=operatedAreas.map(area=>area.id);
|
|
const areaMap=new Map<string,AssetMatch[]>();
|
|
for(const area of operatedAreas){const key=plainImportKey(area.name);const list=areaMap.get(key)??[];list.push(area);areaMap.set(key,list);}
|
|
const lookups=[...new Set(rows.map(row=>plainImportKey(row.normalizedData.areaOrField)).filter(Boolean))];
|
|
const lookupSet=new Set(lookups);
|
|
type ContextCandidate=AssetMatch & {parentName?:string|null};
|
|
const fieldMap=new Map<string,ContextCandidate[]>();
|
|
if(lookups.length){
|
|
const found=(await manager.query(`
|
|
SELECT field.id,field.code,field.name,type.code AS "typeCode",field.parent_id AS "parentId",field.operational_area_id AS "operationalAreaId",field.operator_company_id AS "operatorCompanyId",area.name AS "parentName"
|
|
FROM assets field JOIN asset_types type ON type.id=field.asset_type_id LEFT JOIN assets area ON area.id=field.parent_id
|
|
WHERE type.code='yacimiento' AND field.information_status<>'INACTIVE' AND field.parent_id=ANY($1::uuid[])
|
|
`,[areaIds])) as ContextCandidate[];
|
|
for(const field of found){const key=plainImportKey(field.name);if(!lookupSet.has(key))continue;const list=fieldMap.get(key)??[];list.push(field);fieldMap.set(key,list);}
|
|
}
|
|
const inventoryIds=[...new Set(rows.map(row=>String(row.normalizedData.inventoryId??'').trim().toLowerCase()).filter(Boolean))];
|
|
const inventoryIdCounts=new Map<string,number>();
|
|
for(const row of rows){const key=plainImportKey(row.normalizedData.inventoryId);if(key)inventoryIdCounts.set(key,(inventoryIdCounts.get(key)??0)+1);}
|
|
const namespaceMatches=new Map<string,AssetMatch[]>();
|
|
if(inventoryIds.length){
|
|
const found=(await manager.query(`
|
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId",lower(identifier.value) AS lookup
|
|
FROM asset_external_identifiers identifier JOIN assets asset ON asset.id=identifier.asset_id JOIN asset_types type ON type.id=asset.asset_type_id
|
|
WHERE identifier.namespace=$1 AND identifier.valid_until IS NULL AND lower(identifier.value)=ANY($2::text[]) AND asset.information_status<>'INACTIVE'
|
|
`,[namespace,inventoryIds])) as Array<AssetMatch & {lookup:string}>;
|
|
for(const asset of found){const list=namespaceMatches.get(asset.lookup)??[];list.push(asset);namespaceMatches.set(asset.lookup,list);}
|
|
}
|
|
|
|
const rawOverrides=batch.analysis?.inventoryContextOverrides;
|
|
const contextOverrides=(rawOverrides&&typeof rawOverrides==='object'&&!Array.isArray(rawOverrides)?rawOverrides:{}) as Record<string,unknown>;
|
|
type Context = { row:ImportSourceRow; anchor:ContextCandidate|null; areaId:string|null; contextKey:string|null; contextItem:AssetImportPlanDraftItem|null; reviewCodes:string[] };
|
|
const contextItems=new Map<string,AssetImportPlanDraftItem>();
|
|
const contextDecisionItems=new Map<string,AssetImportPlanDraftItem>();
|
|
const contexts:Context[]=[];
|
|
for(const row of rows){
|
|
const lookup=plainImportKey(row.normalizedData.areaOrField);
|
|
const fields=fieldMap.get(lookup)??[];
|
|
const areas=(areaMap.get(lookup)??[]) as ContextCandidate[];
|
|
const candidates:ContextCandidate[]=fields.length?fields:areas;
|
|
const reviews:string[]=[];
|
|
let anchor:ContextCandidate|null=null;
|
|
const overrideId=String(contextOverrides[lookup]??'').trim();
|
|
if(overrideId){
|
|
anchor=candidates.find(candidate=>candidate.id===overrideId)??null;
|
|
if(!anchor)reviews.push('PLAN_TERRITORY_CONTEXT_REQUIRED');
|
|
}
|
|
if(!anchor){
|
|
if(!overrideId&&candidates.length===1)anchor=candidates[0]!;
|
|
else if(candidates.length!==1||Boolean(overrideId)){
|
|
reviews.push('PLAN_TERRITORY_CONTEXT_REQUIRED');
|
|
const decisionKey=`inventory-context:${lookup}`;
|
|
const existing=contextDecisionItems.get(decisionKey);
|
|
const candidateOptions=candidates.map(candidate=>({id:candidate.id,code:candidate.code,name:candidate.name,typeCode:candidate.typeCode,parentName:candidate.parentName??null}));
|
|
const decisionCode=overrideId?'PLAN_TERRITORY_OVERRIDE_STALE':candidates.length?'PLAN_TERRITORY_AMBIGUOUS':'PLAN_TERRITORY_NOT_FOUND';
|
|
const decision=existing??{entityKey:decisionKey,entityKind:fields.length?'FIELD':'AREA',action:'REVIEW',status:'REVIEW',assetTypeCode:fields.length?'yacimiento':'area',displayName:String(row.normalizedData.areaOrField??'Contexto territorial'),generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{inventoryContextDecision:true,contextLookup:lookup,candidateOptions,operatorAssetId,manualMatchAllowed:candidateOptions.length>0,manualCreateAllowed:false},sourceRowNumbers:[],reviewCodes:[decisionCode]};
|
|
decision.sourceRowNumbers.push(row.rowNumber);contextDecisionItems.set(decisionKey,decision);
|
|
}
|
|
}
|
|
let contextItem:AssetImportPlanDraftItem|null=null;
|
|
let contextKey:string|null=null;
|
|
let areaId:string|null=null;
|
|
if(anchor){
|
|
areaId=anchor.typeCode==='area'?anchor.id:(anchor.operationalAreaId??anchor.parentId);
|
|
contextKey=`context:${anchor.typeCode}:${anchor.id}`;
|
|
contextItem=contextItems.get(contextKey)??{entityKey:contextKey,entityKind:anchor.typeCode==='area'?'AREA':'FIELD',action:'MATCH',status:'MATCHED',assetTypeCode:anchor.typeCode,displayName:anchor.name,generatedCode:null,parentEntityKey:null,matchedAssetId:anchor.id,payload:{operationalAreaAssetId:areaId,operatorAssetId,contextOnly:true,sourceContextOverride:Boolean(overrideId)},sourceRowNumbers:[],reviewCodes:[]};
|
|
contextItem.sourceRowNumbers.push(row.rowNumber);contextItems.set(contextKey,contextItem);
|
|
}
|
|
contexts.push({row,anchor,areaId,contextKey,contextItem,reviewCodes:reviews});
|
|
}
|
|
|
|
type LocalStructureSpec={key:string;name:string;parentKey:string;parentAssetId:string;areaId:string;rows:number[];sourceInstallation:string|null;sourceSubInstallation:string|null;sourcePath:string[];concreteFromLocation:boolean;sourceGroupOnly:boolean;reviewCodes:string[]};
|
|
const localStructures=new Map<string,LocalStructureSpec>();
|
|
const rowLocalStructureKeys=new Map<number,string>();
|
|
for(const context of contexts){
|
|
if(!context.anchor||!context.contextKey||!context.areaId)continue;
|
|
const suggestion=sourceLocalStructureSuggestion(context.row.normalizedData.areaOrField,context.row.normalizedData.installation,context.row.normalizedData.subInstallation,context.row.normalizedData.location);
|
|
if(!suggestion)continue;
|
|
const hierarchyKey=suggestion.concreteFromLocation
|
|
? plainImportKey(suggestion.displayName)
|
|
: [plainImportKey(suggestion.sourceInstallation),plainImportKey(suggestion.sourceSubInstallation)].filter(Boolean).join(':');
|
|
const key=`local-structure:${operatorAssetId}:${context.anchor.id}:${hierarchyKey}`;
|
|
const spec=localStructures.get(key)??{key,name:suggestion.displayName,parentKey:context.contextKey,parentAssetId:context.anchor.id,areaId:context.areaId,rows:[],sourceInstallation:suggestion.sourceInstallation,sourceSubInstallation:suggestion.sourceSubInstallation,sourcePath:suggestion.sourcePath,concreteFromLocation:suggestion.concreteFromLocation,sourceGroupOnly:suggestion.sourceGroupOnly,reviewCodes:[]};
|
|
spec.rows.push(context.row.rowNumber);localStructures.set(key,spec);rowLocalStructureKeys.set(context.row.rowNumber,key);
|
|
}
|
|
const localMatches=new Map<string,AssetMatch[]>();
|
|
if(localStructures.size){
|
|
const parentIds=[...new Set([...localStructures.values()].map(item=>item.parentAssetId))];
|
|
const wanted=new Set([...localStructures.values()].map(item=>`local:${item.parentAssetId}:${plainImportKey(item.name)}`));
|
|
if(parentIds.length){
|
|
const found=(await manager.query(`
|
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId"
|
|
FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id
|
|
WHERE asset.parent_id=ANY($1::uuid[]) AND type.code='estructura_local' AND asset.operator_company_id=$2 AND asset.information_status<>'INACTIVE'
|
|
`,[parentIds,operatorAssetId])) as AssetMatch[];
|
|
for(const asset of found){const key=`local:${asset.parentId}:${plainImportKey(asset.name)}`;if(!wanted.has(key))continue;const list=localMatches.get(key)??[];list.push(asset);localMatches.set(key,list);}
|
|
}
|
|
}
|
|
|
|
const items:AssetImportPlanDraftItem[]=[...contextItems.values(),...contextDecisionItems.values()].map(item=>({...item,sourceRowNumbers:[...new Set(item.sourceRowNumbers)].sort((a,b)=>a-b)}));
|
|
const localItemByKey=new Map<string,AssetImportPlanDraftItem>();
|
|
for(const spec of [...localStructures.values()].sort((a,b)=>a.key.localeCompare(b.key))){
|
|
const matchKey=`local:${spec.parentAssetId}:${plainImportKey(spec.name)}`;
|
|
const matches=localMatches.get(matchKey)??[];
|
|
const reviews=[...spec.reviewCodes];if(matches.length>1)reviews.push('PLAN_MULTIPLE_LOCAL_STRUCTURES');
|
|
const action:AssetImportPlanAction=reviews.length?'REVIEW':matches.length===1?'MATCH':'CREATE';
|
|
const item:AssetImportPlanDraftItem={entityKey:spec.key,entityKind:'LOCAL_STRUCTURE',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:'estructura_local',displayName:spec.name,generatedCode:action==='CREATE'?generatedImportCode(planId,'estructura_local',spec.key):null,parentEntityKey:spec.parentKey,matchedAssetId:matches.length===1?matches[0]!.id:null,payload:{operationalAreaAssetId:spec.areaId,operatorAssetId,sourceInstallation:spec.sourceInstallation,sourceSubInstallation:spec.sourceSubInstallation,sourcePath:spec.sourcePath,sourcePathText:spec.sourcePath.join(' / '),concreteFromLocation:spec.concreteFromLocation,sourceGroupOnly:spec.sourceGroupOnly,sourceDocumentId:batch.sourceDocumentId,localNomenclature:true,classificationStatus:'SOURCE_PRESERVED',manualCreateAllowed:reviews.length===0},sourceRowNumbers:[...new Set(spec.rows)].sort((a,b)=>a-b),reviewCodes:[...new Set(reviews)]};
|
|
items.push(item);localItemByKey.set(spec.key,item);
|
|
}
|
|
|
|
const directAnchorTypes=new Set(['pozo','ducto','colector']);
|
|
for(const context of contexts){
|
|
const row=context.row;
|
|
const inventoryId=String(row.normalizedData.inventoryId??'').trim();
|
|
const typeCode=technicalFamilyTypeCode(row.normalizedData.normalizedFamily,row.normalizedData.normalizedSubtype);
|
|
const displayName=inventoryId?`${String(row.normalizedData.equipment??'Activo').trim()} · ${inventoryId}`:String(row.normalizedData.equipment??'Activo sin identificar');
|
|
const inventoryKey=plainImportKey(inventoryId);
|
|
const entityKey=inventoryKey&&inventoryIdCounts.get(inventoryKey)===1?`technical:${namespace}:${inventoryKey}`:`technical:${namespace}:${inventoryKey||'sin-id'}:row-${row.rowNumber}`;
|
|
const reviews=[...context.reviewCodes];
|
|
if(inventoryId.length>180)reviews.push('PLAN_INVENTORY_ID_TOO_LONG');
|
|
const exact=inventoryId?(namespaceMatches.get(inventoryId.toLowerCase())??[]):[];
|
|
if(exact.length>1)reviews.push('PLAN_MULTIPLE_NAMESPACE_MATCHES');
|
|
if(exact.length===1&&(exact[0]!.operationalAreaId!==context.areaId||exact[0]!.operatorCompanyId!==operatorAssetId))reviews.push('PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH');
|
|
if(!exact.length&&row.matchedAssetId)reviews.push('PLAN_GLOBAL_MATCH_REQUIRES_NAMESPACE');
|
|
if(row.status==='CONFLICT'||row.status==='WARNING')reviews.push(...(row.issues??[]),`SOURCE_ROW_${row.status}`);
|
|
if(row.status==='IGNORED'){
|
|
items.push({entityKey,entityKind:'TECHNICAL_ASSET',action:'IGNORE',status:'IGNORED',assetTypeCode:typeCode,displayName,generatedCode:null,parentEntityKey:null,matchedAssetId:null,payload:{...row.normalizedData,externalIdNamespace:namespace,operatorAssetId,manualCreateAllowed:false},sourceRowNumbers:[row.rowNumber],reviewCodes:[...new Set(reviews)]});
|
|
continue;
|
|
}
|
|
let parentKey=context.contextKey;
|
|
if(!directAnchorTypes.has(typeCode)){
|
|
const key=rowLocalStructureKeys.get(row.rowNumber);
|
|
parentKey=key?localItemByKey.get(key)?.entityKey??null:null;
|
|
if(context.anchor&&context.areaId&&!parentKey)reviews.push('PLAN_LOCAL_STRUCTURE_NOT_IDENTIFIED');
|
|
}
|
|
if(!context.anchor||!context.areaId){
|
|
if(reviews.includes('PLAN_TERRITORY_CONTEXT_REQUIRED'))reviews.push('PLAN_CONTEXT_DECISION_REQUIRED');
|
|
else reviews.push('PLAN_PARENT_NOT_RESOLVED');
|
|
} else if(!parentKey)reviews.push('PLAN_PARENT_NOT_RESOLVED');
|
|
const action:AssetImportPlanAction=reviews.length?'REVIEW':exact.length===1?'MATCH':'CREATE';
|
|
items.push({entityKey,entityKind:'TECHNICAL_ASSET',action,status:action==='REVIEW'?'REVIEW':action==='MATCH'?'MATCHED':'PLANNED',assetTypeCode:typeCode,displayName,generatedCode:action==='CREATE'?generatedImportCode(planId,typeCode,entityKey):null,parentEntityKey:parentKey,matchedAssetId:exact.length===1?exact[0]!.id:null,payload:{...row.normalizedData,operationalAreaAssetId:context.areaId,operatorAssetId,externalIdNamespace:namespace,sourceDocumentId:batch.sourceDocumentId,localStructureEntityKey:parentKey&&parentKey.startsWith('local-structure:')?parentKey:null,manualMatchAllowed:!reviews.includes('PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH'),manualCreateAllowed:reviews.every(code=>code.startsWith('SOURCE_ROW_')||code==='GROUPED_QUANTITY'||code==='SOURCE_STATUS_REQUIRES_MAPPING'||code==='DUPLICATE_INVENTORY_ID_IN_BATCH')},sourceRowNumbers:[row.rowNumber],reviewCodes:[...new Set(reviews)]});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
|
|
private async assetNameMatches(manager:EntityManager,role:'AREA'|'COMPANY',keys:string[]):Promise<Map<string,AssetMatch[]>>{
|
|
const map=new Map<string,AssetMatch[]>();if(!keys.length)return map;const wanted=new Set(keys);
|
|
const rows=(await manager.query(`SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE type.operational_role=$1 AND type.is_active=true AND asset.information_status<>'INACTIVE'`,[role])) as AssetMatch[];
|
|
for(const row of rows){const key=plainImportKey(row.name);if(!wanted.has(key))continue;const list=map.get(key)??[];list.push(row);map.set(key,list);}return map;
|
|
}
|
|
|
|
private async organizationNameMatches(manager:EntityManager,keys:string[]):Promise<Map<string,AssetMatch[]>>{
|
|
const map=new Map<string,AssetMatch[]>();if(!keys.length)return map;const wanted=new Set(keys);
|
|
const rows=(await manager.query(`
|
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",asset.parent_id AS "parentId",asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId",profile.legal_name AS "legalName"
|
|
FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id
|
|
WHERE type.operational_role='COMPANY' AND type.is_active=true AND asset.information_status<>'INACTIVE'
|
|
`)) as Array<AssetMatch & {legalName:string|null}>;
|
|
for(const row of rows){
|
|
const candidateKeys=[organizationImportKey(row.name),organizationImportKey(row.legalName)].filter(Boolean);
|
|
for(const key of new Set(candidateKeys)){if(!wanted.has(key))continue;const list=map.get(key)??[];if(!list.some(item=>item.id===row.id))list.push(row);map.set(key,list);}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
private async insertPlanItems(manager: EntityManager, planId: string, items: AssetImportPlanDraftItem[]): Promise<void> {
|
|
for(let start=0;start<items.length;start+=200){const chunk=items.slice(start,start+200);const params:unknown[]=[];const values=chunk.map((item,index)=>{const base=params.length;params.push(planId,start+index+1,item.entityKey,item.entityKind,item.action,item.status,item.assetTypeCode,item.displayName,item.generatedCode,item.parentEntityKey,item.matchedAssetId,JSON.stringify(item.payload),JSON.stringify(item.sourceRowNumbers),JSON.stringify(item.reviewCodes));return `($${base+1}::uuid,$${base+2},$${base+3},$${base+4},$${base+5},$${base+6},$${base+7},$${base+8},$${base+9},$${base+10},$${base+11}::uuid,$${base+12}::jsonb,$${base+13}::jsonb,$${base+14}::jsonb)`;});await manager.query(`INSERT INTO asset_import_plan_items (plan_id,item_order,entity_key,entity_kind,action,status,asset_type_code,display_name,generated_code,parent_entity_key,matched_asset_id,payload,source_row_numbers,review_codes) VALUES ${values.join(',')}`,params);}
|
|
}
|
|
|
|
private async refreshPlan(manager:EntityManager,planId:string,summaryPatch:Record<string,unknown>={}){
|
|
const plan=await this.loadPlanById(manager,planId);const items=await this.loadPlanItems(manager,planId);
|
|
const drafts:AssetImportPlanDraftItem[]=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,reviewCodes:item.reviewCodes}));
|
|
const summary={...plan.summary,...summarizePlanItems(drafts),...summaryPatch,updatedAt:new Date().toISOString()};const status=planStatusForItems(drafts);const hash=planItemHash(drafts);
|
|
await manager.query(`UPDATE asset_import_plans SET status=$2,summary=$3::jsonb,plan_hash=$4,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[planId,status,JSON.stringify(summary),hash]);
|
|
await manager.query(`UPDATE asset_import_batches SET analysis=jsonb_set(analysis,'{importPlan}',$2::jsonb,true),updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[plan.batchId,JSON.stringify({planId,revision:plan.revision,status,planHash:hash,...summary})]);
|
|
return this.planView(manager,planId);
|
|
}
|
|
|
|
private async revalidateMatchedAssets(manager:EntityManager,items:PlanItemRow[]):Promise<void>{
|
|
const ids=[...new Set(items.filter(item=>item.action==='MATCH'&&item.matchedAssetId).map(item=>item.matchedAssetId!))];if(!ids.length)return;
|
|
const [row]=(await manager.query(`SELECT COUNT(*)::integer AS total FROM assets WHERE id=ANY($1::uuid[]) AND information_status<>'INACTIVE'`,[ids])) as Array<{total:number}>;
|
|
if(Number(row?.total??0)!==ids.length)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:'Uno o más activos coincidentes cambiaron o fueron inactivados. Regenerá el plan.'});
|
|
}
|
|
|
|
private async revalidateMatchedRelationalObjects(manager: EntityManager, items: PlanItemRow[]): Promise<void> {
|
|
const departmentIds = [...new Set(items.filter((item) => item.entityKind === 'DEPARTMENT' && item.action === 'MATCH').map((item) => String(item.payload.matchedDepartmentId ?? '')).filter(Boolean))];
|
|
if (departmentIds.length) {
|
|
const [row] = (await manager.query(`SELECT COUNT(*)::integer AS total FROM administrative_departments WHERE id=ANY($1::uuid[]) AND is_active=true`, [departmentIds])) as Array<{ total: number }>;
|
|
if (Number(row?.total ?? 0) !== departmentIds.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'Uno o más Departamentos del plan cambiaron. Regenerá el plan.' });
|
|
}
|
|
const rightIds = [...new Set(items.filter((item) => item.entityKind === 'LEGAL_RIGHT' && item.action === 'MATCH').map((item) => String(item.payload.matchedLegalRightId ?? '')).filter(Boolean))];
|
|
if (rightIds.length) {
|
|
const [row] = (await manager.query(`SELECT COUNT(*)::integer AS total FROM area_legal_rights WHERE id=ANY($1::uuid[]) AND status IN ('ACTIVE','PENDING') AND (valid_until IS NULL OR valid_until>=CURRENT_DATE)`, [rightIds])) as Array<{ total: number }>;
|
|
if (Number(row?.total ?? 0) !== rightIds.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: 'Uno o más derechos del plan cambiaron. Regenerá el plan.' });
|
|
}
|
|
const relationChecks: Array<{ kind: AssetImportPlanEntityKind; table: string }> = [
|
|
{ kind: 'AREA_DEPARTMENT_RELATION', table: 'area_department_relations' },
|
|
{ kind: 'OPERATOR_RELATION', table: 'area_company_relations' },
|
|
{ kind: 'LEGAL_RIGHT_ORGANIZATION', table: 'area_legal_right_organizations' },
|
|
];
|
|
for (const check of relationChecks) {
|
|
const ids = [...new Set(items.filter((item) => item.entityKind === check.kind && item.action === 'MATCH').map((item) => String(item.payload.matchedRelationId ?? '')).filter(Boolean))];
|
|
if (!ids.length) continue;
|
|
const [row] = (await manager.query(`SELECT COUNT(*)::integer AS total FROM ${check.table} WHERE id=ANY($1::uuid[]) AND valid_until IS NULL`, [ids])) as Array<{ total: number }>;
|
|
if (Number(row?.total ?? 0) !== ids.length) throw new ConflictException({ code: 'ASSET_IMPORT_PLAN_STALE', message: `Una relación ${check.kind} cambió. Regenerá el plan.` });
|
|
}
|
|
}
|
|
|
|
private async revalidateExternalIdentifiers(manager:EntityManager,plan:ImportPlanRow,items:PlanItemRow[]):Promise<void>{
|
|
if(!plan.externalIdNamespace)return;
|
|
const values=[...new Set(items.filter(item=>item.action==='CREATE'&&item.status!=='APPLIED'&&item.entityKind==='TECHNICAL_ASSET').map(item=>String(item.payload.inventoryId??'').trim().toLowerCase()).filter(Boolean))];
|
|
if(!values.length)return;
|
|
const [hit]=(await manager.query(`SELECT identifier.value FROM asset_external_identifiers identifier JOIN assets asset ON asset.id=identifier.asset_id WHERE identifier.namespace=$1 AND identifier.valid_until IS NULL AND lower(identifier.value)=ANY($2::text[]) AND asset.information_status<>'INACTIVE' LIMIT 1`,[plan.externalIdNamespace,values])) as Array<{value:string}>;
|
|
if(hit)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`El identificador ${plan.externalIdNamespace}:${hit.value} apareció en el Maestro después de generar el plan. Regenerá el plan.`});
|
|
}
|
|
|
|
private async revalidateMatchedOperationalContext(manager:EntityManager,items:PlanItemRow[],getRef:(key:string|null|undefined)=>string|null):Promise<void>{
|
|
const matched=items.filter(item=>item.action==='MATCH'&&item.matchedAssetId&&['FIELD','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET'].includes(item.entityKind));
|
|
if(!matched.length)return;
|
|
const ids=[...new Set(matched.map(item=>item.matchedAssetId!))];
|
|
const rows=(await manager.query(`SELECT asset.id,asset.operational_area_id AS "operationalAreaId",asset.operator_company_id AS "operatorCompanyId" FROM assets asset WHERE asset.id=ANY($1::uuid[]) AND asset.information_status<>'INACTIVE'`,[ids])) as Array<{id:string;operationalAreaId:string|null;operatorCompanyId:string|null}>;
|
|
const byId=new Map(rows.map(row=>[row.id,row]));
|
|
for(const item of matched){
|
|
const current=byId.get(item.matchedAssetId!);if(!current)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`La coincidencia ${item.displayName} ya no está disponible`});
|
|
const areaKey=String(item.payload.operationalAreaEntityKey??'');const operatorKey=String(item.payload.operatorEntityKey??'');
|
|
const expectedArea=areaKey?getRef(areaKey):(String(item.payload.operationalAreaAssetId??'')||null);
|
|
const expectedOperator=operatorKey?getRef(operatorKey):(String(item.payload.operatorAssetId??'')||null);
|
|
const explicitlyUnassigned=item.payload.operatorAssignmentStatus==='UNASSIGNED_SOURCE';
|
|
if(explicitlyUnassigned){if(current.operationalAreaId!==null||current.operatorCompanyId!==null)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`${item.displayName} recibió contexto operativo después de generar el plan. Regenerá antes de aplicar.`});continue;}
|
|
if(expectedArea!==null||expectedOperator!==null){if(current.operationalAreaId!==expectedArea||current.operatorCompanyId!==expectedOperator)throw new ConflictException({code:'ASSET_IMPORT_PLAN_STALE',message:`El contexto operativo de ${item.displayName} cambió. Regenerá el plan.`});}
|
|
}
|
|
}
|
|
|
|
private async applyPlanItems(manager:EntityManager,batch:ImportBatchRow,plan:ImportPlanRow,items:PlanItemRow[],principal:AuthPrincipal,request:RequestWithContext):Promise<Record<string,unknown>>{
|
|
const assetKinds = new Set<AssetImportPlanEntityKind>(['ORGANIZATION','AREA','FIELD','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET']);
|
|
const resolved=new Map<string,{id:string;typeCode:string}>();
|
|
for(const item of items){if(item.status==='APPLIED'&&item.appliedAssetId&&assetKinds.has(item.entityKind)){resolved.set(item.entityKey,{id:item.appliedAssetId,typeCode:item.assetTypeCode??''});continue;}if(item.action==='MATCH'&&item.matchedAssetId&&assetKinds.has(item.entityKind)){resolved.set(item.entityKey,{id:item.matchedAssetId,typeCode:item.assetTypeCode??''});}}
|
|
const createAssetItems=items.filter(item=>item.action==='CREATE'&&item.status!=='APPLIED'&&assetKinds.has(item.entityKind));
|
|
for(const item of createAssetItems){resolved.set(item.entityKey,{id:randomUUID(),typeCode:item.assetTypeCode??''});}
|
|
|
|
const resolvedObjects=new Map<string,string>();
|
|
for(const item of items){
|
|
if(item.status==='APPLIED'&&item.appliedObjectId&&(item.entityKind==='DEPARTMENT'||item.entityKind==='LEGAL_RIGHT')){resolvedObjects.set(item.entityKey,item.appliedObjectId);continue;}
|
|
if(item.entityKind==='DEPARTMENT'&&item.action==='MATCH'){const id=String(item.payload.matchedDepartmentId??'');if(id)resolvedObjects.set(item.entityKey,id);}
|
|
if(item.entityKind==='LEGAL_RIGHT'&&item.action==='MATCH'){const id=String(item.payload.matchedLegalRightId??'');if(id)resolvedObjects.set(item.entityKey,id);}
|
|
}
|
|
for(const item of items){if(item.action!=='CREATE'||item.status==='APPLIED')continue;if(item.entityKind==='DEPARTMENT'||item.entityKind==='LEGAL_RIGHT')resolvedObjects.set(item.entityKey,randomUUID());}
|
|
|
|
const typeCodes=[...new Set(createAssetItems.map(item=>item.assetTypeCode).filter((value):value is string=>Boolean(value)))];
|
|
const typeRows=typeCodes.length?(await manager.query(`SELECT id,code,operational_role AS "operationalRole",is_active AS "isActive",can_be_root AS "canBeRoot" FROM asset_types WHERE code=ANY($1::text[])`,[typeCodes])) as Array<{id:string;code:string;operationalRole:string;isActive:boolean;canBeRoot:boolean}>:[];
|
|
const typeMap=new Map(typeRows.map(row=>[row.code,row]));
|
|
for(const code of typeCodes){const type=typeMap.get(code);if(!type||!type.isActive)throw new ConflictException({code:'ASSET_IMPORT_TYPE_NOT_AVAILABLE',message:`El tipo ${code} no existe o está inactivo`});}
|
|
const createdAssetIdSet=new Set(createAssetItems.map(item=>resolved.get(item.entityKey)!.id));
|
|
const matchedIds=[...new Set([...resolved.values()].map(value=>value.id).filter(id=>!createdAssetIdSet.has(id)))];
|
|
const matchedTypes=new Map<string,string>();if(matchedIds.length){const rows=(await manager.query(`SELECT asset.id,type.code FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id=ANY($1::uuid[])`,[matchedIds])) as Array<{id:string;code:string}>;for(const row of rows)matchedTypes.set(row.id,row.code);}
|
|
const getRef=(key:string|null|undefined)=>key?resolved.get(key)?.id??null:null;
|
|
const getObjectRef=(key:string|null|undefined)=>key?resolvedObjects.get(key)??null:null;
|
|
const getType=(key:string|null|undefined)=>{if(!key)return null;const ref=resolved.get(key);if(!ref)return null;return ref.typeCode||matchedTypes.get(ref.id)||null;};
|
|
await this.revalidateMatchedOperationalContext(manager,items,getRef);
|
|
|
|
const rootItems=createAssetItems.filter(item=>item.entityKind==='ORGANIZATION'||item.entityKind==='AREA');
|
|
const fieldItems=createAssetItems.filter(item=>item.entityKind==='FIELD');
|
|
const installationItems=createAssetItems.filter(item=>item.entityKind==='INSTALLATION'||item.entityKind==='LOCAL_STRUCTURE');
|
|
const technicalItems=createAssetItems.filter(item=>item.entityKind==='TECHNICAL_ASSET');
|
|
await this.insertAssetItems(manager,batch,rootItems,resolved,typeMap,getRef,getType,principal);
|
|
|
|
let createdDepartments=0;
|
|
for(const item of items.filter(item=>item.entityKind==='DEPARTMENT'&&item.action==='CREATE'&&item.status!=='APPLIED')){
|
|
const id=getObjectRef(item.entityKey);if(!id)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver Departamento ${item.displayName}`});
|
|
await manager.query(`INSERT INTO administrative_departments (id,province_code,code,name,normalized_name,source_document_id,created_by,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$7)`,[id,String(item.payload.provinceCode??'MENDOZA'),String(item.payload.departmentCode??departmentCode(item.displayName)),item.displayName.slice(0,160),String(item.payload.normalizedName??plainImportKey(item.displayName)),batch.sourceDocumentId,principal.userId]);
|
|
await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,id]);createdDepartments+=1;
|
|
}
|
|
|
|
let createdAreaDepartmentRelations=0;
|
|
for(const item of items.filter(item=>item.entityKind==='AREA_DEPARTMENT_RELATION'&&item.action==='CREATE'&&item.status!=='APPLIED')){
|
|
const areaId=getRef(String(item.payload.areaEntityKey??''));const departmentId=getObjectRef(String(item.payload.departmentEntityKey??''));if(!areaId||!departmentId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver ${item.displayName}`});
|
|
const [created]=await manager.query(`INSERT INTO area_department_relations (area_id,department_id,source_document_id,notes,created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id`,[areaId,departmentId,batch.sourceDocumentId,`Importación controlada · lote ${batch.id}`,principal.userId]);
|
|
await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,String(created.id)]);createdAreaDepartmentRelations+=1;
|
|
}
|
|
|
|
let createdOperatorRelations=0;
|
|
for(const item of items.filter(item=>item.entityKind==='OPERATOR_RELATION'&&item.action==='CREATE'&&item.status!=='APPLIED')){const areaId=getRef(String(item.payload.areaEntityKey??''));const organizationId=getRef(String(item.payload.organizationEntityKey??''));if(!areaId||!organizationId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver la relación ${item.displayName}`});const [created]=await manager.query(`INSERT INTO area_company_relations (area_id,company_id,start_reason,created_by,relation_role,source_document_id) VALUES ($1,$2,$3,$4,'OPERATOR',$5) RETURNING id`,[areaId,organizationId,`Importación controlada · lote ${batch.id}`,principal.userId,batch.sourceDocumentId]);await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,String(created.id)]);createdOperatorRelations+=1;}
|
|
|
|
let createdLegalRights=0;
|
|
for(const item of items.filter(item=>item.entityKind==='LEGAL_RIGHT'&&item.action==='CREATE'&&item.status!=='APPLIED')){
|
|
const id=getObjectRef(item.entityKey);const areaId=getRef(String(item.payload.areaEntityKey??''));if(!id||!areaId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver ${item.displayName}`});
|
|
const rightType=String(item.payload.rightType??'OTHER');
|
|
if(!['EXPLOITATION_CONCESSION','EXPLORATION_PERMIT','TRANSPORT_CONCESSION','OTHER'].includes(rightType))throw new ConflictException({code:'ASSET_IMPORT_LEGAL_RIGHT_TYPE_INVALID',message:`Tipo de derecho inválido para ${item.displayName}`});
|
|
await manager.query(`INSERT INTO area_legal_rights (id,area_id,right_type,name,status,source_document_id,notes,created_by,updated_by) VALUES ($1,$2,$3::area_legal_right_type,$4,'PENDING',$5,$6,$7,$7)`,[id,areaId,rightType,String(item.payload.legalRightName??item.displayName).slice(0,260),batch.sourceDocumentId,`Importado como PENDING: la fuente define tipo y Área pero no instrumento ni vigencia. Lote ${batch.id}.`,principal.userId]);
|
|
await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,id]);createdLegalRights+=1;
|
|
}
|
|
|
|
let createdLegalRightOrganizationRelations=0;
|
|
for(const item of items.filter(item=>item.entityKind==='LEGAL_RIGHT_ORGANIZATION'&&item.action==='CREATE'&&item.status!=='APPLIED')){
|
|
const rightId=getObjectRef(String(item.payload.legalRightEntityKey??''));const organizationId=getRef(String(item.payload.organizationEntityKey??''));if(!rightId||!organizationId)throw new ConflictException({code:'ASSET_IMPORT_PLAN_DEPENDENCY_MISSING',message:`No se pudo resolver ${item.displayName}`});
|
|
const [created]=await manager.query(`INSERT INTO area_legal_right_organizations (right_id,organization_id,role,notes,created_by) VALUES ($1,$2,'OPERATOR',$3,$4) RETURNING id`,[rightId,organizationId,`La fuente identifica a la organización como operadora; no se infiere titularidad. Lote ${batch.id}.`,principal.userId]);
|
|
await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_object_id=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,String(created.id)]);createdLegalRightOrganizationRelations+=1;
|
|
}
|
|
|
|
await this.insertAssetItems(manager,batch,fieldItems,resolved,typeMap,getRef,getType,principal);
|
|
await this.insertAssetItems(manager,batch,installationItems,resolved,typeMap,getRef,getType,principal);
|
|
await this.insertAssetItems(manager,batch,technicalItems,resolved,typeMap,getRef,getType,principal);
|
|
const createdIds=createAssetItems.map(item=>resolved.get(item.entityKey)!.id);
|
|
await this.insertSourceLinks(manager,batch,createAssetItems,resolved,principal.userId);
|
|
const createdIdentifiers=await this.insertExternalIdentifiers(manager,batch,technicalItems,resolved,plan.externalIdNamespace,principal.userId);
|
|
await this.insertKnownAttributes(manager,createAssetItems,resolved,typeMap,principal.userId);
|
|
await this.insertImportVersions(manager,createdIds,principal,request);
|
|
await this.updateImportedRows(manager,batch.id,items,resolved);
|
|
for(const item of createAssetItems){await manager.query(`UPDATE asset_import_plan_items SET status='APPLIED',applied_asset_id=$2::uuid,applied_object_id=($2::uuid)::text,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[item.id,resolved.get(item.entityKey)!.id]);}
|
|
const createdByKind:Record<string,number>={};for(const item of items.filter(item=>item.action==='CREATE'&&item.status!=='APPLIED'))createdByKind[item.entityKind]=(createdByKind[item.entityKind]??0)+1;
|
|
return {createdAssets:createdIds.length,createdDepartments,createdAreaDepartmentRelations,createdOperatorRelations,createdLegalRights,createdLegalRightOrganizationRelations,createdExternalIdentifiers:createdIdentifiers,matchedItems:items.filter(item=>item.action==='MATCH').length,ignoredItems:items.filter(item=>item.action==='IGNORE').length,createdByKind,sourceRowsLinked:items.filter(item=>['FIELD','TECHNICAL_ASSET'].includes(item.entityKind)&&item.action!=='IGNORE').reduce((sum,item)=>sum+item.sourceRowNumbers.length,0)};
|
|
}
|
|
|
|
private async insertAssetItems(
|
|
manager:EntityManager,batch:ImportBatchRow,items:PlanItemRow[],resolved:Map<string,{id:string;typeCode:string}>,typeMap:Map<string,{id:string;code:string;operationalRole:string;isActive:boolean;canBeRoot:boolean}>,getRef:(key:string|null|undefined)=>string|null,getType:(key:string|null|undefined)=>string|null,principal:AuthPrincipal,
|
|
):Promise<void>{
|
|
if(!items.length)return;
|
|
const parentRuleRows=(await manager.query(`SELECT child.code AS child,parent.code AS parent FROM asset_type_parent_rules rule JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id`)) as Array<{child:string;parent:string}>;
|
|
const rules=new Set(parentRuleRows.map(row=>`${row.child}\u001f${row.parent}`));
|
|
const records:Array<{item:PlanItemRow;id:string;typeId:string;parentId:string|null;areaId:string|null;operatorId:string|null;operationalStatus:string;description:string}>=[];
|
|
for(const item of items){const typeCode=item.assetTypeCode;if(!typeCode)throw new ConflictException({code:'ASSET_IMPORT_TYPE_REQUIRED',message:`El ítem ${item.displayName} no tiene tipo`});const type=typeMap.get(typeCode);if(!type)throw new ConflictException({code:'ASSET_IMPORT_TYPE_NOT_AVAILABLE',message:`Tipo no disponible: ${typeCode}`});const id=resolved.get(item.entityKey)!.id;const parentId=getRef(item.parentEntityKey);const parentType=getType(item.parentEntityKey);if(!parentId&&!type.canBeRoot)throw new ConflictException({code:'ASSET_IMPORT_ROOT_RULE_INVALID',message:`${typeCode} no admite activos raíz (${item.displayName})`});if(parentId&&parentType&&!rules.has(`${typeCode}\u001f${parentType}`))throw new ConflictException({code:'ASSET_IMPORT_PARENT_RULE_INVALID',message:`${typeCode} no admite ${parentType} como padre (${item.displayName})`});let areaId:string|null=null;let operatorId:string|null=null;if(type.operationalRole==='GENERIC'){const areaKey=String(item.payload.operationalAreaEntityKey??'');const operatorKey=String(item.payload.operatorEntityKey??'');areaId=areaKey?getRef(areaKey):String(item.payload.operationalAreaAssetId??'')||null;operatorId=operatorKey?getRef(operatorKey):String(item.payload.operatorAssetId??'')||null;if((areaId&&!operatorId)||(!areaId&&operatorId))throw new ConflictException({code:'ASSET_IMPORT_OPERATIONAL_CONTEXT_INCOMPLETE',message:`Contexto operativo incompleto para ${item.displayName}`});}
|
|
const suggested=String(item.payload.operationalStatusSuggestion??'');const operationalStatus=['IN_SERVICE','TEMPORARILY_OUT_OF_SERVICE','OUT_OF_SERVICE','DECOMMISSIONED','ABANDONED'].includes(suggested)?suggested:'UNKNOWN';const sourceClassification=String(item.payload.sourceClassification??'').trim();const family=String(item.payload.normalizedFamily??'').trim();const subtype=String(item.payload.normalizedSubtype??'').trim();const description=[`Importado desde ${batch.originalName}.`,sourceClassification?`Clasificación fuente: ${sourceClassification}.`:'',family?`Familia normalizada: ${family}${subtype?` / ${subtype}`:''}.`:''].filter(Boolean).join(' ');records.push({item,id,typeId:type.id,parentId,areaId,operatorId,operationalStatus,description});}
|
|
for(let start=0;start<records.length;start+=150){const chunk=records.slice(start,start+150);const params:unknown[]=[];const values=chunk.map(record=>{const base=params.length;params.push(record.id,record.typeId,record.parentId,record.areaId,record.operatorId,record.item.generatedCode,record.item.displayName.slice(0,200),record.description,record.operationalStatus,principal.userId,batch.sourceLabel||batch.originalName,`asset-import:${batch.id}`,`Importación planificada ${batch.id}`);return `($${base+1}::uuid,$${base+2}::uuid,$${base+3}::uuid,$${base+4}::uuid,$${base+5}::uuid,$${base+6},$${base+7},$${base+8},'DRAFT',$${base+9}::asset_operational_status,$${base+10},$${base+10},1,'IMPORT',$${base+11},$${base+12},$${base+13},$${base+10})`;});await manager.query(`INSERT INTO assets (id,asset_type_id,parent_id,operational_area_id,operator_company_id,code,name,description,information_status,operational_status,created_by,updated_by,current_version,data_origin,source_name,source_reference,source_notes,provenance_updated_by) VALUES ${values.join(',')}`,params);}
|
|
const organizations=records.filter(record=>record.item.entityKind==='ORGANIZATION');for(let start=0;start<organizations.length;start+=200){const chunk=organizations.slice(start,start+200);const params:unknown[]=[];const values=chunk.map(record=>{const base=params.length;const kind=record.item.payload.organizationKind==='UTE'?'UTE':'COMPANY';params.push(record.id,kind,record.item.displayName.slice(0,240),principal.userId);return `($${base+1}::uuid,$${base+2}::organization_kind,$${base+3},$${base+4})`;});await manager.query(`INSERT INTO organization_profiles (asset_id,organization_kind,legal_name,updated_by) VALUES ${values.join(',')} ON CONFLICT (asset_id) DO NOTHING`,params);}
|
|
}
|
|
|
|
private async insertSourceLinks(manager:EntityManager,batch:ImportBatchRow,items:PlanItemRow[],resolved:Map<string,{id:string;typeCode:string}>,userId:string):Promise<void>{if(!batch.sourceDocumentId||!items.length)return;const ids=[...new Set(items.map(item=>resolved.get(item.entityKey)?.id).filter((value):value is string=>Boolean(value)))];for(let start=0;start<ids.length;start+=250){const chunk=ids.slice(start,start+250);await manager.query(`INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes,created_by) SELECT unnest($1::uuid[]),$2,'SOURCE',$3,$4 ON CONFLICT (asset_id,document_id,relation_type) DO NOTHING`,[chunk,batch.sourceDocumentId,`Creado por lote de importación ${batch.id}`,userId]);}}
|
|
|
|
private async insertExternalIdentifiers(manager:EntityManager,batch:ImportBatchRow,items:PlanItemRow[],resolved:Map<string,{id:string;typeCode:string}>,namespace:string|null,userId:string):Promise<number>{if(!namespace||!items.length)return 0;const values:Array<{assetId:string;value:string}>=[];for(const item of items){const value=String(item.payload.inventoryId??'').trim();if(value)values.push({assetId:resolved.get(item.entityKey)!.id,value});}let count=0;for(let start=0;start<values.length;start+=200){const chunk=values.slice(start,start+200);const params:unknown[]=[];const sql=chunk.map(item=>{const base=params.length;params.push(item.assetId,namespace,item.value,batch.sourceDocumentId,userId,`Inventario importado · lote ${batch.id}`);return `($${base+1}::uuid,$${base+2},$${base+3},$${base+4}::uuid,$${base+5},$${base+6})`;});const result=await manager.query(`INSERT INTO asset_external_identifiers (asset_id,namespace,value,source_document_id,created_by,notes) VALUES ${sql.join(',')} RETURNING id`,params);count+=result.length;}return count;}
|
|
|
|
private async insertKnownAttributes(manager:EntityManager,items:PlanItemRow[],resolved:Map<string,{id:string;typeCode:string}>,typeMap:Map<string,{id:string;code:string;operationalRole:string;isActive:boolean;canBeRoot:boolean}>,userId:string):Promise<void>{
|
|
if(!items.length)return;const typeIds=[...new Set(items.map(item=>item.assetTypeCode?typeMap.get(item.assetTypeCode)?.id:null).filter((value):value is string=>Boolean(value)))];const definitions=(await manager.query(`SELECT id,asset_type_id AS "assetTypeId",code FROM asset_attribute_definitions WHERE asset_type_id=ANY($1::uuid[]) AND is_active=true`,[typeIds])) as Array<{id:string;assetTypeId:string;code:string}>;const defs=new Map<string,string>();for(const def of definitions)defs.set(`${def.assetTypeId}\u001f${def.code}`,def.id);const rows:Array<{assetId:string;definitionId:string;value:unknown}>=[];for(const item of items){const type=item.assetTypeCode?typeMap.get(item.assetTypeCode):null;if(!type)continue;const add=(code:string,value:unknown)=>{if(value===null||value===undefined||String(value).trim()==='')return;const definitionId=defs.get(`${type.id}\u001f${code}`);if(definitionId)rows.push({assetId:resolved.get(item.entityKey)!.id,definitionId,value});};if(item.entityKind==='ORGANIZATION')add('razon_social',item.displayName);add('fabricante',item.payload.manufacturer);add('modelo',item.payload.model);if(item.assetTypeCode==='instalacion')add('tipo_instalacion',item.payload.sourceInstallation);if(item.assetTypeCode==='estructura_local'){add('nivel_fuente',item.payload.sourceInstallation);add('clasificacion_fuente',item.payload.sourceSubInstallation);add('ruta_fuente',item.payload.sourcePathText);}if(item.assetTypeCode==='planta')add('funcion_planta',item.payload.sourceSubInstallation||item.payload.sourceInstallation);if(item.assetTypeCode==='pozo')add('estado_operativo',item.payload.sourceStatus);}
|
|
for(let start=0;start<rows.length;start+=250){const chunk=rows.slice(start,start+250);const params:unknown[]=[];const values=chunk.map(row=>{const base=params.length;params.push(row.assetId,row.definitionId,JSON.stringify(row.value),userId);return `($${base+1}::uuid,$${base+2}::uuid,$${base+3}::jsonb,$${base+4})`;});await manager.query(`INSERT INTO asset_attribute_values (asset_id,definition_id,value,updated_by) VALUES ${values.join(',')} ON CONFLICT (asset_id,definition_id) DO UPDATE SET value=EXCLUDED.value,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP`,params);}
|
|
}
|
|
|
|
private async insertImportVersions(manager:EntityManager,assetIds:string[],principal:AuthPrincipal,request:RequestWithContext):Promise<void>{for(let start=0;start<assetIds.length;start+=300){const chunk=assetIds.slice(start,start+300);await manager.query(`
|
|
INSERT INTO asset_versions (asset_id,version_number,change_type,changed_fields,snapshot,actor_user_id,actor_username,source,request_id)
|
|
SELECT asset.id,1,'CREATED',ARRAY['created','import']::text[],JSONB_BUILD_OBJECT(
|
|
'id',asset.id,'code',asset.code,'name',asset.name,'description',asset.description,
|
|
'type',JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name,'operationalRole',type.operational_role),
|
|
'parent',CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END,
|
|
'operationalArea',CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END,
|
|
'operatorCompany',CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END,
|
|
'informationStatus',asset.information_status,'operationalStatus',asset.operational_status,'attributes','[]'::jsonb,'geometry',NULL,'media','[]'::jsonb,
|
|
'externalIdentifiers',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',identifier.id,'namespace',identifier.namespace,'value',identifier.value,'validFrom',identifier.valid_from,'validUntil',identifier.valid_until,'sourceDocumentId',identifier.source_document_id)) FROM asset_external_identifiers identifier WHERE identifier.asset_id=asset.id),'[]'::jsonb),
|
|
'sourceDocuments',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('documentId',document.id,'title',document.title,'documentType',document.document_type)) FROM asset_source_documents link JOIN source_documents document ON document.id=link.document_id WHERE link.asset_id=asset.id),'[]'::jsonb),
|
|
'provenance',JSONB_BUILD_OBJECT('origin',asset.data_origin,'sourceName',asset.source_name,'sourceReference',asset.source_reference,'notes',asset.source_notes),
|
|
'createdAt',asset.created_at,'updatedAt',asset.updated_at,'createdBy',asset.created_by,'updatedBy',asset.updated_by,'currentVersion',asset.current_version
|
|
),$2,$3,'IMPORT',$4
|
|
FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id LEFT JOIN assets parent ON parent.id=asset.parent_id LEFT JOIN assets area ON area.id=asset.operational_area_id LEFT JOIN assets company ON company.id=asset.operator_company_id
|
|
WHERE asset.id=ANY($1::uuid[])
|
|
`,[chunk,principal.userId,principal.username,request.requestId]);}}
|
|
|
|
private async updateImportedRows(manager:EntityManager,batchId:string,items:PlanItemRow[],resolved:Map<string,{id:string;typeCode:string}>):Promise<void>{const pairs:Array<{rowNumber:number;assetId:string}>=[];for(const item of items){if(!['FIELD','TECHNICAL_ASSET'].includes(item.entityKind)||item.action==='IGNORE'||item.action==='REVIEW')continue;const assetId=item.action==='MATCH'?item.matchedAssetId:resolved.get(item.entityKey)?.id;if(!assetId)continue;for(const rowNumber of item.sourceRowNumbers)pairs.push({rowNumber,assetId});}for(let start=0;start<pairs.length;start+=300){const chunk=pairs.slice(start,start+300);const params:unknown[]=[batchId];const values=chunk.map(pair=>{const base=params.length;params.push(pair.rowNumber,pair.assetId);return `($${base+1}::integer,$${base+2}::uuid)`;});await manager.query(`UPDATE asset_import_rows row SET imported_asset_id=v.asset_id,updated_at=CURRENT_TIMESTAMP FROM (VALUES ${values.join(',')}) AS v(row_number,asset_id) WHERE row.batch_id=$1 AND row.row_number=v.row_number`,params);}}
|
|
|
|
private async assertRollbackSafe(manager:EntityManager,items:PlanItemRow[],createdAssetIds:string[]):Promise<void>{
|
|
const planObjects=items.filter(item=>item.action==='CREATE'&&item.appliedObjectId).map(item=>({entityKind:item.entityKind,id:item.appliedObjectId!}));
|
|
const ids=(kind:AssetImportPlanEntityKind)=>planObjects.filter(row=>row.entityKind===kind).map(row=>row.id);
|
|
const operatorRelationIds=ids('OPERATOR_RELATION');
|
|
const areaDepartmentRelationIds=ids('AREA_DEPARTMENT_RELATION');
|
|
const legalRightIds=ids('LEGAL_RIGHT');
|
|
const legalRightOrganizationIds=ids('LEGAL_RIGHT_ORGANIZATION');
|
|
const departmentIds=ids('DEPARTMENT');
|
|
const checks:Array<{code:string;message:string;sql:string;params:unknown[]}>=[];
|
|
if(createdAssetIds.length){checks.push(
|
|
{code:'ASSET_IMPORT_ROLLBACK_HAS_CHILDREN',message:'Hay activos posteriores que dependen de activos creados por este lote',sql:`SELECT 1 FROM assets WHERE parent_id=ANY($1::uuid[]) AND NOT (id=ANY($1::uuid[])) AND information_status<>'INACTIVE' LIMIT 1`,params:[createdAssetIds]},
|
|
{code:'ASSET_IMPORT_ROLLBACK_HAS_ASSIGNMENTS',message:'Hay activos posteriores asignados a áreas u organizaciones creadas por este lote',sql:`SELECT 1 FROM assets WHERE (operational_area_id=ANY($1::uuid[]) OR operator_company_id=ANY($1::uuid[])) AND NOT (id=ANY($1::uuid[])) AND information_status<>'INACTIVE' LIMIT 1`,params:[createdAssetIds]},
|
|
{code:'ASSET_IMPORT_ROLLBACK_HAS_RELATIONS',message:'Hay relaciones operativas externas al lote que dependen de activos importados',sql:`SELECT 1 FROM area_company_relations WHERE (area_id=ANY($1::uuid[]) OR company_id=ANY($1::uuid[])) AND valid_until IS NULL AND NOT (id=ANY($2::uuid[])) LIMIT 1`,params:[createdAssetIds,operatorRelationIds.length?operatorRelationIds:['00000000-0000-0000-0000-000000000000']]},
|
|
{code:'ASSET_IMPORT_ROLLBACK_IN_USE',message:'Hay relevamientos o inspecciones que ya utilizan activos creados por este lote',sql:`SELECT 1 FROM (SELECT asset_id FROM survey_campaign_targets WHERE asset_id=ANY($1::uuid[]) UNION ALL SELECT scope_asset_id FROM survey_campaigns WHERE scope_asset_id=ANY($1::uuid[]) UNION ALL SELECT asset_id FROM inspection_visit_assets WHERE asset_id=ANY($1::uuid[]) UNION ALL SELECT scope_asset_id FROM inspection_visits WHERE scope_asset_id=ANY($1::uuid[]) UNION ALL SELECT asset_id FROM inspection_act_assets WHERE asset_id=ANY($1::uuid[]) UNION ALL SELECT asset_id FROM inspection_findings WHERE asset_id=ANY($1::uuid[])) usage LIMIT 1`,params:[createdAssetIds]}
|
|
);}
|
|
if(departmentIds.length)checks.push({code:'ASSET_IMPORT_ROLLBACK_DEPARTMENT_IN_USE',message:'Un Departamento creado por el lote ya está relacionado con otra Área',sql:`SELECT 1 FROM area_department_relations WHERE department_id=ANY($1::uuid[]) AND valid_until IS NULL AND NOT (id=ANY($2::uuid[])) LIMIT 1`,params:[departmentIds,areaDepartmentRelationIds.length?areaDepartmentRelationIds:['00000000-0000-0000-0000-000000000000']]});
|
|
if(legalRightIds.length)checks.push({code:'ASSET_IMPORT_ROLLBACK_LEGAL_RIGHT_IN_USE',message:'Un derecho creado por el lote ya tiene relaciones posteriores',sql:`SELECT 1 FROM area_legal_right_organizations WHERE right_id=ANY($1::uuid[]) AND valid_until IS NULL AND NOT (id=ANY($2::uuid[])) LIMIT 1`,params:[legalRightIds,legalRightOrganizationIds.length?legalRightOrganizationIds:['00000000-0000-0000-0000-000000000000']]});
|
|
for(const check of checks){const [hit]=(await manager.query(check.sql,check.params)) as unknown[];if(hit)throw new ConflictException({code:check.code,message:check.message});}
|
|
}
|
|
|
|
private async insertRollbackVersions(manager:EntityManager,assetIds:string[],principal:AuthPrincipal,request:RequestWithContext):Promise<void>{for(let start=0;start<assetIds.length;start+=300){const chunk=assetIds.slice(start,start+300);await manager.query(`INSERT INTO asset_versions (asset_id,version_number,change_type,changed_fields,snapshot,actor_user_id,actor_username,source,request_id) SELECT asset.id,asset.current_version,'STATUS_CHANGED',ARRAY['informationStatus','operationalArea','operatorCompany','rollback']::text[],JSONB_BUILD_OBJECT('id',asset.id,'code',asset.code,'name',asset.name,'description',asset.description,'type',JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name,'operationalRole',type.operational_role),'parent',CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END,'operationalArea',NULL,'operatorCompany',NULL,'informationStatus',asset.information_status,'operationalStatus',asset.operational_status,'attributes','[]'::jsonb,'geometry',NULL,'media','[]'::jsonb,'provenance',JSONB_BUILD_OBJECT('origin',asset.data_origin,'sourceName',asset.source_name,'sourceReference',asset.source_reference,'notes',asset.source_notes),'createdAt',asset.created_at,'updatedAt',asset.updated_at,'createdBy',asset.created_by,'updatedBy',asset.updated_by,'currentVersion',asset.current_version),$2,$3,'IMPORT',$4 FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id LEFT JOIN assets parent ON parent.id=asset.parent_id WHERE asset.id=ANY($1::uuid[])`,[chunk,principal.userId,principal.username,request.requestId]);}}
|
|
|
|
private async insertRows(manager: EntityManager, batchId: string, rows: ImportNormalizedRow[]): Promise<void> {
|
|
const chunkSize = 200;
|
|
for (let start = 0; start < rows.length; start += chunkSize) {
|
|
const chunk = rows.slice(start, start + chunkSize);
|
|
if (!chunk.length) continue;
|
|
const params: unknown[] = [];
|
|
const values = chunk.map((row) => {
|
|
const base = params.length;
|
|
params.push(batchId,row.sheetName,row.rowNumber,row.status,row.suggestedAction,JSON.stringify(row.raw),JSON.stringify(row.normalized),JSON.stringify(row.issues),row.fingerprint);
|
|
return `($${base+1},$${base+2},$${base+3},$${base+4}::asset_import_row_status,$${base+5}::asset_import_suggested_action,$${base+6}::jsonb,$${base+7}::jsonb,$${base+8}::jsonb,$${base+9})`;
|
|
});
|
|
await manager.query(`
|
|
INSERT INTO asset_import_rows (batch_id,worksheet_name,row_number,status,suggested_action,raw_data,normalized_data,issue_codes,fingerprint)
|
|
VALUES ${values.join(',')}
|
|
`, params);
|
|
}
|
|
}
|
|
|
|
private async loadBatch(manager: EntityManager, id: string, lock = false): Promise<ImportBatchRow> {
|
|
const rows = (await manager.query(`${this.batchSelect()} WHERE batch.id=$1${lock ? ' FOR UPDATE OF batch' : ''}`, [id])) as ImportBatchRow[];
|
|
if (!rows[0]) throw importNotFound();
|
|
return rows[0];
|
|
}
|
|
|
|
private batchSelect(): string {
|
|
return `SELECT batch.id,batch.original_name AS "originalName",batch.mime_type AS "mimeType",batch.size_bytes AS "sizeBytes",batch.sha256,
|
|
batch.profile_code AS "profileCode",batch.profile_confidence AS "profileConfidence",batch.worksheet_name AS "worksheetName",batch.header_row AS "headerRow",
|
|
batch.total_rows AS "totalRows",batch.ready_rows AS "readyRows",batch.warning_rows AS "warningRows",batch.conflict_rows AS "conflictRows",batch.ignored_rows AS "ignoredRows",
|
|
batch.status,batch.source_document_id AS "sourceDocumentId",batch.source_label AS "sourceLabel",batch.notes,batch.analysis,
|
|
batch.uploaded_by AS "uploadedBy",u.username AS "uploadedByUsername",batch.analyzed_at AS "analyzedAt",batch.created_at AS "createdAt",batch.updated_at AS "updatedAt"
|
|
FROM asset_import_batches batch LEFT JOIN users u ON u.id=batch.uploaded_by`;
|
|
}
|
|
}
|