Add product composition export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s
This commit is contained in:
@@ -10,7 +10,7 @@ const {
|
||||
getProductDetailsAnalytics,
|
||||
getRfmAnalytics
|
||||
} = require('../services/analyticsService');
|
||||
const { getProductComposition } = require('../services/productionOrderService');
|
||||
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
|
||||
|
||||
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) => {
|
||||
try {
|
||||
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
|
||||
|
||||
@@ -699,9 +699,8 @@ const mapProductCompositionRow = (row) => ({
|
||||
})) : []
|
||||
});
|
||||
|
||||
const getProductComposition = async (productId, client = pool) => {
|
||||
const findProductCompositions = async (productId, client = pool) => {
|
||||
const identity = normalizeText(productId);
|
||||
if (!identity) return null;
|
||||
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
@@ -751,15 +750,22 @@ const getProductComposition = async (productId, client = pool) => {
|
||||
FROM product_compositions composition
|
||||
WHERE composition.source = $1
|
||||
AND (
|
||||
composition.finished_tiny_product_id = $2
|
||||
$2 = ''
|
||||
OR composition.finished_tiny_product_id = $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]);
|
||||
|
||||
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 finishedTinyProductId = getTinyProductIdFromCompositionReference(order.tinyId);
|
||||
const finishedProductSku = normalizeSku(order.productSku);
|
||||
@@ -1051,6 +1057,7 @@ const updateProductionOrderStatus = async (id, status) => {
|
||||
module.exports = {
|
||||
createProductionOrders,
|
||||
getProductComposition,
|
||||
listProductCompositions,
|
||||
listProductionOrders,
|
||||
normalizeStatus,
|
||||
upsertTinyProductionOrderDetail,
|
||||
|
||||
@@ -13,6 +13,7 @@ const getRouteHandler = (router, method, path) => {
|
||||
|
||||
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
|
||||
const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
|
||||
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
|
||||
|
||||
const invokeHandler = async (handler, req) => {
|
||||
let statusCode = 200;
|
||||
@@ -132,18 +133,17 @@ const withFakeDatabase = async (run) => {
|
||||
}
|
||||
|
||||
if (normalizedSql.includes('FROM product_compositions composition')) {
|
||||
const composition = state.compositions.find(item => (
|
||||
const compositions = state.compositions.filter(item => (
|
||||
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 {
|
||||
rows: [{
|
||||
rows: compositions.map(composition => ({
|
||||
...composition,
|
||||
components: state.compositionComponents
|
||||
.filter(item => item.product_composition_id === composition.id)
|
||||
.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 () => {
|
||||
await withFakeDatabase(async (state) => {
|
||||
const response = await invokeHandler(tinySyncHandler, { body: {
|
||||
|
||||
@@ -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>) => {
|
||||
if (!filters) return;
|
||||
if (filters.marketplace) params.set('marketplace', filters.marketplace);
|
||||
|
||||
@@ -7,7 +7,7 @@ import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
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 { formatColorLabel } from '../displayFormatters';
|
||||
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
@@ -171,12 +171,15 @@ const Products = () => {
|
||||
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
|
||||
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
|
||||
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
||||
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
|
||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||
const exportMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
|
||||
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
|
||||
const [isExportingCompositions, setIsExportingCompositions] = useState(false);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
@@ -236,16 +239,20 @@ const Products = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFilterMenuOpen) return;
|
||||
if (!isFilterMenuOpen && !isExportMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!filterMenuRef.current?.contains(event.target as Node)) {
|
||||
setIsFilterMenuOpen(false);
|
||||
}
|
||||
if (!exportMenuRef.current?.contains(event.target as Node)) {
|
||||
setIsExportMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsFilterMenuOpen(false);
|
||||
setIsExportMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,7 +263,7 @@ const Products = () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isFilterMenuOpen]);
|
||||
}, [isFilterMenuOpen, isExportMenuOpen]);
|
||||
|
||||
const productsData = useMemo<ProductRow[]>(() => {
|
||||
const days = getRangeDays(dateRange);
|
||||
@@ -453,34 +460,68 @@ const Products = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const exportData = productsData.map(product => ({
|
||||
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
|
||||
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
|
||||
'Descrição': product.name,
|
||||
'SKUs': product.skuCount,
|
||||
'Cores': product.colors.join(' | '),
|
||||
'Tamanhos': product.sizes.join(' | '),
|
||||
'Tipo de produto': getProductTypeConfig(product.productType).label,
|
||||
'Cor principal': product.topColor,
|
||||
'Tamanho principal': product.topSize,
|
||||
'Status': product.riskLabel,
|
||||
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
|
||||
'Total Vendido (un.)': product.quantitySold,
|
||||
'Estoque': product.stock,
|
||||
'Média Diária': product.dailySales.toFixed(2).replace('.', ','),
|
||||
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','),
|
||||
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
|
||||
}));
|
||||
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
}}
|
||||
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="Exportar para CSV"
|
||||
>
|
||||
<Download size={16} className="text-brand-primary" />
|
||||
<span className="hidden sm:inline">Exportar</span>
|
||||
</button>
|
||||
<div ref={exportMenuRef} className="relative">
|
||||
<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={() => {
|
||||
const exportData = productsData.map(product => ({
|
||||
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
|
||||
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
|
||||
'Descrição': product.name,
|
||||
'SKUs': product.skuCount,
|
||||
'Cores': product.colors.join(' | '),
|
||||
'Tamanhos': product.sizes.join(' | '),
|
||||
'Tipo de produto': getProductTypeConfig(product.productType).label,
|
||||
'Cor principal': product.topColor,
|
||||
'Tamanho principal': product.topSize,
|
||||
'Status': product.riskLabel,
|
||||
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
|
||||
'Total Vendido (un.)': product.quantitySold,
|
||||
'Estoque': product.stock,
|
||||
'Média Diária': product.dailySales.toFixed(2).replace('.', ','),
|
||||
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','),
|
||||
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
|
||||
}));
|
||||
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
setIsExportMenuOpen(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"
|
||||
>
|
||||
Produtos (CSV)
|
||||
</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user