F5 · Inventario operativo, territorio y catálogo autorizado (#25)
* fix(web): simplify inventory administration menu * fix(web): remove legacy imports and function catalog routes * fix(web): remove redundant inspections lifecycle legend * feat(inventory): distinguish physical instances from structural records * fix(dashboard): align inventory and act follow-up metrics * fix(web): align dashboard summary contract * fix(web): clarify dashboard act and report concepts * feat(inventory): mark field-created records as real instances * feat(inventory): map physical instance flag on asset entity * fix(inventory): keep field yacimientos structural * feat(inventory): classify future concrete instances at database level * fix(inventory): count only installation and subinstallation instances * feat(inventory): add authoritative F5 source snapshot * feat(inventory): preload authoritative territory model * feat(inventory): preload authoritative technical catalog * fix(findings): use only authoritative F5 family catalog * fix(inventory): preserve non-hierarchical operator snapshot compatibility * feat(inventory): add inventory-only asset filter * feat(inventory): add inventory-only tree filter * feat(inventory): add inventory browser query contract * feat(inventory): add area-owned inventory browser * feat(inventory): expose area-owned inventory browser * refactor(inventory): remove function catalog and add inventory browser * fix(inventory): make operator relation temporal and non-owning * feat(web): add inventory browser API client * feat(inventory): extend inventory browser filters * feat(inventory): add real inventory list endpoint logic * feat(inventory): expose real inventory list * feat(web): add real inventory list client * refactor(web): make inventory hierarchy area-owned * fix(web): show only real inventory instances * fix(web): style act follow-up tabs and F5 inventory context * fix(web): load F5 flow styles * fix(inventory): apply area-owned operational guard on F5 up * fix(inventory): treat company on asset as non-owning creation snapshot * fix(inventory): resolve field inventory by area hierarchy, not company ownership * fix(inventory): preserve custom catalog and apply authoritative universal findings * fix(inventory): harden authoritative catalog migration checks * fix(inventory): make authoritative territory preload safely reversible * feat(inventory): allow independent company master creation * fix(inventory): make guided creation area-owned and support companies * feat(web): expose independent company master in inventory setup * feat(web): create companies independently from physical inventory hierarchy * fix(inventory): merge by physical area and preserve sealed documents * test(inventory): lock F5 authoritative model and merge invariants * feat(inventory): add family administration DTOs * feat(inventory): administer installation and subinstallation classifications * feat(inventory): expose family classification administration * feat(web): add inventory classification administration API * fix(web): configure finding applicability by inventory classification * fix(web): redefine inventory configuration around hierarchy classifications and columns * chore(release): identify F5 inventory model * chore(release): bump API for F5 inventory model * test(release): expect F5 health metadata * chore(release): align WEB package with F5 inventory cut * chore(release): expose F5 WEB phase * test(dashboard): expect inspector activity and act follow-up metrics * test(dashboard): route F5 summary query mocks explicitly * ci: rehearse all migrations on clean PostGIS before merge * ci: prove F5 migrations revert and reapply cleanly * test(f5): align operational navigation contract * test(f5): align operator lifecycle with area-owned inventory * test(f5): make merge compatibility area-based * test(f5): distinguish literal and normalized yacimiento counts * test(f5): model normalized yacimiento collision explicitly * ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal * ci(f5): bypass irreversible historical reset in clean rehearsal * fix(f5): make territory SQL parameter types explicit * fix(f5): guarantee canonical inventory hierarchy before territory preload * ci(f5): include canonical hierarchy migration in rollback gate * fix(f5): type relation backup markers explicitly * fix(f5): make catalog SQL text parameter types explicit
This commit is contained in:
@@ -7,13 +7,15 @@ async function source(relativePath: string): Promise<string> {
|
||||
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('D5.3.11.1 exposes imported operational assignments in Maestro navigation without creating legal relations', async () => {
|
||||
test('D5.3.11.1/F5 navigates Area ↔ Empresa from temporal operator relations instead of Inventory ownership', async () => {
|
||||
const service = await source('asset-master/asset-operational-relations.service.ts');
|
||||
|
||||
assert.match(service, /SELECT asset\.operator_company_id AS company_id/);
|
||||
assert.match(service, /WHERE asset\.operational_area_id = \$1/);
|
||||
assert.match(service, /SELECT asset\.operational_area_id AS area_id/);
|
||||
assert.match(service, /WHERE asset\.operator_company_id = \$1/);
|
||||
assert.match(service, /UNION/);
|
||||
assert.match(service, /FROM area_company_relations relation/);
|
||||
assert.match(service, /WHERE relation\.area_id = \$1/);
|
||||
assert.match(service, /WHERE relation\.company_id = \$1/);
|
||||
assert.match(service, /relation\.relation_role = 'OPERATOR'/);
|
||||
assert.match(service, /relation\.valid_until IS NULL/);
|
||||
assert.doesNotMatch(service, /SELECT asset\.operator_company_id AS company_id/);
|
||||
assert.doesNotMatch(service, /SELECT asset\.operational_area_id AS area_id/);
|
||||
assert.doesNotMatch(service, /INSERT INTO area_company_relations[\s\S]*listAreasForCompany/);
|
||||
});
|
||||
|
||||
@@ -33,11 +33,13 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry',
|
||||
assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/);
|
||||
});
|
||||
|
||||
test('D5.3 relation lifecycle is historical and audited', async () => {
|
||||
test('D5.3/F5 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => {
|
||||
const service = await source('asset-master/asset-operational-relations.service.ts');
|
||||
assert.match(service, /INSERT INTO area_company_relations/);
|
||||
assert.match(service, /valid_until = CURRENT_TIMESTAMP/);
|
||||
assert.match(service, /AREA_COMPANY_RELATION_IN_USE/);
|
||||
assert.match(service, /Ending an operator relation must never be blocked by existing Inventory/);
|
||||
assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/);
|
||||
assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/);
|
||||
assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/);
|
||||
assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/);
|
||||
});
|
||||
|
||||
@@ -2,27 +2,28 @@ import { strict as assert } from 'node:assert';
|
||||
import test from 'node:test';
|
||||
import { DashboardService } from '../../src/dashboard/dashboard.service';
|
||||
|
||||
test('DashboardService returns F4 operational counts and recent activity consistently', async () => {
|
||||
test('DashboardService returns F5 operational counts and inspector activity consistently', async () => {
|
||||
const recent = [{
|
||||
id: 'event-id',
|
||||
occurredAt: new Date('2026-08-13T12:00:00.000Z'),
|
||||
actorUsername: 'admin',
|
||||
action: 'AUTH_LOGIN_SUCCESS',
|
||||
entityType: 'auth_session',
|
||||
entityId: 'session-id',
|
||||
occurredAt: new Date('2026-09-08T12:00:00.000Z'),
|
||||
actorUsername: 'inspector1',
|
||||
action: 'INSPECTION_STARTED',
|
||||
entityType: 'inspection_visit',
|
||||
entityId: 'visit-id',
|
||||
}];
|
||||
const manager = {
|
||||
query: async (sql: string) => {
|
||||
if (sql.includes('COUNT(*) FROM users')) {
|
||||
return [{
|
||||
activeUsers: '4', inactiveUsers: '2', activeSessions: '3',
|
||||
totalAssets: '12', assetsNeedValidation: '4', assetsWithoutGeometry: '6', plannedInspections: '2',
|
||||
openFindings: '8', findingsWithoutControlDate: '5', overdueControls: '2', controlsNext30Days: '3',
|
||||
totalAssets: '0', assetsNeedValidation: '0', assetsWithoutGeometry: '0', plannedInspections: '2',
|
||||
openFindings: '8', actsInFollowUp: '6', findingsWithoutControlDate: '5', overdueControls: '2', controlsNext30Days: '3',
|
||||
reportsWorking: '1', reportsOfficialized: '2', sealedActsWithoutReport: '4',
|
||||
}];
|
||||
}
|
||||
if (sql.includes('FROM inspection_findings finding')) return [];
|
||||
return recent;
|
||||
if (sql.includes('FROM audit_events event')) return recent;
|
||||
if (sql.includes('next_control_on AS "nextControlOn"')) return [];
|
||||
throw new Error(`Unexpected DashboardService query in test: ${sql}`);
|
||||
},
|
||||
};
|
||||
const dataSource = {
|
||||
@@ -31,14 +32,15 @@ test('DashboardService returns F4 operational counts and recent activity consist
|
||||
|
||||
const result = await new DashboardService(dataSource as never).summary();
|
||||
assert.deepEqual(result.counts, {
|
||||
totalAssets: 12,
|
||||
assetsNeedValidation: 4,
|
||||
assetsWithoutGeometry: 6,
|
||||
totalAssets: 0,
|
||||
assetsNeedValidation: 0,
|
||||
assetsWithoutGeometry: 0,
|
||||
plannedInspections: 2,
|
||||
activeUsers: 4,
|
||||
inactiveUsers: 2,
|
||||
activeSessions: 3,
|
||||
openFindings: 8,
|
||||
actsInFollowUp: 6,
|
||||
findingsWithoutControlDate: 5,
|
||||
overdueControls: 2,
|
||||
controlsNext30Days: 3,
|
||||
@@ -46,7 +48,8 @@ test('DashboardService returns F4 operational counts and recent activity consist
|
||||
reportsOfficialized: 2,
|
||||
sealedActsWithoutReport: 4,
|
||||
});
|
||||
assert.equal(result.recentAudit, recent);
|
||||
assert.deepEqual(result.recentInspectorActivity, recent);
|
||||
assert.equal('recentAudit' in result, false);
|
||||
assert.deepEqual(result.upcomingControls, []);
|
||||
assert.equal(typeof result.generatedAt, 'string');
|
||||
});
|
||||
|
||||
@@ -19,12 +19,14 @@ test('F3.1 registra cada fusión como evento append-only con snapshots', () => {
|
||||
assert.match(migration, /append-only/);
|
||||
});
|
||||
|
||||
test('F3.1 sólo permite fusionar Instalaciones o Subinstalaciones compatibles', () => {
|
||||
test('F3.1/F5 sólo permite fusionar Instalaciones o Subinstalaciones compatibles dentro de la misma Área física', () => {
|
||||
assert.match(service, /MERGEABLE_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/);
|
||||
assert.match(service, /INVENTORY_MERGE_TYPE_INVALID/);
|
||||
assert.match(service, /INVENTORY_MERGE_CONTEXT_MISMATCH/);
|
||||
assert.match(service, /resolvePhysicalAreaId/);
|
||||
assert.match(service, /INVENTORY_MERGE_AREA_MISMATCH/);
|
||||
assert.match(service, /INVENTORY_MERGE_PARENT_MISMATCH/);
|
||||
assert.match(service, /INVENTORY_MERGE_CHILD_FAMILY_CONFLICT/);
|
||||
assert.doesNotMatch(service, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
|
||||
});
|
||||
|
||||
test('F3.1 preserva referencias históricas y sólo reubica hijos actuales', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||
|
||||
test('F4 health metadata reports the deployed phase without changing the release version', () => {
|
||||
assert.equal(API_PHASE, 'F4');
|
||||
assert.equal(API_VERSION, '0.25.0-1');
|
||||
test('health metadata reports the current F5 release', () => {
|
||||
assert.equal(API_PHASE, 'F5');
|
||||
assert.equal(API_VERSION, '0.26.0-1');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { loadF5InventoryAuthoritativeSource } from '../../src/reference-data/f5-authoritative-inventory-source';
|
||||
|
||||
function key(value: string): string {
|
||||
return value.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
test('F5 territory preload is exactly the authorized Tablas de yacimiento y areas workbook', () => {
|
||||
const source = loadF5InventoryAuthoritativeSource().areaSource;
|
||||
assert.equal(source.file, 'Tablas de yacimiento y areas.xlsx');
|
||||
assert.equal(source.sheet, 'cr26e_tabla1');
|
||||
assert.equal(source.sha256, '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78');
|
||||
assert.equal(source.rows.length, 230);
|
||||
|
||||
const areas = new Set(source.rows.map((row) => key(row.area)));
|
||||
const literalYacimientoNames = new Set(source.rows.map((row) => row.yacimiento.trim()));
|
||||
const yacimientos = new Set(source.rows.map((row) => key(row.yacimiento)));
|
||||
const areaYacimiento = new Set(source.rows.map((row) => `${key(row.area)}|${key(row.yacimiento)}`));
|
||||
const departments = new Set(source.rows.map((row) => key(row.departamento)));
|
||||
const concessionTypes = new Set(source.rows.map((row) => key(row.tipoConcesion)));
|
||||
const operators = new Set(source.rows.map((row) => key(row.empresaOperadora)));
|
||||
|
||||
assert.equal(areas.size, 64);
|
||||
// The authorized workbook contains 220 literal Yacimiento names. The F5 key
|
||||
// intentionally folds accents/punctuation/case, so one pair of literal names
|
||||
// collides into the same normalized key. Yacimiento identity is Area+Yacimiento,
|
||||
// never a globally-unique normalized name.
|
||||
assert.equal(literalYacimientoNames.size, 220);
|
||||
assert.equal(yacimientos.size, 219);
|
||||
assert.equal(areaYacimiento.size, 230);
|
||||
assert.equal(departments.size, 7);
|
||||
assert.equal(concessionTypes.size, 2);
|
||||
assert.equal(operators.size, 13);
|
||||
|
||||
const literalNamesByKey = new Map<string, Set<string>>();
|
||||
for (const row of source.rows) {
|
||||
const normalized = key(row.yacimiento);
|
||||
const literals = literalNamesByKey.get(normalized) ?? new Set<string>();
|
||||
literals.add(row.yacimiento.trim());
|
||||
literalNamesByKey.set(normalized, literals);
|
||||
}
|
||||
assert.equal([...literalNamesByKey.values()].filter((items) => items.size > 1).length, 1);
|
||||
|
||||
const literalAreasByYacimiento = new Map<string, Set<string>>();
|
||||
const areasByYacimiento = new Map<string, Set<string>>();
|
||||
for (const row of source.rows) {
|
||||
const literal = row.yacimiento.trim();
|
||||
const literalMemberships = literalAreasByYacimiento.get(literal) ?? new Set<string>();
|
||||
literalMemberships.add(key(row.area));
|
||||
literalAreasByYacimiento.set(literal, literalMemberships);
|
||||
|
||||
const normalized = key(row.yacimiento);
|
||||
const memberships = areasByYacimiento.get(normalized) ?? new Set<string>();
|
||||
memberships.add(key(row.area));
|
||||
areasByYacimiento.set(normalized, memberships);
|
||||
}
|
||||
const literalMembershipSurplus = [...literalAreasByYacimiento.values()]
|
||||
.reduce((total, memberships) => total + Math.max(0, memberships.size - 1), 0);
|
||||
const normalizedMembershipSurplus = [...areasByYacimiento.values()]
|
||||
.reduce((total, memberships) => total + Math.max(0, memberships.size - 1), 0);
|
||||
assert.equal(literalMembershipSurplus, 10);
|
||||
assert.equal(normalizedMembershipSurplus, 11);
|
||||
|
||||
for (const area of areas) {
|
||||
const rows = source.rows.filter((row) => key(row.area) === area);
|
||||
assert.equal(new Set(rows.map((row) => key(row.departamento))).size, 1, `${area}: departamento`);
|
||||
assert.equal(new Set(rows.map((row) => key(row.tipoConcesion))).size, 1, `${area}: concesión`);
|
||||
assert.equal(new Set(rows.map((row) => key(row.empresaOperadora))).size, 1, `${area}: operadora`);
|
||||
}
|
||||
});
|
||||
|
||||
test('F5 technical catalog is exactly final_modelov2 and resolves internal IDEM references', () => {
|
||||
const source = loadF5InventoryAuthoritativeSource().catalogSource;
|
||||
assert.equal(source.file, 'final_modelov2.xlsx');
|
||||
assert.equal(source.sheet, 'Hoja1');
|
||||
assert.equal(source.sha256, 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155');
|
||||
assert.equal(source.installations.length, 14);
|
||||
assert.equal(source.subinstallations.length, 109);
|
||||
|
||||
const installationKeys = new Set(source.installations.map((item) => key(item.name)));
|
||||
assert.equal(installationKeys.size, 14);
|
||||
for (const subinstallation of source.subinstallations) {
|
||||
assert.ok(installationKeys.has(key(subinstallation.installation)), `${subinstallation.name} parent`);
|
||||
assert.ok(subinstallation.findings.every((finding) => !/^idem\b/i.test(finding.trim())), `${subinstallation.name} has unresolved IDEM`);
|
||||
}
|
||||
for (const installation of source.installations) {
|
||||
assert.ok(installation.findings.every((finding) => !/^idem\b/i.test(finding.trim())), `${installation.name} has unresolved IDEM`);
|
||||
}
|
||||
|
||||
const allFindings = new Map<string, string>();
|
||||
for (const finding of source.universalFindings) allFindings.set(key(finding), finding);
|
||||
for (const installation of source.installations) {
|
||||
for (const finding of installation.findings) allFindings.set(key(finding), finding);
|
||||
}
|
||||
for (const subinstallation of source.subinstallations) {
|
||||
for (const finding of subinstallation.findings) allFindings.set(key(finding), finding);
|
||||
}
|
||||
assert.equal(allFindings.size, 177);
|
||||
assert.deepEqual(
|
||||
new Set(source.universalFindings.map(key)),
|
||||
new Set([
|
||||
'ORDEN Y LIMPIEZA',
|
||||
'CARTELERIA PREVENTIVA / INFORMATIVA',
|
||||
'EXTINTORES',
|
||||
].map(key)),
|
||||
);
|
||||
});
|
||||
|
||||
test('F5 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => {
|
||||
const mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8');
|
||||
const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8');
|
||||
const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8');
|
||||
const catalogMigration = readFileSync('src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts', 'utf8');
|
||||
|
||||
assert.match(mergeSource, /resolvePhysicalAreaId/);
|
||||
assert.match(mergeSource, /documentInvariantsBefore/);
|
||||
assert.match(mergeSource, /locked_sha256/);
|
||||
assert.match(mergeSource, /closure_sha256/);
|
||||
assert.match(mergeSource, /frozen_sha256/);
|
||||
assert.match(mergeSource, /gedo_pdf_sha256/);
|
||||
assert.match(mergeSource, /historicalReferencesRewritten:\s*false/);
|
||||
assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
|
||||
assert.doesNotMatch(mergeSource, /misma Área y Operadora/);
|
||||
|
||||
assert.match(fieldSource, /Área de esta inspección/);
|
||||
assert.match(fieldSource, /area_company_relations/);
|
||||
assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/);
|
||||
|
||||
assert.match(contextMigration, /public async up[\s\S]*installAreaOwnedGuard/);
|
||||
assert.match(contextMigration, /public async down[\s\S]*installLegacyPairedGuard/);
|
||||
assert.match(contextMigration, /La Empresa se cambia en la relación temporal del Área/);
|
||||
|
||||
assert.doesNotMatch(catalogMigration, /unaccent\s*\(/i);
|
||||
assert.doesNotMatch(catalogMigration, /source_reference\s+IS\s+NULL/i);
|
||||
assert.match(catalogMigration, /F5_SOURCE_FAMILY_COUNT\s*=\s*123/);
|
||||
});
|
||||
Reference in New Issue
Block a user