389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
import type { UniqueID } from "@repo/rdx-ddd";
|
|
import { Maybe, Result } from "@repo/rdx-utils";
|
|
import type { Sequelize, Transaction } from "sequelize";
|
|
import { QueryTypes } from "sequelize";
|
|
|
|
import type { IAssetRepository, InventoryContext } from "../../application/asset.repository";
|
|
import { AssetCatalogError } from "../../domain/asset-definition";
|
|
import type { Workstation } from "../../domain/workstation.aggregate";
|
|
|
|
import { AvailabilityModel, CatalogModel } from "./asset-catalog.model";
|
|
import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper";
|
|
import { readBindings, readDefinitions } from "./asset-catalog-relational";
|
|
import { readInventory, writeInventory } from "./asset-inventory-relational";
|
|
import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper";
|
|
import { sqlIdentifier } from "./asset-relational-sql";
|
|
import { RelationshipModel } from "./asset-relationship.model";
|
|
import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
|
|
import { loadWorkstations } from "./load-workstations";
|
|
import { readBrands } from "./sequelize-brand.repository";
|
|
import { WorkstationAssetModel } from "./workstation.model";
|
|
import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
|
|
import { writeWorkstation } from "./workstation-relational";
|
|
|
|
export class SequelizeAssetRepository implements IAssetRepository {
|
|
private readonly mapper = new AssetPersistenceMapper();
|
|
|
|
constructor(private readonly database: Sequelize) {}
|
|
|
|
private async load(
|
|
companyId: UniqueID,
|
|
transaction: Transaction,
|
|
write: boolean
|
|
): Promise<Result<InventoryContext, AssetCatalogError>> {
|
|
// Orden fijo de bloqueos: catálogo antes de compañía, compartido con las escrituras del catálogo.
|
|
const root = await CatalogModel.findOne({
|
|
where: { catalogKey: "global" },
|
|
transaction,
|
|
...(write ? { lock: transaction.LOCK.UPDATE } : {}),
|
|
});
|
|
|
|
if (!root) {
|
|
throw new Error("Catálogo no inicializado.");
|
|
}
|
|
const replacements = { id: companyId.toPrimitive() };
|
|
const companies = await this.database.query<{ id: string }>(
|
|
`SELECT id FROM companies WHERE id=:id${write ? " FOR UPDATE" : ""}`,
|
|
{ replacements, type: QueryTypes.SELECT, transaction }
|
|
);
|
|
|
|
if (!companies.length) {
|
|
return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND"));
|
|
}
|
|
const definitions = await readDefinitions(this.database, root.id, transaction);
|
|
const bindings = await readBindings(
|
|
this.database,
|
|
definitions.map((item) => item.id),
|
|
transaction
|
|
);
|
|
const overrides = await AvailabilityModel.findAll({
|
|
where: { catalogId: root.id },
|
|
transaction,
|
|
});
|
|
const catalog = new AssetCatalogPersistenceMapper().toDomain(
|
|
root,
|
|
definitions,
|
|
bindings,
|
|
overrides
|
|
);
|
|
const { rows, revisions } = await readInventory(
|
|
this.database,
|
|
companyId.toPrimitive(),
|
|
transaction
|
|
);
|
|
const sites = await this.database.query<{ id: string }>(
|
|
"SELECT id FROM company_sites WHERE companyId=:id",
|
|
{ replacements, type: QueryTypes.SELECT, transaction }
|
|
);
|
|
const people = await this.database.query<{ id: string; active: boolean }>(
|
|
"SELECT id, active FROM company_contacts WHERE companyId=:id",
|
|
{ replacements, type: QueryTypes.SELECT, transaction }
|
|
);
|
|
const actors = await this.database.query<{ id: string; name: string }>(
|
|
"SELECT id, name FROM users",
|
|
{ type: QueryTypes.SELECT, transaction }
|
|
);
|
|
const assets = rows.map((row) => {
|
|
const asset = this.mapper.toDomain(
|
|
row,
|
|
revisions.filter((revision) => revision.assetId === row.id)
|
|
);
|
|
const spec = asset.specification.toPrimitive();
|
|
const template = definitions.find((item) => item.id === spec.templateId);
|
|
|
|
if (
|
|
!template ||
|
|
(template.companyId !== null && template.companyId !== companyId.toPrimitive())
|
|
) {
|
|
throw new Error("Plantilla de activo fuera de su compañía.");
|
|
}
|
|
for (const revision of asset.history) {
|
|
const values = revision.values.toPrimitive();
|
|
|
|
if (values.siteId && !sites.some((site) => site.id === values.siteId)) {
|
|
throw new Error("Asignación de inventario fuera de su compañía.");
|
|
}
|
|
}
|
|
|
|
return asset;
|
|
});
|
|
const relationships = (
|
|
await RelationshipModel.findAll({
|
|
where: { companyId: companyId.toPrimitive() },
|
|
transaction,
|
|
})
|
|
).map((row) => new AssetRelationshipPersistenceMapper().toDomain(row));
|
|
|
|
for (const relationship of relationships) {
|
|
const values = relationship.values;
|
|
const definition = catalog
|
|
.visible(Maybe.some(companyId))
|
|
.find((item) => item.id.equals(values.typeId));
|
|
|
|
if (
|
|
!(
|
|
definition &&
|
|
definition.values.data.kind === "relation" &&
|
|
definition.values.data.symmetric === values.symmetric &&
|
|
[values.sourceId, values.targetId].every((id) =>
|
|
assets.some((asset) => asset.id.equals(id))
|
|
)
|
|
)
|
|
) {
|
|
throw new Error("Relación de activos fuera de su compañía.");
|
|
}
|
|
}
|
|
const workstations = await loadWorkstations(companyId, transaction, assets, sites, people);
|
|
|
|
return Result.ok({
|
|
workstations,
|
|
brands: await readBrands(this.database, transaction, write),
|
|
relationships,
|
|
catalog,
|
|
assets,
|
|
sites: sites.map((site) => inventoryIdentity(site.id)),
|
|
people: people.map((person) => ({
|
|
id: inventoryIdentity(person.id),
|
|
active: Boolean(person.active),
|
|
})),
|
|
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
|
|
});
|
|
}
|
|
|
|
saveWorkstation(companyId: UniqueID, action: Parameters<IAssetRepository["saveWorkstation"]>[1]) {
|
|
return this.database.transaction(async (transaction) => {
|
|
const loaded = await this.load(companyId, transaction, true);
|
|
|
|
if (!loaded.isSuccess) {
|
|
return Result.fail(loaded.error);
|
|
}
|
|
const changed = action(loaded.data);
|
|
|
|
if (!changed.isSuccess) {
|
|
return Result.fail(changed.error);
|
|
}
|
|
const previous = loaded.data.workstations.find((item) => item.id.equals(changed.data.id));
|
|
|
|
if (previous !== changed.data) {
|
|
await this.persistWorkstation(changed.data, previous, transaction);
|
|
}
|
|
|
|
return Result.ok({ workstation: changed.data, actorNames: loaded.data.actorNames });
|
|
});
|
|
}
|
|
|
|
removeWorkstation(companyId: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>> {
|
|
return this.database.transaction(async (transaction) => {
|
|
const loaded = await this.load(companyId, transaction, true);
|
|
|
|
if (!loaded.isSuccess) {
|
|
return Result.fail(loaded.error);
|
|
}
|
|
if (!loaded.data.workstations.some((item) => item.id.equals(id))) {
|
|
return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND"));
|
|
}
|
|
const references = await this.database.query<{ tableName: string; columnName: string }>(
|
|
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME='workstations' AND REFERENCED_COLUMN_NAME='id'",
|
|
{ type: QueryTypes.SELECT, transaction }
|
|
);
|
|
const owned = new Set(["workstation_assets", "workstation_members", "workstation_revisions"]);
|
|
const replacements = { id: id.toPrimitive(), companyId: companyId.toPrimitive() };
|
|
|
|
for (const reference of references) {
|
|
if (owned.has(reference.tableName)) {
|
|
continue;
|
|
}
|
|
const rows = await this.database.query<{ value: number }>(
|
|
`SELECT 1 AS value FROM ${sqlIdentifier(reference.tableName)} WHERE ${sqlIdentifier(reference.columnName)}=:id LIMIT 1`,
|
|
{ replacements, transaction, type: QueryTypes.SELECT }
|
|
);
|
|
|
|
if (rows.length) {
|
|
return Result.fail(new AssetCatalogError("WORKSTATION_IN_USE"));
|
|
}
|
|
}
|
|
// Las asociaciones de composición se eliminan; sus activos no se borran.
|
|
await this.database.query("DELETE FROM workstation_assets WHERE workstationId=:id", {
|
|
replacements,
|
|
transaction,
|
|
});
|
|
await this.database.query("DELETE FROM workstation_revisions WHERE workstationId=:id", {
|
|
replacements,
|
|
transaction,
|
|
});
|
|
await this.database.query("DELETE FROM workstations WHERE companyId=:companyId AND id=:id", {
|
|
replacements,
|
|
transaction,
|
|
});
|
|
|
|
return Result.ok(undefined);
|
|
});
|
|
}
|
|
|
|
private async persistWorkstation(
|
|
item: Workstation,
|
|
previous: Workstation | undefined,
|
|
transaction: Transaction
|
|
) {
|
|
const rows = new WorkstationPersistenceMapper().toPersistence(item);
|
|
|
|
const existing = new Set(previous?.history.map((item) => item.id.toPrimitive()));
|
|
|
|
await writeWorkstation(this.database, rows, existing, transaction);
|
|
for (const reference of rows.references) {
|
|
await WorkstationAssetModel.upsert(reference, { transaction });
|
|
}
|
|
}
|
|
|
|
private async hasExternalReferences(assetId: string, transaction: Transaction) {
|
|
const references = await this.database.query<{ tableName: string; columnName: string }>(
|
|
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME='assets' AND REFERENCED_COLUMN_NAME='id'",
|
|
{ type: QueryTypes.SELECT, transaction }
|
|
);
|
|
const ownedTables = new Set([
|
|
"asset_characteristics",
|
|
"asset_revision_characteristics",
|
|
"asset_revisions",
|
|
"asset_specification_field_choices",
|
|
"asset_specification_fields",
|
|
"asset_specification_sections",
|
|
]);
|
|
|
|
for (const reference of references) {
|
|
if (ownedTables.has(reference.tableName)) {
|
|
continue;
|
|
}
|
|
const rows = await this.database.query<{ value: number }>(
|
|
`SELECT 1 AS value FROM ${sqlIdentifier(reference.tableName)} WHERE ${sqlIdentifier(reference.columnName)}=:assetId LIMIT 1`,
|
|
{ replacements: { assetId }, type: QueryTypes.SELECT, transaction }
|
|
);
|
|
|
|
if (rows.length) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private async removeInventory(assetId: string, companyId: string, transaction: Transaction) {
|
|
for (const table of [
|
|
"asset_revision_characteristics",
|
|
"asset_characteristics",
|
|
"asset_specification_field_choices",
|
|
"asset_specification_fields",
|
|
"asset_specification_sections",
|
|
"asset_revisions",
|
|
]) {
|
|
await this.database.query(`DELETE FROM ${sqlIdentifier(table)} WHERE assetId=:assetId`, {
|
|
replacements: { assetId },
|
|
transaction,
|
|
});
|
|
}
|
|
await this.database.query("DELETE FROM assets WHERE companyId=:companyId AND id=:assetId", {
|
|
replacements: { assetId, companyId },
|
|
transaction,
|
|
});
|
|
}
|
|
|
|
remove(companyId: UniqueID, id: UniqueID) {
|
|
return this.database.transaction(async (transaction) => {
|
|
const loaded = await this.load(companyId, transaction, true);
|
|
|
|
if (!loaded.isSuccess) {
|
|
return Result.fail(loaded.error);
|
|
}
|
|
const asset = loaded.data.assets.find((item) => item.id.equals(id));
|
|
|
|
if (!asset) {
|
|
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
|
|
}
|
|
if (await this.hasExternalReferences(id.toPrimitive(), transaction)) {
|
|
return Result.fail(new AssetCatalogError("ASSET_IN_USE"));
|
|
}
|
|
await this.removeInventory(id.toPrimitive(), companyId.toPrimitive(), transaction);
|
|
|
|
return Result.ok({ asset, actorNames: loaded.data.actorNames });
|
|
});
|
|
}
|
|
|
|
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>> {
|
|
return this.database.transaction(async (transaction) => {
|
|
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
|
|
replacements: { companyId: company.toPrimitive() },
|
|
transaction,
|
|
type: QueryTypes.SELECT,
|
|
});
|
|
const row = await RelationshipModel.findOne({
|
|
where: { companyId: company.toPrimitive(), id: id.toPrimitive() },
|
|
transaction,
|
|
lock: transaction.LOCK.UPDATE,
|
|
});
|
|
|
|
if (!row) {
|
|
return Result.fail(new AssetCatalogError("RELATIONSHIP_NOT_FOUND"));
|
|
}
|
|
await row.destroy({ transaction });
|
|
|
|
return Result.ok(undefined);
|
|
});
|
|
}
|
|
|
|
saveRelationship(
|
|
companyId: UniqueID,
|
|
action: Parameters<IAssetRepository["saveRelationship"]>[1]
|
|
) {
|
|
return this.database.transaction(async (transaction) => {
|
|
const loaded = await this.load(companyId, transaction, true);
|
|
|
|
if (!loaded.isSuccess) {
|
|
return Result.fail(loaded.error);
|
|
}
|
|
const changed = action(loaded.data);
|
|
|
|
if (!changed.isSuccess) {
|
|
return Result.fail(changed.error);
|
|
}
|
|
const previous = loaded.data.relationships.find((item) => item.id.equals(changed.data.id));
|
|
|
|
if (previous !== changed.data) {
|
|
await RelationshipModel.upsert(
|
|
new AssetRelationshipPersistenceMapper().toPersistence(changed.data),
|
|
{ transaction }
|
|
);
|
|
}
|
|
|
|
return Result.ok({ relationship: changed.data, actorNames: loaded.data.actorNames });
|
|
});
|
|
}
|
|
|
|
get(companyId: UniqueID) {
|
|
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
|
|
}
|
|
|
|
save(companyId: UniqueID, action: Parameters<IAssetRepository["save"]>[1]) {
|
|
return this.database.transaction(async (transaction) => {
|
|
const loaded = await this.load(companyId, transaction, true);
|
|
|
|
if (!loaded.isSuccess) {
|
|
return Result.fail(loaded.error);
|
|
}
|
|
const changed = action(loaded.data);
|
|
|
|
if (!changed.isSuccess) {
|
|
return Result.fail(changed.error);
|
|
}
|
|
const previous = loaded.data.assets.find((asset) => asset.id.equals(changed.data.id));
|
|
|
|
if (changed.data !== previous) {
|
|
const rows = this.mapper.toPersistence(changed.data);
|
|
const existing = new Set(previous?.history.map((revision) => revision.id.toPrimitive()));
|
|
|
|
await writeInventory(this.database, rows, existing, transaction);
|
|
}
|
|
|
|
return Result.ok({ asset: changed.data, actorNames: loaded.data.actorNames });
|
|
});
|
|
}
|
|
}
|