2026-10-10 09:41:42 +00:00
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 ;
} ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
export type RevisionRow = {
id : string ;
assetId : string ;
actorId : string ;
recordedAt : string ;
position : number ;
siteId : string | null ;
brandId : string | null ;
values : unknown ;
} ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
type AssetRow = Omit < InventoryRow , " values " | " specification " > & {
name : string ;
status : string ;
notes : string ;
brandDefined : boolean ;
templateName : string ;
sectionsDefined : boolean ;
} ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
type RevisionColumns = Omit < RevisionRow , " values " > & {
name : string ;
status : string ;
notes : string ;
brandDefined : boolean ;
templateId : string ;
} ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
type SpecificationField = Omit < AssetSpecificationData [ " fields " ] [ number ] , " type " | " choices " > & {
assetId : string ;
fieldType : string ;
position : number ;
} ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
type SpecificationSection = {
assetId : string ;
id : string ;
name : string ;
description : string ;
position : number ;
} ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
type SpecificationChoice = { assetId : string ; fieldId : string ; position : number ; value : string } ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
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
) ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
if ( populated . length !== 1 ) {
throw new Error ( "Valor tipado de activo persistido inconsistente." ) ;
}
switch ( row . valueType ) {
case "text" :
2026-10-10 20:22:28 +00:00
case "multiline" :
2026-10-10 09:41:42 +00:00
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 ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
if ( ! type ) {
throw new Error ( "Campo de activo sin especificación." ) ;
}
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
return {
assetId ,
. . . ( revisionId ? { revisionId } : { } ) ,
fieldId ,
valueType : type ,
2026-10-10 20:22:28 +00:00
textValue :
type === "text" || type === "multiline" || type === "choice" ? String ( value ) : null ,
2026-10-10 09:41:42 +00:00
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
) ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
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 ) ] )
) ,
} ) ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
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 )
) ,
} ) ) ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
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 ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
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 ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
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," : "" ;
2026-10-10 10:01:58 +00:00
2026-10-10 09:41:42 +00:00
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 }
) ;
}