metis-desk/modules/assets/src/api/infrastructure/persistence/asset-inventory-relational.ts

369 lines
10 KiB
TypeScript

import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import type { AssetInput, AssetSpecificationData } from "../../domain/asset-values";
import { AssetInputSchema, AssetSpecificationSchema } from "../../domain/asset-values";
import type { AssetPersistenceMapper } from "./asset-persistence.mapper";
import { readAssetRows, sqlIdentifier, writeAssetRow } from "./asset-relational-sql";
export type InventoryRow = {
id: string;
companyId: string;
templateId: string;
siteId: string | null;
brandId: string | null;
values: unknown;
specification: unknown;
};
export type RevisionRow = {
id: string;
assetId: string;
actorId: string;
recordedAt: string;
position: number;
siteId: string | null;
brandId: string | null;
values: unknown;
};
type AssetRow = Omit<InventoryRow, "values" | "specification"> & {
name: string;
status: string;
notes: string;
brandDefined: boolean;
templateName: string;
sectionsDefined: boolean;
};
type RevisionColumns = Omit<RevisionRow, "values"> & {
name: string;
status: string;
notes: string;
brandDefined: boolean;
templateId: string;
};
type SpecificationField = Omit<AssetSpecificationData["fields"][number], "type" | "choices"> & {
assetId: string;
fieldType: string;
position: number;
};
type SpecificationSection = {
assetId: string;
id: string;
name: string;
description: string;
position: number;
};
type SpecificationChoice = { assetId: string; fieldId: string; position: number; value: string };
type CharacteristicRow = {
assetId: string;
revisionId?: string;
fieldId: string;
valueType: string;
textValue: string | null;
numberValue: number | null;
booleanValue: boolean | null;
dateValue: string | null;
};
export function decodeCharacteristic(
row: Omit<CharacteristicRow, "assetId" | "fieldId" | "revisionId">
) {
const populated = [row.textValue, row.numberValue, row.booleanValue, row.dateValue].filter(
(value) => value !== null
);
if (populated.length !== 1) {
throw new Error("Valor tipado de activo persistido inconsistente.");
}
switch (row.valueType) {
case "text":
case "multiline":
case "choice":
if (row.textValue !== null) {
return row.textValue;
}
break;
case "number":
if (row.numberValue !== null) {
return Number(row.numberValue);
}
break;
case "boolean":
if (row.booleanValue !== null) {
return Boolean(row.booleanValue);
}
break;
case "date":
if (row.dateValue !== null) {
return row.dateValue;
}
break;
}
throw new Error("Tipo de característica persistida inconsistente.");
}
function characteristicRows(
assetId: string,
values: AssetInput,
specification: AssetSpecificationData,
revisionId?: string
) {
return Object.entries(values.characteristics).map(([fieldId, value]) => {
const type = specification.fields.find((field) => field.fieldId === fieldId)?.type;
if (!type) {
throw new Error("Campo de activo sin especificación.");
}
return {
assetId,
...(revisionId ? { revisionId } : {}),
fieldId,
valueType: type,
textValue:
type === "text" || type === "multiline" || type === "choice" ? String(value) : null,
numberValue: type === "number" && typeof value === "number" ? value : null,
booleanValue: type === "boolean" && typeof value === "boolean" ? value : null,
dateValue: type === "date" ? String(value) : null,
};
});
}
export async function readInventory(
database: Sequelize,
companyId: string,
transaction: Transaction
) {
const assets = await readAssetRows<AssetRow>(
database,
"assets",
"companyId",
[companyId],
transaction
);
const ids = assets.map((row) => row.id);
const fields = await readAssetRows<SpecificationField>(
database,
"asset_specification_fields",
"assetId",
ids,
transaction
);
const sections = await readAssetRows<SpecificationSection>(
database,
"asset_specification_sections",
"assetId",
ids,
transaction
);
const choices = await readAssetRows<SpecificationChoice>(
database,
"asset_specification_field_choices",
"assetId",
ids,
transaction
);
const current = await readCharacteristics(database, "asset_characteristics", ids, transaction);
const histories = await readAssetRows<RevisionColumns>(
database,
"asset_revisions",
"assetId",
ids,
transaction
);
const historical = await readCharacteristics(
database,
"asset_revision_characteristics",
ids,
transaction
);
const values = (row: AssetRow | RevisionColumns, entries: CharacteristicRow[]) =>
AssetInputSchema.parse({
name: row.name,
templateId: row.templateId,
siteId: row.siteId,
...(row.brandDefined ? { brandId: row.brandId } : {}),
status: row.status,
notes: row.notes,
characteristics: Object.fromEntries(
entries.map((entry) => [entry.fieldId, decodeCharacteristic(entry)])
),
});
const rows: InventoryRow[] = assets.map((row) => ({
...row,
values: values(
row,
current.filter((entry) => entry.assetId === row.id)
),
specification: AssetSpecificationSchema.parse({
templateId: row.templateId,
templateName: row.templateName,
...(row.sectionsDefined
? {
sections: sections
.filter((section) => section.assetId === row.id)
.sort((a, b) => a.position - b.position)
.map(({ id, name, description }) => ({ id, name, description })),
}
: {}),
fields: fields
.filter((field) => field.assetId === row.id)
.sort((a, b) => a.position - b.position)
.map((field) => ({
fieldId: field.fieldId,
name: field.name,
type: field.fieldType,
unit: field.unit,
required: Boolean(field.required),
...(field.sectionId ? { sectionId: field.sectionId } : {}),
choices: choices
.filter((choice) => choice.assetId === row.id && choice.fieldId === field.fieldId)
.sort((a, b) => a.position - b.position)
.map((choice) => choice.value),
})),
}),
}));
const revisions: RevisionRow[] = histories.map((row) => ({
...row,
values: values(
row,
historical.filter((entry) => entry.revisionId === row.id)
),
}));
return { rows, revisions };
}
export async function writeInventory(
database: Sequelize,
rows: ReturnType<AssetPersistenceMapper["toPersistence"]>,
existing: Set<string>,
transaction: Transaction
) {
const { asset, revisions } = rows;
const v = asset.values;
const specification = asset.specification;
await writeAssetRow(
database,
"assets",
{
id: asset.id,
companyId: asset.companyId,
templateId: v.templateId,
siteId: v.siteId,
brandId: v.brandId ?? null,
name: v.name,
status: v.status,
notes: v.notes,
brandDefined: v.brandId !== undefined,
templateName: specification.templateName,
sectionsDefined: specification.sections !== undefined,
},
transaction
);
// La especificación permanece congelada durante toda la vida del activo.
for (const [position, section] of (specification.sections ?? []).entries()) {
await writeAssetRow(
database,
"asset_specification_sections",
{ ...section, assetId: asset.id, position },
transaction
);
}
for (const [position, field] of specification.fields.entries()) {
await writeAssetRow(
database,
"asset_specification_fields",
{
assetId: asset.id,
fieldId: field.fieldId,
name: field.name,
fieldType: field.type,
unit: field.unit,
required: field.required,
sectionId: field.sectionId ?? null,
position,
},
transaction
);
for (const [choicePosition, value] of field.choices.entries()) {
await writeAssetRow(
database,
"asset_specification_field_choices",
{
assetId: asset.id,
fieldId: field.fieldId,
position: choicePosition,
value,
},
transaction
);
}
}
await database.query("DELETE FROM asset_characteristics WHERE assetId=:id", {
replacements: { id: asset.id },
transaction,
});
for (const row of characteristicRows(asset.id, v, specification)) {
await writeAssetRow(database, "asset_characteristics", row, transaction);
}
for (const revision of revisions) {
if (existing.has(revision.id)) {
continue;
}
const previous = revision.values;
await writeAssetRow(
database,
"asset_revisions",
{
id: revision.id,
assetId: asset.id,
actorId: revision.actorId,
recordedAt: revision.recordedAt,
position: revision.position,
templateId: previous.templateId,
siteId: previous.siteId,
brandId: previous.brandId ?? null,
name: previous.name,
status: previous.status,
notes: previous.notes,
brandDefined: previous.brandId !== undefined,
},
transaction
);
for (const row of characteristicRows(asset.id, previous, specification, revision.id)) {
await writeAssetRow(database, "asset_revision_characteristics", row, transaction);
}
}
}
async function readCharacteristics(
database: Sequelize,
table: "asset_characteristics" | "asset_revision_characteristics",
ids: string[],
transaction: Transaction
) {
if (!ids.length) {
return [];
}
// El protocolo de MariaDB puede devolver DATE como Date: fijar ISO evita depender
// de la zona horaria del proceso y mantiene el contrato de fecha sin hora.
const revision = table === "asset_revision_characteristics" ? "revisionId," : "";
return database.query<CharacteristicRow>(
`SELECT ${revision}assetId,fieldId,valueType,textValue,numberValue,booleanValue,DATE_FORMAT(dateValue,'%Y-%m-%d') AS dateValue FROM ${sqlIdentifier(table)} WHERE assetId IN (:ids)`,
{ replacements: { ids }, type: QueryTypes.SELECT, transaction }
);
}