Add product composition export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s

This commit is contained in:
Cauê Faleiros
2026-07-31 10:30:49 -03:00
parent 5559e9951a
commit b431802d50
5 changed files with 134 additions and 42 deletions

View File

@@ -10,7 +10,7 @@ const {
getProductDetailsAnalytics, getProductDetailsAnalytics,
getRfmAnalytics getRfmAnalytics
} = require('../services/analyticsService'); } = require('../services/analyticsService');
const { getProductComposition } = require('../services/productionOrderService'); const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -69,6 +69,15 @@ router.get('/analytics/products/:productId/composition', verifyToken, async (req
} }
}); });
router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
try {
res.json({ compositions: await listProductCompositions() });
} catch (error) {
console.error('Error exporting product compositions:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients', verifyToken, async (req, res) => { router.get('/analytics/clients', verifyToken, async (req, res) => {
try { try {
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query))); res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));

View File

@@ -699,9 +699,8 @@ const mapProductCompositionRow = (row) => ({
})) : [] })) : []
}); });
const getProductComposition = async (productId, client = pool) => { const findProductCompositions = async (productId, client = pool) => {
const identity = normalizeText(productId); const identity = normalizeText(productId);
if (!identity) return null;
const result = await client.query(` const result = await client.query(`
SELECT SELECT
@@ -751,15 +750,22 @@ const getProductComposition = async (productId, client = pool) => {
FROM product_compositions composition FROM product_compositions composition
WHERE composition.source = $1 WHERE composition.source = $1
AND ( AND (
composition.finished_tiny_product_id = $2 $2 = ''
OR composition.finished_tiny_product_id = $2
OR composition.finished_product_sku = UPPER($2) OR composition.finished_product_sku = UPPER($2)
) )
LIMIT 1; ORDER BY composition.finished_product_sku, composition.finished_product_description;
`, [TINY_OLIST_V3_COMPOSITION_SOURCE, identity]); `, [TINY_OLIST_V3_COMPOSITION_SOURCE, identity]);
return result.rows[0] ? mapProductCompositionRow(result.rows[0]) : null; return result.rows.map(mapProductCompositionRow);
}; };
const getProductComposition = async (productId, client = pool) => (
(await findProductCompositions(productId, client))[0] || null
);
const listProductCompositions = async (client = pool) => findProductCompositions('', client);
const upsertTinyProductComposition = async (order, components) => { const upsertTinyProductComposition = async (order, components) => {
const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId); const finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId);
const finishedProductSku = normalizeSku(order.productSku); const finishedProductSku = normalizeSku(order.productSku);
@@ -1051,6 +1057,7 @@ const updateProductionOrderStatus = async (id, status) => {
module.exports = { module.exports = {
createProductionOrders, createProductionOrders,
getProductComposition, getProductComposition,
listProductCompositions,
listProductionOrders, listProductionOrders,
normalizeStatus, normalizeStatus,
upsertTinyProductionOrderDetail, upsertTinyProductionOrderDetail,

View File

@@ -13,6 +13,7 @@ const getRouteHandler = (router, method, path) => {
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync'); const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition'); const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
const invokeHandler = async (handler, req) => { const invokeHandler = async (handler, req) => {
let statusCode = 200; let statusCode = 200;
@@ -132,18 +133,17 @@ const withFakeDatabase = async (run) => {
} }
if (normalizedSql.includes('FROM product_compositions composition')) { if (normalizedSql.includes('FROM product_compositions composition')) {
const composition = state.compositions.find(item => ( const compositions = state.compositions.filter(item => (
item.source === params[0] item.source === params[0]
&& (item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase()) && (params[1] === '' || item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
)); ));
if (!composition) return { rows: [] };
return { return {
rows: [{ rows: compositions.map(composition => ({
...composition, ...composition,
components: state.compositionComponents components: state.compositionComponents
.filter(item => item.product_composition_id === composition.id) .filter(item => item.product_composition_id === composition.id)
.map(item => ({ ...item, component_product_id: null })) .map(item => ({ ...item, component_product_id: null }))
}] }))
}; };
} }
@@ -243,6 +243,17 @@ test('product composition detail lookup returns stored components', async () =>
}); });
}); });
test('product composition export endpoint returns every stored composition', async () => {
await withFakeDatabase(async () => {
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
const response = await invokeHandler(productCompositionsHandler, { params: {} });
assert.equal(response.statusCode, 200);
assert.equal(response.body.compositions.length, 1);
assert.equal(response.body.compositions[0].components.length, 2);
});
});
test('ordinary Tiny production-order payloads continue to create production orders', async () => { test('ordinary Tiny production-order payloads continue to create production orders', async () => {
await withFakeDatabase(async (state) => { await withFakeDatabase(async (state) => {
const response = await invokeHandler(tinySyncHandler, { body: { const response = await invokeHandler(tinySyncHandler, { body: {

View File

@@ -551,6 +551,30 @@ export const fetchProductComposition = async (productId: string): Promise<Produc
} }
}; };
export const exportProductCompositions = async (): Promise<number> => {
const response = await authFetch('/analytics/product-compositions');
const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null;
if (!response.ok) {
throw new Error(data?.error || 'Não foi possível exportar as composições.');
}
const compositions = data?.compositions || [];
const blob = new Blob([JSON.stringify({
exportedAt: new Date().toISOString(),
compositions
}, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `composicoes_produtos_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return compositions.length;
};
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => { const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
if (!filters) return; if (!filters) return;
if (filters.marketplace) params.set('marketplace', filters.marketplace); if (filters.marketplace) params.set('marketplace', filters.marketplace);

View File

@@ -7,7 +7,7 @@ import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge'; import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types'; import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService'; import { exportProductCompositions, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification'; import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
@@ -171,12 +171,15 @@ const Products = () => {
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all'); const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
const [viewMode, setViewMode] = useState<ProductViewMode>('sku'); const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null); const filterMenuRef = useRef<HTMLDivElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]); const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} }); const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null); const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
const [isSavingPlanning, setIsSavingPlanning] = useState(false); const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const [isExportingCompositions, setIsExportingCompositions] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
@@ -236,16 +239,20 @@ const Products = () => {
}; };
useEffect(() => { useEffect(() => {
if (!isFilterMenuOpen) return; if (!isFilterMenuOpen && !isExportMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
if (!filterMenuRef.current?.contains(event.target as Node)) { if (!filterMenuRef.current?.contains(event.target as Node)) {
setIsFilterMenuOpen(false); setIsFilterMenuOpen(false);
} }
if (!exportMenuRef.current?.contains(event.target as Node)) {
setIsExportMenuOpen(false);
}
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setIsFilterMenuOpen(false); setIsFilterMenuOpen(false);
setIsExportMenuOpen(false);
} }
}; };
@@ -256,7 +263,7 @@ const Products = () => {
document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [isFilterMenuOpen]); }, [isFilterMenuOpen, isExportMenuOpen]);
const productsData = useMemo<ProductRow[]>(() => { const productsData = useMemo<ProductRow[]>(() => {
const days = getRangeDays(dateRange); const days = getRangeDays(dateRange);
@@ -453,7 +460,21 @@ const Products = () => {
}} }}
/> />
<div ref={exportMenuRef} className="relative">
<button <button
type="button"
onClick={() => setIsExportMenuOpen(current => !current)}
aria-expanded={isExportMenuOpen}
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
title="Escolher exportação"
>
<Download size={16} className="text-brand-primary" />
<span>Exportar</span>
</button>
{isExportMenuOpen && (
<div className="absolute right-0 z-20 mt-2 w-56 overflow-hidden rounded-xl border border-dark-border bg-dark-card p-1 shadow-xl">
<button
type="button"
onClick={() => { onClick={() => {
const exportData = productsData.map(product => ({ const exportData = productsData.map(product => ({
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU', 'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
@@ -474,13 +495,33 @@ const Products = () => {
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',') 'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
})); }));
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`); exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
setIsExportMenuOpen(false);
}} }}
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer" className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer"
title="Exportar para CSV"
> >
<Download size={16} className="text-brand-primary" /> Produtos (CSV)
<span className="hidden sm:inline">Exportar</span>
</button> </button>
<button
type="button"
disabled={isExportingCompositions}
onClick={async () => {
setIsExportingCompositions(true);
try {
await exportProductCompositions();
setIsExportMenuOpen(false);
} catch (error) {
window.alert(error instanceof Error ? error.message : 'Não foi possível exportar as composições.');
} finally {
setIsExportingCompositions(false);
}
}}
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer disabled:cursor-wait disabled:opacity-60"
>
{isExportingCompositions ? 'Preparando…' : 'Composições (JSON)'}
</button>
</div>
)}
</div>
</div> </div>
</div> </div>