Merge: Products/Catalog module (agente eden-commercial)

This commit is contained in:
2026-09-03 09:40:44 -03:00
7 changed files with 430 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
/**
* Commercial bounded context, first slice: products (sellable catalog
* items) — Master Prompt §6.3, eden.md §2 §1.1. MVP scope only: the shape
* below ports the legacy `products` table's core identity/pricing/flag
* columns faithfully (product_code sequence, the 5 fixed price tiers,
* impl_unit_price), but deliberately leaves out service_type/metered/
* tariff_rates/ixc_product_code — those are legacy telecom-billing
* specifics that belong to a later phase, not invented here. `product_type`
* is free text for now (no CHECK) since the enum of types isn't finalized;
* tightening it to a CHECK/lookup table is future work once Quotes needs
* to branch behavior on it.
*
* Quotes/pricing-tier selection logic is explicitly out of scope here —
* this migration only stores the data, it computes nothing.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
CREATE SEQUENCE products_product_code_seq START 1;
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
product_code INTEGER NOT NULL UNIQUE DEFAULT nextval('products_product_code_seq'),
name TEXT NOT NULL,
description TEXT,
product_type TEXT NOT NULL,
product_group TEXT,
product_subgroup TEXT,
brand TEXT,
unit TEXT NOT NULL DEFAULT 'unidade',
active BOOLEAN NOT NULL DEFAULT true,
discontinued BOOLEAN NOT NULL DEFAULT false,
tracks_stock BOOLEAN NOT NULL DEFAULT false,
tracks_serial BOOLEAN NOT NULL DEFAULT false,
tracks_asset_tag BOOLEAN NOT NULL DEFAULT false,
tracks_mac BOOLEAN NOT NULL DEFAULT false,
price_0 NUMERIC(12,2),
price_12 NUMERIC(12,2),
price_24 NUMERIC(12,2),
price_36 NUMERIC(12,2),
price_48 NUMERIC(12,2),
impl_unit_price NUMERIC(12,2) NOT NULL DEFAULT 0
);
COMMENT ON TABLE products IS
'Catálogo de produtos vendáveis — eden.md §2 §1.1, Master Prompt §6.3. product_code é a sequência legada (distinta do UUID); price_0/12/24/36/48 são as 5 faixas fixas de preço por período de fidelidade, não uma tabela relacionada. Leitura aberta a qualquer autenticado (catálogo precisa ser navegável por qualquer vendedor); escrita restrita à permissão products:edit.';
COMMENT ON COLUMN products.impl_unit_price IS
'Valor de implantação por unidade, multiplicado pela quantidade do item na oferta (fase futura — Quotes). NUMERIC sempre, nunca float.';
CREATE TRIGGER trg_products_updated_at BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_products_active ON products(active);
ALTER SEQUENCE products_product_code_seq OWNED BY products.product_code;
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS products;
DROP SEQUENCE IF EXISTS products_product_code_seq;
`);
};

View File

@@ -0,0 +1,157 @@
import { query } from "../pool.js";
export interface Product {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
product_code: number;
name: string;
description: string | null;
product_type: string;
product_group: string | null;
product_subgroup: string | null;
brand: string | null;
unit: string;
active: boolean;
discontinued: boolean;
tracks_stock: boolean;
tracks_serial: boolean;
tracks_asset_tag: boolean;
tracks_mac: boolean;
price_0: string | null;
price_12: string | null;
price_24: string | null;
price_36: string | null;
price_48: string | null;
impl_unit_price: string;
}
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",
);
return rows;
}
export async function getProductById(id: string): Promise<Product | null> {
const { rows } = await query<Product>("SELECT * FROM products WHERE 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;
active?: boolean;
discontinued?: boolean;
tracksStock?: boolean;
tracksSerial?: boolean;
tracksAssetTag?: boolean;
tracksMac?: boolean;
price0?: number | null;
price12?: number | null;
price24?: number | null;
price36?: number | null;
price48?: number | null;
implUnitPrice?: number;
createdBy?: string | null;
}
export async function createProduct(input: CreateProductInput): Promise<Product> {
const { rows } = await query<Product>(
`INSERT INTO products (
name, description, product_type, product_group, product_subgroup, brand, unit,
active, discontinued, tracks_stock, tracks_serial, tracks_asset_tag, tracks_mac,
price_0, price_12, price_24, price_36, price_48, impl_unit_price, created_by
) VALUES (
$1,$2,$3,$4,$5,$6,COALESCE($7,'unidade'),
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),$20
)
RETURNING *`,
[
input.name,
input.description ?? null,
input.productType,
input.productGroup ?? null,
input.productSubgroup ?? null,
input.brand ?? null,
input.unit ?? null,
input.active ?? null,
input.discontinued ?? null,
input.tracksStock ?? null,
input.tracksSerial ?? null,
input.tracksAssetTag ?? null,
input.tracksMac ?? null,
input.price0 ?? null,
input.price12 ?? null,
input.price24 ?? null,
input.price36 ?? null,
input.price48 ?? null,
input.implUnitPrice ?? null,
input.createdBy ?? null,
],
);
const product = rows[0];
if (!product) throw new Error("failed to create product");
return product;
}
export type UpdateProductInput = Partial<Omit<CreateProductInput, "createdBy">>;
export async function updateProduct(id: string, input: UpdateProductInput): Promise<Product | null> {
const { rows } = await query<Product>(
`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),
active = COALESCE($9, active),
discontinued = COALESCE($10, discontinued),
tracks_stock = COALESCE($11, tracks_stock),
tracks_serial = COALESCE($12, tracks_serial),
tracks_asset_tag = COALESCE($13, tracks_asset_tag),
tracks_mac = COALESCE($14, tracks_mac),
price_0 = COALESCE($15, price_0),
price_12 = COALESCE($16, price_12),
price_24 = COALESCE($17, price_24),
price_36 = COALESCE($18, price_36),
price_48 = COALESCE($19, price_48),
impl_unit_price = COALESCE($20, impl_unit_price)
WHERE id = $1
RETURNING *`,
[
id,
input.name ?? null,
input.description ?? null,
input.productType ?? null,
input.productGroup ?? null,
input.productSubgroup ?? null,
input.brand ?? null,
input.unit ?? null,
input.active ?? null,
input.discontinued ?? null,
input.tracksStock ?? null,
input.tracksSerial ?? null,
input.tracksAssetTag ?? null,
input.tracksMac ?? null,
input.price0 ?? null,
input.price12 ?? null,
input.price24 ?? null,
input.price36 ?? null,
input.price48 ?? null,
input.implUnitPrice ?? null,
],
);
return rows[0] ?? null;
}