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

@@ -6,6 +6,7 @@ export * as sessionsRepo from "./repositories/sessions.js";
export * as auditRepo from "./repositories/audit.js";
export * as companiesRepo from "./repositories/companies.js";
export * as productsRepo from "./repositories/products.js";
export * as productCatalogsRepo from "./repositories/product-catalogs.js";
export * as resellersRepo from "./repositories/resellers.js";
export * as quotesRepo from "./repositories/quotes.js";
export * as contractsRepo from "./repositories/contracts.js";
@@ -34,6 +35,26 @@ export {
export type * from "./repositories/types.js";
export type { Company } from "./repositories/companies.js";
export type { Product, CreateProductInput, UpdateProductInput } from "./repositories/products.js";
export type {
SimpleCatalogRow,
CreateSimpleCatalogEntryInput,
UpdateSimpleCatalogEntryInput,
ProductType,
CreateProductTypeInput,
UpdateProductTypeInput,
ProductGroup,
CreateProductGroupInput,
UpdateProductGroupInput,
ProductSubgroup,
CreateProductSubgroupInput,
UpdateProductSubgroupInput,
Brand,
CreateBrandInput,
UpdateBrandInput,
Unit,
CreateUnitInput,
UpdateUnitInput,
} from "./repositories/product-catalogs.js";
export type { Reseller, CreateResellerInput, UpdateResellerInput } from "./repositories/resellers.js";
export type {
Lead,

View File

@@ -0,0 +1,219 @@
import { query } from "../pool.js";
/**
* Products bounded context — Tipo/Grupo/Subgrupo/Marca/Unidade catalogs
* (1789694035000_product-catalogs.cjs). Same "simple catalog" shape as
* fiscal_ncm/fiscal_cfop (see repositories/fiscal.ts) minus the sync-log
* columns: these five catalogs are always manually curated, never
* imported. No physical delete anywhere here — `active=false` is the
* "apagar", same convention as the fiscal catalogs.
*/
export interface SimpleCatalogRow {
id: string;
name: string;
active: boolean;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
}
export interface CreateSimpleCatalogEntryInput {
name: string;
active?: boolean;
createdBy?: string | null;
}
export interface UpdateSimpleCatalogEntryInput {
name?: string;
active?: boolean;
updatedBy?: string | null;
}
function buildCreateQuery(table: string) {
return `INSERT INTO ${table} (name, active, created_by, updated_by)
VALUES ($1, COALESCE($2, true), $3, $3)
RETURNING *`;
}
function buildUpdateQuery(table: string) {
return `UPDATE ${table} SET
name = COALESCE($2, name),
active = COALESCE($3, active),
updated_by = COALESCE($4, updated_by)
WHERE id = $1
RETURNING *`;
}
async function listSimpleCatalog(table: string, includeInactive: boolean): Promise<SimpleCatalogRow[]> {
const { rows } = await query<SimpleCatalogRow>(
includeInactive
? `SELECT * FROM ${table} ORDER BY name ASC`
: `SELECT * FROM ${table} WHERE active = true ORDER BY name ASC`,
);
return rows;
}
async function getSimpleCatalogById(table: string, id: string): Promise<SimpleCatalogRow | null> {
const { rows } = await query<SimpleCatalogRow>(`SELECT * FROM ${table} WHERE id = $1`, [id]);
return rows[0] ?? null;
}
async function createSimpleCatalogEntry(
table: string,
input: CreateSimpleCatalogEntryInput,
): Promise<SimpleCatalogRow> {
const { rows } = await query<SimpleCatalogRow>(buildCreateQuery(table), [
input.name,
input.active ?? null,
input.createdBy ?? null,
]);
const row = rows[0];
if (!row) throw new Error(`failed to create ${table} row`);
return row;
}
async function updateSimpleCatalogEntry(
table: string,
id: string,
input: UpdateSimpleCatalogEntryInput,
): Promise<SimpleCatalogRow | null> {
const { rows } = await query<SimpleCatalogRow>(buildUpdateQuery(table), [
id,
input.name ?? null,
input.active ?? null,
input.updatedBy ?? null,
]);
return rows[0] ?? null;
}
// ===========================================================================
// product_types
// ===========================================================================
export type ProductType = SimpleCatalogRow;
export type CreateProductTypeInput = CreateSimpleCatalogEntryInput;
export type UpdateProductTypeInput = UpdateSimpleCatalogEntryInput;
export const listProductTypes = (includeInactive = false) =>
listSimpleCatalog("product_types", includeInactive);
export const getProductTypeById = (id: string) => getSimpleCatalogById("product_types", id);
export const createProductType = (input: CreateProductTypeInput) =>
createSimpleCatalogEntry("product_types", input);
export const updateProductType = (id: string, input: UpdateProductTypeInput) =>
updateSimpleCatalogEntry("product_types", id, input);
// ===========================================================================
// product_groups
// ===========================================================================
export type ProductGroup = SimpleCatalogRow;
export type CreateProductGroupInput = CreateSimpleCatalogEntryInput;
export type UpdateProductGroupInput = UpdateSimpleCatalogEntryInput;
export const listProductGroups = (includeInactive = false) =>
listSimpleCatalog("product_groups", includeInactive);
export const getProductGroupById = (id: string) => getSimpleCatalogById("product_groups", id);
export const createProductGroup = (input: CreateProductGroupInput) =>
createSimpleCatalogEntry("product_groups", input);
export const updateProductGroup = (id: string, input: UpdateProductGroupInput) =>
updateSimpleCatalogEntry("product_groups", id, input);
// ===========================================================================
// product_subgroups — always scoped to a group_id
// ===========================================================================
export interface ProductSubgroup extends SimpleCatalogRow {
group_id: string;
}
export interface CreateProductSubgroupInput extends CreateSimpleCatalogEntryInput {
groupId: string;
}
export type UpdateProductSubgroupInput = UpdateSimpleCatalogEntryInput;
export async function listProductSubgroups(
groupId?: string,
includeInactive = false,
): Promise<ProductSubgroup[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (groupId) {
params.push(groupId);
conditions.push(`group_id = $${params.length}`);
}
if (!includeInactive) {
conditions.push("active = true");
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<ProductSubgroup>(
`SELECT * FROM product_subgroups ${where} ORDER BY name ASC`,
params,
);
return rows;
}
export async function getProductSubgroupById(id: string): Promise<ProductSubgroup | null> {
const { rows } = await query<ProductSubgroup>("SELECT * FROM product_subgroups WHERE id = $1", [id]);
return rows[0] ?? null;
}
export async function createProductSubgroup(
input: CreateProductSubgroupInput,
): Promise<ProductSubgroup> {
const { rows } = await query<ProductSubgroup>(
`INSERT INTO product_subgroups (group_id, name, active, created_by, updated_by)
VALUES ($1, $2, COALESCE($3, true), $4, $4)
RETURNING *`,
[input.groupId, input.name, input.active ?? null, input.createdBy ?? null],
);
const row = rows[0];
if (!row) throw new Error("failed to create product_subgroups row");
return row;
}
export async function updateProductSubgroup(
id: string,
input: UpdateProductSubgroupInput,
): Promise<ProductSubgroup | null> {
const { rows } = await query<ProductSubgroup>(
`UPDATE product_subgroups SET
name = COALESCE($2, name),
active = COALESCE($3, active),
updated_by = COALESCE($4, updated_by)
WHERE id = $1
RETURNING *`,
[id, input.name ?? null, input.active ?? null, input.updatedBy ?? null],
);
return rows[0] ?? null;
}
// ===========================================================================
// brands
// ===========================================================================
export type Brand = SimpleCatalogRow;
export type CreateBrandInput = CreateSimpleCatalogEntryInput;
export type UpdateBrandInput = UpdateSimpleCatalogEntryInput;
export const listBrands = (includeInactive = false) => listSimpleCatalog("brands", includeInactive);
export const getBrandById = (id: string) => getSimpleCatalogById("brands", id);
export const createBrand = (input: CreateBrandInput) => createSimpleCatalogEntry("brands", input);
export const updateBrand = (id: string, input: UpdateBrandInput) =>
updateSimpleCatalogEntry("brands", id, input);
// ===========================================================================
// units
// ===========================================================================
export type Unit = SimpleCatalogRow;
export type CreateUnitInput = CreateSimpleCatalogEntryInput;
export type UpdateUnitInput = UpdateSimpleCatalogEntryInput;
export const listUnits = (includeInactive = false) => listSimpleCatalog("units", includeInactive);
export const getUnitById = (id: string) => getSimpleCatalogById("units", id);
export const createUnit = (input: CreateUnitInput) => createSimpleCatalogEntry("units", input);
export const updateUnit = (id: string, input: UpdateUnitInput) =>
updateSimpleCatalogEntry("units", id, input);

View File

@@ -1,6 +1,12 @@
import { query } from "../pool.js";
import type { ServiceType, TariffRates } from "@eden/domain-shared";
/**
* `*_name` columns are resolved via JOIN against the product_types/
* product_groups/product_subgroups/brands/units catalogs
* (1789694035000_product-catalogs.cjs) — never re-fetched by ID
* separately, so a list/detail response is always self-contained.
*/
export interface Product {
id: string;
created_at: Date;
@@ -9,11 +15,16 @@ export interface Product {
product_code: number;
name: string;
description: string | null;
product_type: string;
product_group: string | null;
product_subgroup: string | null;
brand: string | null;
unit: string;
product_type_id: string;
product_type_name: string;
product_group_id: string | null;
product_group_name: string | null;
product_subgroup_id: string | null;
product_subgroup_name: string | null;
brand_id: string | null;
brand_name: string | null;
unit_id: string;
unit_name: string;
active: boolean;
discontinued: boolean;
tracks_stock: boolean;
@@ -33,28 +44,44 @@ export interface Product {
has_ldi: boolean;
}
const SELECT_PRODUCT = `
SELECT
p.*,
pt.name AS product_type_name,
pg.name AS product_group_name,
psg.name AS product_subgroup_name,
b.name AS brand_name,
u.name AS unit_name
FROM products p
JOIN product_types pt ON pt.id = p.product_type_id
LEFT JOIN product_groups pg ON pg.id = p.product_group_id
LEFT JOIN product_subgroups psg ON psg.id = p.product_subgroup_id
LEFT JOIN brands b ON b.id = p.brand_id
JOIN units u ON u.id = p.unit_id
`;
export async function listProducts(includeInactive = false): Promise<Product[]> {
const { rows } = await query<Product>(
includeInactive
? "SELECT * FROM products ORDER BY created_at ASC"
: "SELECT * FROM products WHERE active = true ORDER BY created_at ASC",
? `${SELECT_PRODUCT} ORDER BY p.created_at ASC`
: `${SELECT_PRODUCT} WHERE p.active = true ORDER BY p.created_at ASC`,
);
return rows;
}
export async function getProductById(id: string): Promise<Product | null> {
const { rows } = await query<Product>("SELECT * FROM products WHERE id = $1", [id]);
const { rows } = await query<Product>(`${SELECT_PRODUCT} WHERE p.id = $1`, [id]);
return rows[0] ?? null;
}
export interface CreateProductInput {
name: string;
description?: string | null;
productType: string;
productGroup?: string | null;
productSubgroup?: string | null;
brand?: string | null;
unit?: string;
productTypeId: string;
productGroupId?: string | null;
productSubgroupId?: string | null;
brandId?: string | null;
unitId: string;
active?: boolean;
discontinued?: boolean;
tracksStock?: boolean;
@@ -76,27 +103,27 @@ export interface CreateProductInput {
}
export async function createProduct(input: CreateProductInput): Promise<Product> {
const { rows } = await query<Product>(
const { rows } = await query<{ id: string }>(
`INSERT INTO products (
name, description, product_type, product_group, product_subgroup, brand, unit,
name, description, product_type_id, product_group_id, product_subgroup_id, brand_id, unit_id,
active, discontinued, tracks_stock, tracks_serial, tracks_asset_tag, tracks_mac,
price_0, price_12, price_24, price_36, price_48, impl_unit_price,
service_type, metered, minutes_allowance, tariff_rates, has_ldi, created_by
) VALUES (
$1,$2,$3,$4,$5,$6,COALESCE($7,'unidade'),
$1,$2,$3,$4,$5,$6,$7,
COALESCE($8,true),COALESCE($9,false),COALESCE($10,false),COALESCE($11,false),COALESCE($12,false),COALESCE($13,false),
$14,$15,$16,$17,$18,COALESCE($19,0),
COALESCE($20,'STFC'),COALESCE($21,false),$22,COALESCE($23,'{}'::jsonb),COALESCE($24,false),$25
)
RETURNING *`,
RETURNING id`,
[
input.name,
input.description ?? null,
input.productType,
input.productGroup ?? null,
input.productSubgroup ?? null,
input.brand ?? null,
input.unit ?? null,
input.productTypeId,
input.productGroupId ?? null,
input.productSubgroupId ?? null,
input.brandId ?? null,
input.unitId,
input.active ?? null,
input.discontinued ?? null,
input.tracksStock ?? null,
@@ -117,7 +144,9 @@ export async function createProduct(input: CreateProductInput): Promise<Product>
input.createdBy ?? null,
],
);
const product = rows[0];
const id = rows[0]?.id;
if (!id) throw new Error("failed to create product");
const product = await getProductById(id);
if (!product) throw new Error("failed to create product");
return product;
}
@@ -125,15 +154,15 @@ export async function createProduct(input: CreateProductInput): Promise<Product>
export type UpdateProductInput = Partial<Omit<CreateProductInput, "createdBy">>;
export async function updateProduct(id: string, input: UpdateProductInput): Promise<Product | null> {
const { rows } = await query<Product>(
const { rows } = await query<{ id: string }>(
`UPDATE products SET
name = COALESCE($2, name),
description = COALESCE($3, description),
product_type = COALESCE($4, product_type),
product_group = COALESCE($5, product_group),
product_subgroup = COALESCE($6, product_subgroup),
brand = COALESCE($7, brand),
unit = COALESCE($8, unit),
product_type_id = COALESCE($4, product_type_id),
product_group_id = COALESCE($5, product_group_id),
product_subgroup_id = COALESCE($6, product_subgroup_id),
brand_id = COALESCE($7, brand_id),
unit_id = COALESCE($8, unit_id),
active = COALESCE($9, active),
discontinued = COALESCE($10, discontinued),
tracks_stock = COALESCE($11, tracks_stock),
@@ -152,16 +181,16 @@ export async function updateProduct(id: string, input: UpdateProductInput): Prom
tariff_rates = COALESCE($24, tariff_rates),
has_ldi = COALESCE($25, has_ldi)
WHERE id = $1
RETURNING *`,
RETURNING id`,
[
id,
input.name ?? null,
input.description ?? null,
input.productType ?? null,
input.productGroup ?? null,
input.productSubgroup ?? null,
input.brand ?? null,
input.unit ?? null,
input.productTypeId ?? null,
input.productGroupId ?? null,
input.productSubgroupId ?? null,
input.brandId ?? null,
input.unitId ?? null,
input.active ?? null,
input.discontinued ?? null,
input.tracksStock ?? null,
@@ -181,5 +210,17 @@ export async function updateProduct(id: string, input: UpdateProductInput): Prom
input.hasLdi ?? null,
],
);
return rows[0] ?? null;
if (!rows[0]) return null;
return getProductById(id);
}
/**
* `updateProduct`'s COALESCE-based SET can't express "set to NULL" for a
* nullable column — a null parameter reads as "unchanged", same as
* omitting the field. An explicit subgroup removal needs its own
* statement; called by ProductsService right after `updateProduct` when
* `productSubgroupId === null` was explicitly requested.
*/
export async function clearProductSubgroup(id: string): Promise<void> {
await query(`UPDATE products SET product_subgroup_id = NULL WHERE id = $1`, [id]);
}