feat(products): catálogos de Tipo/Grupo/Subgrupo/Marca/Unidade com submenu e dropdowns

Menu Produtos ganha submenu (AppShell agora suporta itens aninhados) para
cadastrar Tipos/Grupos/Marcas/Unidades; Subgrupo é gerenciado a partir da
tela do Grupo (sempre pertence a um). O cadastro de item passa a usar
dropdowns desses catálogos em vez de texto livre — exatamente o que o
comentário original da migration de products já previa como trabalho
futuro.

Backend: 5 tabelas de catálogo simples (mesmo padrão dos catálogos
fiscais), products ganha as FKs correspondentes com backfill, e
endpoints CRUD (sem exclusão física, active=false é o "apagar").

Corrige também um buraco de integridade real encontrado na integração:
PATCH /v1/products/:id usava COALESCE para os campos de catálogo
opcionais, então não dava para distinguir "não mexer" de "limpar" —
trocar o Grupo de um produto sem tocar no Subgrupo deixava o vínculo
antigo órfão e inconsistente, sem nenhuma validação pegando isso. Agora
a atualização valida o estado efetivo resultante (bloqueia com 400 em
vez de corromper o vínculo) e productSubgroupId: null limpa de verdade.

Suíte: 95/95 verdes (92→95). pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 23:46:14 -03:00
parent 9c14671e19
commit f38f2407ff
39 changed files with 2738 additions and 137 deletions

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
export class CreateBrandDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
export class CreateProductGroupDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -0,0 +1,12 @@
import { IsBoolean, IsOptional, IsUUID, MinLength, IsString } from "class-validator";
export class CreateProductSubgroupDto {
@IsString()
@MinLength(1)
name!: string;
@IsUUID()
groupId!: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
export class CreateProductTypeDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -1,4 +1,4 @@
import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, Min, MinLength, ValidateNested } from "class-validator";
import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min, MinLength, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { SERVICE_TYPES, type ServiceType } from "@eden/domain-shared";
import { TariffRatesDto } from "./tariff-rate.dto.js";
@@ -10,14 +10,19 @@ export class CreateProductDto {
@IsOptional() @IsString() description?: string;
@IsString()
@MinLength(1)
productType!: string;
@IsUUID()
productTypeId!: string;
@IsOptional() @IsString() productGroup?: string;
@IsOptional() @IsString() productSubgroup?: string;
@IsOptional() @IsString() brand?: string;
@IsOptional() @IsString() unit?: string;
@IsOptional() @IsUUID() productGroupId?: string;
// `null` is only meaningful on update (PATCH) — an explicit "remove the
// subgroup" instruction, distinct from omitting the field ("leave
// unchanged"). On create it's equivalent to omitting it. See
// ProductsService.assertSubgroupConsistent.
@IsOptional() @IsUUID() productSubgroupId?: string | null;
@IsOptional() @IsUUID() brandId?: string;
@IsUUID()
unitId!: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsBoolean() discontinued?: boolean;

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
export class CreateUnitDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateBrandDto } from "./create-brand.dto.js";
export class UpdateBrandDto extends PartialType(CreateBrandDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateProductGroupDto } from "./create-product-group.dto.js";
export class UpdateProductGroupDto extends PartialType(CreateProductGroupDto) {}

View File

@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
/** groupId is deliberately not patchable here — moving a subgroup to a different group is not part of this MVP; create a new subgroup under the target group instead. */
export class UpdateProductSubgroupDto {
@IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateProductTypeDto } from "./create-product-type.dto.js";
export class UpdateProductTypeDto extends PartialType(CreateProductTypeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateUnitDto } from "./create-unit.dto.js";
export class UpdateUnitDto extends PartialType(CreateUnitDto) {}

View File

@@ -3,6 +3,16 @@ import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ProductsService } from "./products.service.js";
import { CreateProductDto } from "./dto/create-product.dto.js";
import { UpdateProductDto } from "./dto/update-product.dto.js";
import { CreateProductTypeDto } from "./dto/create-product-type.dto.js";
import { UpdateProductTypeDto } from "./dto/update-product-type.dto.js";
import { CreateProductGroupDto } from "./dto/create-product-group.dto.js";
import { UpdateProductGroupDto } from "./dto/update-product-group.dto.js";
import { CreateProductSubgroupDto } from "./dto/create-product-subgroup.dto.js";
import { UpdateProductSubgroupDto } from "./dto/update-product-subgroup.dto.js";
import { CreateBrandDto } from "./dto/create-brand.dto.js";
import { UpdateBrandDto } from "./dto/update-brand.dto.js";
import { CreateUnitDto } from "./dto/create-unit.dto.js";
import { UpdateUnitDto } from "./dto/update-unit.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
@@ -16,6 +26,12 @@ import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
* for companies. Write requires products:edit. Backend always
* recalculates/validates: money fields are format-checked (non-negative)
* in the DTO but no pricing logic runs here — that's Quotes, a later phase.
*
* Tipo/Grupo/Subgrupo/Marca/Unidade sub-catalogs (product_types/
* product_groups/product_subgroups/brands/units) share this controller,
* same style as FiscalController grouping fiscal_ncm/fiscal_cfop/etc. — no
* physical delete for any of them in this MVP, `active=false` is the
* "apagar".
*/
@ApiTags("products")
@ApiBearerAuth()
@@ -29,6 +45,126 @@ export class ProductsController {
return this.products.list(includeInactive === "true");
}
// -- product_types --------------------------------------------------------
@Get("types")
listTypes(@Query("includeInactive") includeInactive?: string) {
return this.products.listProductTypes(includeInactive === "true");
}
@Post("types")
@RequirePermission("products", "edit")
createType(@Body() dto: CreateProductTypeDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.products.createProductType(dto, user);
}
@Patch("types/:id")
@RequirePermission("products", "edit")
updateType(
@Param("id") id: string,
@Body() dto: UpdateProductTypeDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.products.updateProductType(id, dto, user);
}
// -- product_groups ---------------------------------------------------------
@Get("groups")
listGroups(@Query("includeInactive") includeInactive?: string) {
return this.products.listProductGroups(includeInactive === "true");
}
@Post("groups")
@RequirePermission("products", "edit")
createGroup(@Body() dto: CreateProductGroupDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.products.createProductGroup(dto, user);
}
@Patch("groups/:id")
@RequirePermission("products", "edit")
updateGroup(
@Param("id") id: string,
@Body() dto: UpdateProductGroupDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.products.updateProductGroup(id, dto, user);
}
// -- product_subgroups --------------------------------------------------------
@Get("subgroups")
listSubgroups(
@Query("groupId") groupId?: string,
@Query("includeInactive") includeInactive?: string,
) {
return this.products.listProductSubgroups(groupId, includeInactive === "true");
}
@Post("subgroups")
@RequirePermission("products", "edit")
createSubgroup(@Body() dto: CreateProductSubgroupDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.products.createProductSubgroup(dto, user);
}
@Patch("subgroups/:id")
@RequirePermission("products", "edit")
updateSubgroup(
@Param("id") id: string,
@Body() dto: UpdateProductSubgroupDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.products.updateProductSubgroup(id, dto, user);
}
// -- brands -----------------------------------------------------------------
@Get("brands")
listBrands(@Query("includeInactive") includeInactive?: string) {
return this.products.listBrands(includeInactive === "true");
}
@Post("brands")
@RequirePermission("products", "edit")
createBrand(@Body() dto: CreateBrandDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.products.createBrand(dto, user);
}
@Patch("brands/:id")
@RequirePermission("products", "edit")
updateBrand(
@Param("id") id: string,
@Body() dto: UpdateBrandDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.products.updateBrand(id, dto, user);
}
// -- units --------------------------------------------------------------------
@Get("units")
listUnits(@Query("includeInactive") includeInactive?: string) {
return this.products.listUnits(includeInactive === "true");
}
@Post("units")
@RequirePermission("products", "edit")
createUnit(@Body() dto: CreateUnitDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.products.createUnit(dto, user);
}
@Patch("units/:id")
@RequirePermission("products", "edit")
updateUnit(
@Param("id") id: string,
@Body() dto: UpdateUnitDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.products.updateUnit(id, dto, user);
}
// -- products (:id must come after every static sub-route above) --------------
@Get(":id")
get(@Param("id") id: string) {
return this.products.get(id);

View File

@@ -1,8 +1,29 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { auditRepo, productsRepo, type Product } from "@eden/database";
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import {
auditRepo,
productsRepo,
productCatalogsRepo,
type Product,
type ProductType,
type ProductGroup,
type ProductSubgroup,
type Brand,
type Unit,
} from "@eden/database";
import { emptyTariffRates, type TariffRates } from "@eden/domain-shared";
import type { CreateProductDto } from "./dto/create-product.dto.js";
import type { UpdateProductDto } from "./dto/update-product.dto.js";
import type { CreateProductTypeDto } from "./dto/create-product-type.dto.js";
import type { UpdateProductTypeDto } from "./dto/update-product-type.dto.js";
import type { CreateProductGroupDto } from "./dto/create-product-group.dto.js";
import type { UpdateProductGroupDto } from "./dto/update-product-group.dto.js";
import type { CreateProductSubgroupDto } from "./dto/create-product-subgroup.dto.js";
import type { UpdateProductSubgroupDto } from "./dto/update-product-subgroup.dto.js";
import type { CreateBrandDto } from "./dto/create-brand.dto.js";
import type { UpdateBrandDto } from "./dto/update-brand.dto.js";
import type { CreateUnitDto } from "./dto/create-unit.dto.js";
import type { UpdateUnitDto } from "./dto/update-unit.dto.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/** DTO fields are individually optional (partial override); persisted tariff_rates always carries all 6 keys — mirrors the legacy's normalizeTariffRates(), never a partial object at rest. */
function normalizeTariffRates(partial: Partial<TariffRates> | undefined): TariffRates | undefined {
@@ -10,6 +31,10 @@ function normalizeTariffRates(partial: Partial<TariffRates> | undefined): Tariff
return { ...emptyTariffRates(), ...partial };
}
function isUniqueViolation(err: unknown): boolean {
return typeof err === "object" && err !== null && (err as { code?: string }).code === "23505";
}
@Injectable()
export class ProductsService {
list(includeInactive: boolean): Promise<Product[]> {
@@ -23,14 +48,17 @@ export class ProductsService {
}
async create(dto: CreateProductDto, actorId: string): Promise<Product> {
await this.assertCatalogReferencesValid(dto);
await this.assertSubgroupConsistent(dto, null);
const product = await productsRepo.createProduct({
name: dto.name,
description: dto.description,
productType: dto.productType,
productGroup: dto.productGroup,
productSubgroup: dto.productSubgroup,
brand: dto.brand,
unit: dto.unit,
productTypeId: dto.productTypeId,
productGroupId: dto.productGroupId,
productSubgroupId: dto.productSubgroupId,
brandId: dto.brandId,
unitId: dto.unitId,
active: dto.active,
discontinued: dto.discontinued,
tracksStock: dto.tracksStock,
@@ -63,16 +91,18 @@ export class ProductsService {
}
async update(id: string, dto: UpdateProductDto, actorId: string): Promise<Product> {
await this.get(id); // 404 if missing
const current = await this.get(id); // 404 if missing
await this.assertCatalogReferencesValid(dto);
await this.assertSubgroupConsistent(dto, current);
const product = await productsRepo.updateProduct(id, {
let product = await productsRepo.updateProduct(id, {
name: dto.name,
description: dto.description,
productType: dto.productType,
productGroup: dto.productGroup,
productSubgroup: dto.productSubgroup,
brand: dto.brand,
unit: dto.unit,
productTypeId: dto.productTypeId,
productGroupId: dto.productGroupId,
productSubgroupId: dto.productSubgroupId,
brandId: dto.brandId,
unitId: dto.unitId,
active: dto.active,
discontinued: dto.discontinued,
tracksStock: dto.tracksStock,
@@ -93,6 +123,15 @@ export class ProductsService {
});
if (!product) throw new NotFoundException("Produto não encontrado");
// `productSubgroupId: null` is an explicit clear — the COALESCE-based
// update above can't express "set to NULL" vs. "leave unchanged"
// (both collapse to the same SQL NULL parameter), so it's a deliberate
// no-op there and corrected here with a dedicated statement.
if (dto.productSubgroupId === null) {
await productsRepo.clearProductSubgroup(id);
product = (await productsRepo.getProductById(id)) ?? product;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
@@ -104,4 +143,358 @@ export class ProductsService {
return product;
}
/**
* Validates the simple (existence-only) catalog references on
* create/update: type/unit/group/brand must exist. Subgroup consistency
* is handled separately by `assertSubgroupConsistent` — it needs the
* product's current state on update, which this method doesn't have.
*/
private async assertCatalogReferencesValid(
dto: Partial<Pick<CreateProductDto, "productTypeId" | "unitId" | "productGroupId" | "brandId">>,
): Promise<void> {
if (dto.productTypeId !== undefined) {
const type = await productCatalogsRepo.getProductTypeById(dto.productTypeId);
if (!type) throw new BadRequestException("Tipo de produto não encontrado");
}
if (dto.unitId !== undefined) {
const unit = await productCatalogsRepo.getUnitById(dto.unitId);
if (!unit) throw new BadRequestException("Unidade não encontrada");
}
if (dto.brandId !== undefined) {
const brand = await productCatalogsRepo.getBrandById(dto.brandId);
if (!brand) throw new BadRequestException("Marca não encontrada");
}
if (dto.productGroupId !== undefined) {
const group = await productCatalogsRepo.getProductGroupById(dto.productGroupId);
if (!group) throw new BadRequestException("Grupo de produto não encontrado");
}
}
/**
* `productSubgroupId === null` is an explicit "remove the subgroup"
* instruction — always valid, never looked up. Anything else is
* resolved against the EFFECTIVE resulting state (the DTO's value where
* provided, falling back to the current product's value otherwise) so a
* PATCH that only changes `productGroupId` can't silently leave behind a
* subgroup that no longer belongs to the new group — it's rejected with
* a clear message instead of a corrupted invariant.
*/
private async assertSubgroupConsistent(
dto: Pick<UpdateProductDto, "productGroupId" | "productSubgroupId">,
current: Pick<Product, "product_group_id" | "product_subgroup_id"> | null,
): Promise<void> {
if (dto.productSubgroupId === null) return;
const effectiveGroupId = dto.productGroupId !== undefined ? dto.productGroupId : (current?.product_group_id ?? undefined);
const effectiveSubgroupId = dto.productSubgroupId !== undefined ? dto.productSubgroupId : (current?.product_subgroup_id ?? undefined);
if (!effectiveSubgroupId) return;
const subgroup = await productCatalogsRepo.getProductSubgroupById(effectiveSubgroupId);
if (!subgroup) throw new BadRequestException("Subgrupo de produto não encontrado");
if (!effectiveGroupId) {
throw new BadRequestException("Grupo de produto é obrigatório quando um subgrupo é informado");
}
if (subgroup.group_id !== effectiveGroupId) {
throw new BadRequestException(
dto.productGroupId !== undefined && dto.productSubgroupId === undefined
? "O novo grupo é incompatível com o subgrupo atual do produto — informe também um subgrupo válido para o novo grupo, ou envie productSubgroupId: null para removê-lo."
: "Subgrupo não pertence ao grupo informado",
);
}
}
// ---------------------------------------------------------------------
// product_types
// ---------------------------------------------------------------------
listProductTypes(includeInactive: boolean): Promise<ProductType[]> {
return productCatalogsRepo.listProductTypes(includeInactive);
}
async createProductType(dto: CreateProductTypeDto, user: EdenAuthenticatedUser): Promise<ProductType> {
let row: ProductType;
try {
row = await productCatalogsRepo.createProductType({
name: dto.name,
active: dto.active,
createdBy: user.id,
});
} catch (err) {
if (isUniqueViolation(err)) throw new ConflictException("Já existe um tipo de produto com esse nome");
throw err;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "product_type.created",
resourceType: "product_type",
resourceId: row.id,
});
return row;
}
async updateProductType(
id: string,
dto: UpdateProductTypeDto,
user: EdenAuthenticatedUser,
): Promise<ProductType> {
let row: ProductType | null;
try {
row = await productCatalogsRepo.updateProductType(id, {
name: dto.name,
active: dto.active,
updatedBy: user.id,
});
} catch (err) {
if (isUniqueViolation(err)) throw new ConflictException("Já existe um tipo de produto com esse nome");
throw err;
}
if (!row) throw new NotFoundException("Tipo de produto não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "product_type.updated",
resourceType: "product_type",
resourceId: id,
metadata: { ...dto },
});
return row;
}
// ---------------------------------------------------------------------
// product_groups
// ---------------------------------------------------------------------
listProductGroups(includeInactive: boolean): Promise<ProductGroup[]> {
return productCatalogsRepo.listProductGroups(includeInactive);
}
async createProductGroup(dto: CreateProductGroupDto, user: EdenAuthenticatedUser): Promise<ProductGroup> {
let row: ProductGroup;
try {
row = await productCatalogsRepo.createProductGroup({
name: dto.name,
active: dto.active,
createdBy: user.id,
});
} catch (err) {
if (isUniqueViolation(err)) throw new ConflictException("Já existe um grupo de produto com esse nome");
throw err;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "product_group.created",
resourceType: "product_group",
resourceId: row.id,
});
return row;
}
async updateProductGroup(
id: string,
dto: UpdateProductGroupDto,
user: EdenAuthenticatedUser,
): Promise<ProductGroup> {
let row: ProductGroup | null;
try {
row = await productCatalogsRepo.updateProductGroup(id, {
name: dto.name,
active: dto.active,
updatedBy: user.id,
});
} catch (err) {
if (isUniqueViolation(err)) throw new ConflictException("Já existe um grupo de produto com esse nome");
throw err;
}
if (!row) throw new NotFoundException("Grupo de produto não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "product_group.updated",
resourceType: "product_group",
resourceId: id,
metadata: { ...dto },
});
return row;
}
// ---------------------------------------------------------------------
// product_subgroups
// ---------------------------------------------------------------------
listProductSubgroups(groupId: string | undefined, includeInactive: boolean): Promise<ProductSubgroup[]> {
return productCatalogsRepo.listProductSubgroups(groupId, includeInactive);
}
async createProductSubgroup(
dto: CreateProductSubgroupDto,
user: EdenAuthenticatedUser,
): Promise<ProductSubgroup> {
const group = await productCatalogsRepo.getProductGroupById(dto.groupId);
if (!group) throw new BadRequestException("Grupo de produto não encontrado");
let row: ProductSubgroup;
try {
row = await productCatalogsRepo.createProductSubgroup({
groupId: dto.groupId,
name: dto.name,
active: dto.active,
createdBy: user.id,
});
} catch (err) {
if (isUniqueViolation(err)) {
throw new ConflictException("Já existe um subgrupo com esse nome nesse grupo");
}
throw err;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "product_subgroup.created",
resourceType: "product_subgroup",
resourceId: row.id,
metadata: { groupId: dto.groupId },
});
return row;
}
async updateProductSubgroup(
id: string,
dto: UpdateProductSubgroupDto,
user: EdenAuthenticatedUser,
): Promise<ProductSubgroup> {
let row: ProductSubgroup | null;
try {
row = await productCatalogsRepo.updateProductSubgroup(id, {
name: dto.name,
active: dto.active,
updatedBy: user.id,
});
} catch (err) {
if (isUniqueViolation(err)) {
throw new ConflictException("Já existe um subgrupo com esse nome nesse grupo");
}
throw err;
}
if (!row) throw new NotFoundException("Subgrupo de produto não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "product_subgroup.updated",
resourceType: "product_subgroup",
resourceId: id,
metadata: { ...dto },
});
return row;
}
// ---------------------------------------------------------------------
// brands
// ---------------------------------------------------------------------
listBrands(includeInactive: boolean): Promise<Brand[]> {
return productCatalogsRepo.listBrands(includeInactive);
}
async createBrand(dto: CreateBrandDto, user: EdenAuthenticatedUser): Promise<Brand> {
let row: Brand;
try {
row = await productCatalogsRepo.createBrand({
name: dto.name,
active: dto.active,
createdBy: user.id,
});
} catch (err) {
if (isUniqueViolation(err)) throw new ConflictException("Já existe uma marca com esse nome");
throw err;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "brand.created",
resourceType: "brand",
resourceId: row.id,
});
return row;
}
async updateBrand(id: string, dto: UpdateBrandDto, user: EdenAuthenticatedUser): Promise<Brand> {
let row: Brand | null;
try {
row = await productCatalogsRepo.updateBrand(id, {
name: dto.name,
active: dto.active,
updatedBy: user.id,
});
} catch (err) {
if (isUniqueViolation(err)) throw new ConflictException("Já existe uma marca com esse nome");
throw err;
}
if (!row) throw new NotFoundException("Marca não encontrada");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "brand.updated",
resourceType: "brand",
resourceId: id,
metadata: { ...dto },
});
return row;
}
// ---------------------------------------------------------------------
// units
// ---------------------------------------------------------------------
listUnits(includeInactive: boolean): Promise<Unit[]> {
return productCatalogsRepo.listUnits(includeInactive);
}
async createUnit(dto: CreateUnitDto, user: EdenAuthenticatedUser): Promise<Unit> {
let row: Unit;
try {
row = await productCatalogsRepo.createUnit({
name: dto.name,
active: dto.active,
createdBy: user.id,
});
} catch (err) {
if (isUniqueViolation(err)) throw new ConflictException("Já existe uma unidade com esse nome");
throw err;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "unit.created",
resourceType: "unit",
resourceId: row.id,
});
return row;
}
async updateUnit(id: string, dto: UpdateUnitDto, user: EdenAuthenticatedUser): Promise<Unit> {
let row: Unit | null;
try {
row = await productCatalogsRepo.updateUnit(id, {
name: dto.name,
active: dto.active,
updatedBy: user.id,
});
} catch (err) {
if (isUniqueViolation(err)) throw new ConflictException("Já existe uma unidade com esse nome");
throw err;
}
if (!row) throw new NotFoundException("Unidade não encontrada");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "unit.updated",
resourceType: "unit",
resourceId: id,
metadata: { ...dto },
});
return row;
}
}