369 lines
10 KiB
TypeScript
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 }
|
|
);
|
|
}
|