# ET-CATALOG-BACKEND: Servicios y API REST

## Identificacion

| Campo | Valor |
|-------|-------|
| **ID** | ET-CATALOG-BACKEND |
| **Modulo** | MGN-005 Catalogs |
| **Version** | 1.0 |
| **Estado** | En Diseno |
| **Framework** | NestJS |
| **Autor** | Requirements-Analyst |
| **Fecha** | 2025-12-05 |

---

## Descripcion General

Especificacion tecnica del modulo backend de Catalogs implementado en NestJS. Incluye servicios, controladores, DTOs y entidades TypeORM para la gestion de contactos, catalogos globales (paises, monedas) y catalogos por tenant (UoM, categorias).

### Estructura de Archivos

```
apps/backend/src/modules/catalogs/
├── catalogs.module.ts
├── controllers/
│   ├── contacts.controller.ts
│   ├── countries.controller.ts
│   ├── currencies.controller.ts
│   ├── uom.controller.ts
│   └── categories.controller.ts
├── services/
│   ├── contacts.service.ts
│   ├── countries.service.ts
│   ├── currencies.service.ts
│   ├── currency-rates.service.ts
│   ├── uom.service.ts
│   └── categories.service.ts
├── entities/
│   ├── contact.entity.ts
│   ├── contact-tag.entity.ts
│   ├── country.entity.ts
│   ├── state.entity.ts
│   ├── currency.entity.ts
│   ├── currency-rate.entity.ts
│   ├── uom-category.entity.ts
│   ├── uom.entity.ts
│   └── category.entity.ts
├── dto/
│   ├── contacts/
│   │   ├── create-contact.dto.ts
│   │   ├── update-contact.dto.ts
│   │   └── contact-query.dto.ts
│   ├── currencies/
│   │   ├── create-rate.dto.ts
│   │   └── convert-currency.dto.ts
│   ├── uom/
│   │   ├── create-uom.dto.ts
│   │   └── convert-uom.dto.ts
│   └── categories/
│       ├── create-category.dto.ts
│       └── update-category.dto.ts
└── interfaces/
    ├── contact.interface.ts
    └── catalog.interface.ts
```

---

## Entidades TypeORM

### Contact Entity

```typescript
// entities/contact.entity.ts
import {
  Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany,
  JoinColumn, CreateDateColumn, UpdateDateColumn, Index
} from 'typeorm';
import { TenantEntity } from '@core/entities/tenant.entity';
import { Country } from './country.entity';
import { State } from './state.entity';
import { Currency } from './currency.entity';
import { User } from '@modules/users/entities/user.entity';

export enum ContactType {
  COMPANY = 'company',
  INDIVIDUAL = 'individual',
  CONTACT = 'contact'
}

export enum ContactRole {
  CUSTOMER = 'customer',
  VENDOR = 'vendor',
  EMPLOYEE = 'employee',
  OTHER = 'other'
}

@Entity('contacts', { schema: 'core_catalogs' })
@Index(['tenantId', 'vat'], { unique: true, where: 'vat IS NOT NULL' })
@Index(['tenantId', 'ref'], { unique: true, where: 'ref IS NOT NULL' })
export class Contact extends TenantEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'parent_id', type: 'uuid', nullable: true })
  parentId: string;

  @ManyToOne(() => Contact, contact => contact.children, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'parent_id' })
  parent: Contact;

  @OneToMany(() => Contact, contact => contact.parent)
  children: Contact[];

  @Column({ length: 255 })
  name: string;

  @Column({ name: 'display_name', length: 500, nullable: true })
  displayName: string;

  @Column({
    name: 'contact_type',
    type: 'enum',
    enum: ContactType,
    default: ContactType.COMPANY
  })
  contactType: ContactType;

  @Column({ type: 'jsonb', default: [] })
  roles: ContactRole[];

  @Column({ length: 50, nullable: true })
  ref: string;

  @Column({ length: 50, nullable: true })
  vat: string;

  @Column({ length: 255, nullable: true })
  email: string;

  @Column({ length: 50, nullable: true })
  phone: string;

  @Column({ length: 50, nullable: true })
  mobile: string;

  @Column({ length: 255, nullable: true })
  website: string;

  @Column({ length: 255, nullable: true })
  street: string;

  @Column({ length: 255, nullable: true })
  street2: string;

  @Column({ length: 100, nullable: true })
  city: string;

  @Column({ length: 20, nullable: true })
  zip: string;

  @Column({ name: 'state_id', type: 'uuid', nullable: true })
  stateId: string;

  @ManyToOne(() => State)
  @JoinColumn({ name: 'state_id' })
  state: State;

  @Column({ name: 'country_id', type: 'uuid', nullable: true })
  countryId: string;

  @ManyToOne(() => Country)
  @JoinColumn({ name: 'country_id' })
  country: Country;

  @Column({ length: 100, nullable: true })
  function: string;

  @Column({ name: 'user_id', type: 'uuid', nullable: true, unique: true })
  userId: string;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'user_id' })
  user: User;

  @Column({ name: 'currency_id', type: 'uuid', nullable: true })
  currencyId: string;

  @ManyToOne(() => Currency)
  @JoinColumn({ name: 'currency_id' })
  currency: Currency;

  @Column({ name: 'payment_term_days', type: 'int', nullable: true })
  paymentTermDays: number;

  @Column({
    name: 'credit_limit',
    type: 'decimal',
    precision: 15,
    scale: 2,
    default: 0
  })
  creditLimit: number;

  @Column({ name: 'bank_accounts', type: 'jsonb', default: [] })
  bankAccounts: object[];

  @Column({ type: 'text', nullable: true })
  notes: string;

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;

  // Virtual: Tags through relation table
  tags?: ContactTag[];
}
```

### Currency Entity

```typescript
// entities/currency.entity.ts
import {
  Entity, PrimaryGeneratedColumn, Column,
  CreateDateColumn, UpdateDateColumn
} from 'typeorm';

export enum CurrencyPosition {
  BEFORE = 'before',
  AFTER = 'after'
}

@Entity('currencies', { schema: 'core_catalogs' })
export class Currency {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'char', length: 3, unique: true })
  name: string; // ISO code

  @Column({ name: 'full_name', length: 100 })
  fullName: string;

  @Column({ length: 10 })
  symbol: string;

  @Column({ name: 'iso_numeric', type: 'int', nullable: true })
  isoNumeric: number;

  @Column({ name: 'decimal_places', type: 'int', default: 2 })
  decimalPlaces: number;

  @Column({ type: 'decimal', precision: 10, scale: 6, default: 0.01 })
  rounding: number;

  @Column({
    type: 'enum',
    enum: CurrencyPosition,
    default: CurrencyPosition.BEFORE
  })
  position: CurrencyPosition;

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}
```

### UoM Entity

```typescript
// entities/uom.entity.ts
import {
  Entity, PrimaryGeneratedColumn, Column, ManyToOne,
  JoinColumn, CreateDateColumn, UpdateDateColumn
} from 'typeorm';
import { TenantEntity } from '@core/entities/tenant.entity';
import { UomCategory } from './uom-category.entity';

export enum UomType {
  REFERENCE = 'reference',
  BIGGER = 'bigger',
  SMALLER = 'smaller'
}

@Entity('uom', { schema: 'core_catalogs' })
export class Uom extends TenantEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'category_id', type: 'uuid' })
  categoryId: string;

  @ManyToOne(() => UomCategory, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'category_id' })
  category: UomCategory;

  @Column({ length: 50 })
  name: string;

  @Column({
    name: 'uom_type',
    type: 'enum',
    enum: UomType
  })
  uomType: UomType;

  @Column({ type: 'decimal', precision: 20, scale: 10, default: 1 })
  factor: number;

  @Column({ type: 'decimal', precision: 10, scale: 6, default: 0.01 })
  rounding: number;

  @Column({ name: 'is_protected', default: false })
  isProtected: boolean;

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}
```

---

## DTOs

### Create Contact DTO

```typescript
// dto/contacts/create-contact.dto.ts
import {
  IsString, IsEmail, IsOptional, IsEnum, IsArray,
  IsUUID, IsNumber, Min, MaxLength, ValidateNested
} from 'class-validator';
import { Type } from 'class-transformer';
import { ContactType, ContactRole } from '../entities/contact.entity';

class AddressDto {
  @IsOptional()
  @IsString()
  @MaxLength(255)
  street?: string;

  @IsOptional()
  @IsString()
  @MaxLength(255)
  street2?: string;

  @IsOptional()
  @IsString()
  @MaxLength(100)
  city?: string;

  @IsOptional()
  @IsString()
  @MaxLength(20)
  zip?: string;

  @IsOptional()
  @IsUUID()
  stateId?: string;

  @IsOptional()
  @IsUUID()
  countryId?: string;
}

export class CreateContactDto {
  @IsString()
  @MaxLength(255)
  name: string;

  @IsEnum(ContactType)
  contactType: ContactType;

  @IsOptional()
  @IsArray()
  @IsEnum(ContactRole, { each: true })
  roles?: ContactRole[];

  @IsOptional()
  @IsUUID()
  parentId?: string;

  @IsOptional()
  @IsString()
  @MaxLength(50)
  ref?: string;

  @IsOptional()
  @IsString()
  @MaxLength(50)
  vat?: string;

  @IsOptional()
  @IsEmail()
  email?: string;

  @IsOptional()
  @IsString()
  @MaxLength(50)
  phone?: string;

  @IsOptional()
  @IsString()
  @MaxLength(50)
  mobile?: string;

  @IsOptional()
  @IsString()
  @MaxLength(255)
  website?: string;

  @IsOptional()
  @ValidateNested()
  @Type(() => AddressDto)
  address?: AddressDto;

  @IsOptional()
  @IsString()
  @MaxLength(100)
  function?: string;

  @IsOptional()
  @IsUUID()
  currencyId?: string;

  @IsOptional()
  @IsNumber()
  @Min(0)
  paymentTermDays?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  creditLimit?: number;

  @IsOptional()
  @IsArray()
  @IsUUID(undefined, { each: true })
  tagIds?: string[];
}
```

### Contact Query DTO

```typescript
// dto/contacts/contact-query.dto.ts
import { IsOptional, IsEnum, IsString, IsBoolean, IsArray } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { PaginationDto } from '@core/dto/pagination.dto';
import { ContactType, ContactRole } from '../entities/contact.entity';

export class ContactQueryDto extends PaginationDto {
  @IsOptional()
  @IsEnum(ContactType)
  type?: ContactType;

  @IsOptional()
  @IsArray()
  @IsEnum(ContactRole, { each: true })
  @Transform(({ value }) => value.split(','))
  roles?: ContactRole[];

  @IsOptional()
  @IsString()
  search?: string;

  @IsOptional()
  @Transform(({ value }) => value === 'true')
  @IsBoolean()
  isCustomer?: boolean;

  @IsOptional()
  @Transform(({ value }) => value === 'true')
  @IsBoolean()
  isVendor?: boolean;

  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  @Transform(({ value }) => value.split(','))
  tags?: string[];

  @IsOptional()
  @Transform(({ value }) => value === 'true')
  @IsBoolean()
  includeChildren?: boolean;
}
```

---

## Servicios

### ContactsService

```typescript
// services/contacts.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, ILike, In, Brackets } from 'typeorm';
import { Contact, ContactType, ContactRole } from '../entities/contact.entity';
import { ContactTag } from '../entities/contact-tag.entity';
import { CreateContactDto } from '../dto/contacts/create-contact.dto';
import { UpdateContactDto } from '../dto/contacts/update-contact.dto';
import { ContactQueryDto } from '../dto/contacts/contact-query.dto';
import { TenantContext } from '@core/decorators/tenant.decorator';
import { PaginatedResult } from '@core/interfaces/pagination.interface';

@Injectable()
export class ContactsService {
  constructor(
    @InjectRepository(Contact)
    private readonly contactRepo: Repository<Contact>,
    @InjectRepository(ContactTag)
    private readonly tagRepo: Repository<ContactTag>,
  ) {}

  async create(
    dto: CreateContactDto,
    @TenantContext() tenantId: string
  ): Promise<Contact> {
    // Validar que contactos tipo 'contact' tengan parent
    if (dto.contactType === ContactType.CONTACT && !dto.parentId) {
      throw new ConflictException('Contact type requires a parent company');
    }

    // Validar unicidad de VAT
    if (dto.vat) {
      const existing = await this.contactRepo.findOne({
        where: { tenantId, vat: dto.vat }
      });
      if (existing) {
        throw new ConflictException('VAT already exists for this tenant');
      }
    }

    // Validar unicidad de REF
    if (dto.ref) {
      const existing = await this.contactRepo.findOne({
        where: { tenantId, ref: dto.ref }
      });
      if (existing) {
        throw new ConflictException('REF already exists for this tenant');
      }
    }

    const contact = this.contactRepo.create({
      ...dto,
      tenantId,
      street: dto.address?.street,
      street2: dto.address?.street2,
      city: dto.address?.city,
      zip: dto.address?.zip,
      stateId: dto.address?.stateId,
      countryId: dto.address?.countryId,
    });

    const saved = await this.contactRepo.save(contact);

    // Asignar tags si se proporcionaron
    if (dto.tagIds?.length) {
      await this.assignTags(saved.id, dto.tagIds);
    }

    return this.findOne(saved.id, tenantId);
  }

  async findAll(
    query: ContactQueryDto,
    @TenantContext() tenantId: string
  ): Promise<PaginatedResult<Contact>> {
    const qb = this.contactRepo
      .createQueryBuilder('contact')
      .where('contact.tenant_id = :tenantId', { tenantId })
      .leftJoinAndSelect('contact.country', 'country')
      .leftJoinAndSelect('contact.state', 'state')
      .leftJoinAndSelect('contact.currency', 'currency');

    // Filtro por tipo
    if (query.type) {
      qb.andWhere('contact.contact_type = :type', { type: query.type });
    }

    // Filtro por roles
    if (query.isCustomer) {
      qb.andWhere("contact.roles @> :customerRole", {
        customerRole: JSON.stringify([ContactRole.CUSTOMER])
      });
    }
    if (query.isVendor) {
      qb.andWhere("contact.roles @> :vendorRole", {
        vendorRole: JSON.stringify([ContactRole.VENDOR])
      });
    }

    // Busqueda full-text
    if (query.search) {
      qb.andWhere(new Brackets(sub => {
        sub.where('contact.name ILIKE :search', { search: `%${query.search}%` })
           .orWhere('contact.email ILIKE :search', { search: `%${query.search}%` })
           .orWhere('contact.vat ILIKE :search', { search: `%${query.search}%` })
           .orWhere('contact.ref ILIKE :search', { search: `%${query.search}%` });
      }));
    }

    // Filtro por tags
    if (query.tags?.length) {
      qb.innerJoin('core_catalogs.contact_tag_rel', 'ctr', 'ctr.contact_id = contact.id')
        .andWhere('ctr.tag_id IN (:...tagIds)', { tagIds: query.tags });
    }

    // Incluir hijos
    if (query.includeChildren) {
      qb.leftJoinAndSelect('contact.children', 'children');
    }

    // Paginacion
    const [data, total] = await qb
      .orderBy('contact.name', 'ASC')
      .skip((query.page - 1) * query.limit)
      .take(query.limit)
      .getManyAndCount();

    return {
      data,
      meta: {
        total,
        page: query.page,
        limit: query.limit,
        totalPages: Math.ceil(total / query.limit)
      }
    };
  }

  async findOne(id: string, @TenantContext() tenantId: string): Promise<Contact> {
    const contact = await this.contactRepo.findOne({
      where: { id, tenantId },
      relations: ['country', 'state', 'currency', 'parent', 'children']
    });

    if (!contact) {
      throw new NotFoundException(`Contact ${id} not found`);
    }

    // Cargar tags
    const tags = await this.getContactTags(id);
    contact.tags = tags;

    return contact;
  }

  async update(
    id: string,
    dto: UpdateContactDto,
    @TenantContext() tenantId: string
  ): Promise<Contact> {
    const contact = await this.findOne(id, tenantId);

    // Validar unicidad de VAT si cambia
    if (dto.vat && dto.vat !== contact.vat) {
      const existing = await this.contactRepo.findOne({
        where: { tenantId, vat: dto.vat }
      });
      if (existing) {
        throw new ConflictException('VAT already exists');
      }
    }

    Object.assign(contact, dto);

    if (dto.address) {
      contact.street = dto.address.street ?? contact.street;
      contact.street2 = dto.address.street2 ?? contact.street2;
      contact.city = dto.address.city ?? contact.city;
      contact.zip = dto.address.zip ?? contact.zip;
      contact.stateId = dto.address.stateId ?? contact.stateId;
      contact.countryId = dto.address.countryId ?? contact.countryId;
    }

    await this.contactRepo.save(contact);

    if (dto.tagIds !== undefined) {
      await this.assignTags(id, dto.tagIds);
    }

    return this.findOne(id, tenantId);
  }

  async remove(id: string, @TenantContext() tenantId: string): Promise<void> {
    const contact = await this.findOne(id, tenantId);
    await this.contactRepo.remove(contact);
  }

  async assignTags(contactId: string, tagIds: string[]): Promise<void> {
    // Eliminar tags existentes
    await this.contactRepo.query(
      'DELETE FROM core_catalogs.contact_tag_rel WHERE contact_id = $1',
      [contactId]
    );

    // Insertar nuevos
    if (tagIds.length) {
      const values = tagIds.map(tagId => `('${contactId}', '${tagId}')`).join(',');
      await this.contactRepo.query(
        `INSERT INTO core_catalogs.contact_tag_rel (contact_id, tag_id) VALUES ${values}`
      );
    }
  }

  async getContactTags(contactId: string): Promise<ContactTag[]> {
    return this.tagRepo
      .createQueryBuilder('tag')
      .innerJoin('core_catalogs.contact_tag_rel', 'rel', 'rel.tag_id = tag.id')
      .where('rel.contact_id = :contactId', { contactId })
      .getMany();
  }

  async search(
    query: string,
    @TenantContext() tenantId: string,
    limit = 10
  ): Promise<Contact[]> {
    return this.contactRepo
      .createQueryBuilder('contact')
      .where('contact.tenant_id = :tenantId', { tenantId })
      .andWhere(new Brackets(sub => {
        sub.where('contact.name ILIKE :q', { q: `%${query}%` })
           .orWhere('contact.email ILIKE :q', { q: `%${query}%` })
           .orWhere('contact.vat ILIKE :q', { q: `%${query}%` });
      }))
      .orderBy('contact.name', 'ASC')
      .limit(limit)
      .getMany();
  }
}
```

### CurrencyRatesService

```typescript
// services/currency-rates.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThanOrEqual } from 'typeorm';
import { CurrencyRate } from '../entities/currency-rate.entity';
import { Currency } from '../entities/currency.entity';
import { CreateRateDto } from '../dto/currencies/create-rate.dto';
import { TenantContext } from '@core/decorators/tenant.decorator';

@Injectable()
export class CurrencyRatesService {
  constructor(
    @InjectRepository(CurrencyRate)
    private readonly rateRepo: Repository<CurrencyRate>,
    @InjectRepository(Currency)
    private readonly currencyRepo: Repository<Currency>,
  ) {}

  async createRate(
    dto: CreateRateDto,
    @TenantContext() tenantId: string
  ): Promise<CurrencyRate> {
    // Verificar que la moneda existe
    const currency = await this.currencyRepo.findOne({
      where: { id: dto.currencyId }
    });
    if (!currency) {
      throw new NotFoundException('Currency not found');
    }

    // Verificar que no exista tasa para esa fecha
    const existing = await this.rateRepo.findOne({
      where: {
        tenantId,
        currencyId: dto.currencyId,
        name: dto.date
      }
    });
    if (existing) {
      throw new BadRequestException('Rate already exists for this date');
    }

    const rate = this.rateRepo.create({
      tenantId,
      currencyId: dto.currencyId,
      rate: dto.rate,
      name: dto.date
    });

    return this.rateRepo.save(rate);
  }

  async getRate(
    currencyId: string,
    date: Date,
    @TenantContext() tenantId: string
  ): Promise<number> {
    const rate = await this.rateRepo.findOne({
      where: {
        tenantId,
        currencyId,
        name: LessThanOrEqual(date)
      },
      order: { name: 'DESC' }
    });

    return rate?.rate ?? 1;
  }

  async convert(
    amount: number,
    fromCurrencyId: string,
    toCurrencyId: string,
    date: Date,
    @TenantContext() tenantId: string
  ): Promise<{ amount: number; rate: number }> {
    if (fromCurrencyId === toCurrencyId) {
      return { amount, rate: 1 };
    }

    const fromRate = await this.getRate(fromCurrencyId, date, tenantId);
    const toRate = await this.getRate(toCurrencyId, date, tenantId);

    const rate = toRate / fromRate;
    const convertedAmount = amount * rate;

    return {
      amount: convertedAmount,
      rate
    };
  }

  async getRateHistory(
    currencyId: string,
    fromDate: Date,
    toDate: Date,
    @TenantContext() tenantId: string
  ): Promise<CurrencyRate[]> {
    return this.rateRepo
      .createQueryBuilder('rate')
      .where('rate.tenant_id = :tenantId', { tenantId })
      .andWhere('rate.currency_id = :currencyId', { currencyId })
      .andWhere('rate.name BETWEEN :from AND :to', { from: fromDate, to: toDate })
      .orderBy('rate.name', 'DESC')
      .getMany();
  }
}
```

### UomService

```typescript
// services/uom.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Uom, UomType } from '../entities/uom.entity';
import { UomCategory } from '../entities/uom-category.entity';
import { CreateUomDto } from '../dto/uom/create-uom.dto';
import { TenantContext } from '@core/decorators/tenant.decorator';

@Injectable()
export class UomService {
  constructor(
    @InjectRepository(Uom)
    private readonly uomRepo: Repository<Uom>,
    @InjectRepository(UomCategory)
    private readonly categoryRepo: Repository<UomCategory>,
  ) {}

  async createCategory(
    name: string,
    @TenantContext() tenantId: string
  ): Promise<UomCategory> {
    const category = this.categoryRepo.create({ tenantId, name });
    return this.categoryRepo.save(category);
  }

  async getCategories(@TenantContext() tenantId: string): Promise<UomCategory[]> {
    return this.categoryRepo.find({
      where: { tenantId, isActive: true },
      order: { name: 'ASC' }
    });
  }

  async createUom(
    dto: CreateUomDto,
    @TenantContext() tenantId: string
  ): Promise<Uom> {
    // Validar categoria existe
    const category = await this.categoryRepo.findOne({
      where: { id: dto.categoryId, tenantId }
    });
    if (!category) {
      throw new NotFoundException('Category not found');
    }

    // Validar que solo haya una reference por categoria
    if (dto.uomType === UomType.REFERENCE) {
      const existing = await this.uomRepo.findOne({
        where: { categoryId: dto.categoryId, uomType: UomType.REFERENCE }
      });
      if (existing) {
        throw new BadRequestException('Category already has a reference UoM');
      }
    }

    // Validar que reference tenga factor = 1
    if (dto.uomType === UomType.REFERENCE && dto.factor !== 1) {
      throw new BadRequestException('Reference UoM must have factor = 1');
    }

    const uom = this.uomRepo.create({
      ...dto,
      tenantId
    });

    return this.uomRepo.save(uom);
  }

  async getByCategory(
    categoryId: string,
    @TenantContext() tenantId: string
  ): Promise<Uom[]> {
    return this.uomRepo.find({
      where: { categoryId, tenantId, isActive: true },
      order: { uomType: 'ASC', name: 'ASC' }
    });
  }

  async convert(
    quantity: number,
    fromUomId: string,
    toUomId: string
  ): Promise<{ quantity: number; factor: number }> {
    if (fromUomId === toUomId) {
      return { quantity, factor: 1 };
    }

    const fromUom = await this.uomRepo.findOne({ where: { id: fromUomId } });
    const toUom = await this.uomRepo.findOne({ where: { id: toUomId } });

    if (!fromUom || !toUom) {
      throw new NotFoundException('UoM not found');
    }

    if (fromUom.categoryId !== toUom.categoryId) {
      throw new BadRequestException('Cannot convert between different categories');
    }

    const factor = Number(fromUom.factor) / Number(toUom.factor);
    const converted = quantity * factor;

    // Redondear segun precision de destino
    const rounded = Math.round(converted / Number(toUom.rounding)) * Number(toUom.rounding);

    return { quantity: rounded, factor };
  }

  async round(quantity: number, uomId: string): Promise<number> {
    const uom = await this.uomRepo.findOne({ where: { id: uomId } });
    if (!uom) {
      throw new NotFoundException('UoM not found');
    }

    return Math.round(quantity / Number(uom.rounding)) * Number(uom.rounding);
  }
}
```

---

## Controladores

### ContactsController

```typescript
// controllers/contacts.controller.ts
import {
  Controller, Get, Post, Put, Delete, Param, Body, Query,
  UseGuards, ParseUUIDPipe, HttpCode, HttpStatus
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { RbacGuard } from '@modules/rbac/guards/rbac.guard';
import { Permissions } from '@modules/rbac/decorators/permissions.decorator';
import { TenantId } from '@core/decorators/tenant.decorator';
import { ContactsService } from '../services/contacts.service';
import { CreateContactDto } from '../dto/contacts/create-contact.dto';
import { UpdateContactDto } from '../dto/contacts/update-contact.dto';
import { ContactQueryDto } from '../dto/contacts/contact-query.dto';

@ApiTags('Contacts')
@ApiBearerAuth()
@Controller('contacts')
@UseGuards(JwtAuthGuard, RbacGuard)
export class ContactsController {
  constructor(private readonly contactsService: ContactsService) {}

  @Post()
  @ApiOperation({ summary: 'Create a new contact' })
  @Permissions('catalogs.contacts.create')
  async create(
    @Body() dto: CreateContactDto,
    @TenantId() tenantId: string
  ) {
    return this.contactsService.create(dto, tenantId);
  }

  @Get()
  @ApiOperation({ summary: 'List contacts with filters' })
  @Permissions('catalogs.contacts.read')
  async findAll(
    @Query() query: ContactQueryDto,
    @TenantId() tenantId: string
  ) {
    return this.contactsService.findAll(query, tenantId);
  }

  @Get('search')
  @ApiOperation({ summary: 'Quick search contacts' })
  @Permissions('catalogs.contacts.read')
  async search(
    @Query('q') query: string,
    @Query('limit') limit: number = 10,
    @TenantId() tenantId: string
  ) {
    return this.contactsService.search(query, tenantId, limit);
  }

  @Get(':id')
  @ApiOperation({ summary: 'Get contact by ID' })
  @Permissions('catalogs.contacts.read')
  async findOne(
    @Param('id', ParseUUIDPipe) id: string,
    @TenantId() tenantId: string
  ) {
    return this.contactsService.findOne(id, tenantId);
  }

  @Put(':id')
  @ApiOperation({ summary: 'Update contact' })
  @Permissions('catalogs.contacts.update')
  async update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateContactDto,
    @TenantId() tenantId: string
  ) {
    return this.contactsService.update(id, dto, tenantId);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  @ApiOperation({ summary: 'Delete contact' })
  @Permissions('catalogs.contacts.delete')
  async remove(
    @Param('id', ParseUUIDPipe) id: string,
    @TenantId() tenantId: string
  ) {
    await this.contactsService.remove(id, tenantId);
  }

  @Post(':id/tags')
  @ApiOperation({ summary: 'Assign tags to contact' })
  @Permissions('catalogs.contacts.update')
  async assignTags(
    @Param('id', ParseUUIDPipe) id: string,
    @Body('tagIds') tagIds: string[],
    @TenantId() tenantId: string
  ) {
    await this.contactsService.findOne(id, tenantId); // Validar acceso
    await this.contactsService.assignTags(id, tagIds);
    return { success: true };
  }
}
```

### CurrenciesController

```typescript
// controllers/currencies.controller.ts
import {
  Controller, Get, Post, Query, Body, Param,
  UseGuards, ParseUUIDPipe
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
import { TenantId } from '@core/decorators/tenant.decorator';
import { CurrenciesService } from '../services/currencies.service';
import { CurrencyRatesService } from '../services/currency-rates.service';
import { CreateRateDto } from '../dto/currencies/create-rate.dto';

@ApiTags('Currencies')
@ApiBearerAuth()
@Controller('currencies')
@UseGuards(JwtAuthGuard)
export class CurrenciesController {
  constructor(
    private readonly currenciesService: CurrenciesService,
    private readonly ratesService: CurrencyRatesService
  ) {}

  @Get()
  @ApiOperation({ summary: 'List all currencies (global)' })
  async findAll(@Query('active') active?: boolean) {
    return this.currenciesService.findAll(active);
  }

  @Get('rates')
  @ApiOperation({ summary: 'Get rate history for a currency' })
  async getRates(
    @Query('currencyId', ParseUUIDPipe) currencyId: string,
    @Query('from') from: string,
    @Query('to') to: string,
    @TenantId() tenantId: string
  ) {
    return this.ratesService.getRateHistory(
      currencyId,
      new Date(from),
      new Date(to),
      tenantId
    );
  }

  @Post('rates')
  @ApiOperation({ summary: 'Register a currency rate' })
  async createRate(
    @Body() dto: CreateRateDto,
    @TenantId() tenantId: string
  ) {
    return this.ratesService.createRate(dto, tenantId);
  }

  @Get('convert')
  @ApiOperation({ summary: 'Convert amount between currencies' })
  async convert(
    @Query('from', ParseUUIDPipe) from: string,
    @Query('to', ParseUUIDPipe) to: string,
    @Query('amount') amount: number,
    @Query('date') date: string,
    @TenantId() tenantId: string
  ) {
    const result = await this.ratesService.convert(
      amount,
      from,
      to,
      date ? new Date(date) : new Date(),
      tenantId
    );

    return {
      from: { currencyId: from, amount },
      to: { currencyId: to, amount: result.amount },
      rate: result.rate,
      date: date ?? new Date().toISOString().split('T')[0]
    };
  }
}
```

---

## API Endpoints Summary

### Contacts

| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| POST | /api/v1/contacts | catalogs.contacts.create | Create contact |
| GET | /api/v1/contacts | catalogs.contacts.read | List contacts |
| GET | /api/v1/contacts/search | catalogs.contacts.read | Quick search |
| GET | /api/v1/contacts/:id | catalogs.contacts.read | Get contact |
| PUT | /api/v1/contacts/:id | catalogs.contacts.update | Update contact |
| DELETE | /api/v1/contacts/:id | catalogs.contacts.delete | Delete contact |
| POST | /api/v1/contacts/:id/tags | catalogs.contacts.update | Assign tags |

### Countries (Global - Read Only)

| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /api/v1/catalogs/countries | - | List countries |
| GET | /api/v1/catalogs/countries/:code | - | Get country |
| GET | /api/v1/catalogs/countries/:code/states | - | Get states |
| GET | /api/v1/catalogs/countries/search | - | Search countries |

### Currencies

| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /api/v1/currencies | - | List currencies |
| GET | /api/v1/currencies/rates | catalogs.rates.read | Rate history |
| POST | /api/v1/currencies/rates | catalogs.rates.create | Register rate |
| GET | /api/v1/currencies/convert | catalogs.rates.read | Convert amount |

### UoM

| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /api/v1/catalogs/uom-categories | catalogs.uom.read | List categories |
| POST | /api/v1/catalogs/uom-categories | catalogs.uom.create | Create category |
| GET | /api/v1/catalogs/uom | catalogs.uom.read | List UoMs |
| POST | /api/v1/catalogs/uom | catalogs.uom.create | Create UoM |
| GET | /api/v1/catalogs/uom/convert | catalogs.uom.read | Convert quantity |

### Categories

| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /api/v1/catalogs/categories | catalogs.categories.read | List categories |
| POST | /api/v1/catalogs/categories | catalogs.categories.create | Create category |
| PUT | /api/v1/catalogs/categories/:id | catalogs.categories.update | Update category |
| DELETE | /api/v1/catalogs/categories/:id | catalogs.categories.delete | Delete category |

---

## Module Registration

```typescript
// catalogs.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

// Entities
import { Contact } from './entities/contact.entity';
import { ContactTag } from './entities/contact-tag.entity';
import { Country } from './entities/country.entity';
import { State } from './entities/state.entity';
import { Currency } from './entities/currency.entity';
import { CurrencyRate } from './entities/currency-rate.entity';
import { UomCategory } from './entities/uom-category.entity';
import { Uom } from './entities/uom.entity';
import { Category } from './entities/category.entity';

// Services
import { ContactsService } from './services/contacts.service';
import { CountriesService } from './services/countries.service';
import { CurrenciesService } from './services/currencies.service';
import { CurrencyRatesService } from './services/currency-rates.service';
import { UomService } from './services/uom.service';
import { CategoriesService } from './services/categories.service';

// Controllers
import { ContactsController } from './controllers/contacts.controller';
import { CountriesController } from './controllers/countries.controller';
import { CurrenciesController } from './controllers/currencies.controller';
import { UomController } from './controllers/uom.controller';
import { CategoriesController } from './controllers/categories.controller';

@Module({
  imports: [
    TypeOrmModule.forFeature([
      Contact,
      ContactTag,
      Country,
      State,
      Currency,
      CurrencyRate,
      UomCategory,
      Uom,
      Category,
    ]),
  ],
  controllers: [
    ContactsController,
    CountriesController,
    CurrenciesController,
    UomController,
    CategoriesController,
  ],
  providers: [
    ContactsService,
    CountriesService,
    CurrenciesService,
    CurrencyRatesService,
    UomService,
    CategoriesService,
  ],
  exports: [
    ContactsService,
    CountriesService,
    CurrenciesService,
    CurrencyRatesService,
    UomService,
    CategoriesService,
  ],
})
export class CatalogsModule {}
```

---

## Historial de Cambios

| Version | Fecha | Autor | Cambios |
|---------|-------|-------|---------|
| 1.0 | 2025-12-05 | Requirements-Analyst | Creacion inicial |

---

## Aprobaciones

| Rol | Nombre | Fecha | Firma |
|-----|--------|-------|-------|
| Tech Lead | - | - | [ ] |
| Backend Lead | - | - | [ ] |
