diff --git a/client/public/test-data.js b/client/public/test-data.js deleted file mode 100644 index 951e522..0000000 --- a/client/public/test-data.js +++ /dev/null @@ -1,113 +0,0 @@ -// Test data script - run in browser console to add sample data with printing costs -console.log('Adding test data with printing costs...'); - -// Sample products with printing costs -const sampleProducts = [ - { - _id: 'prod1', - title: 'Custom Business Cards', - description: 'Professional business cards with custom design', - price: 25.00, - costOfGoods: 5.00, - printingCost: 3.50, - sku: 'BC001', - stockLevel: 100, - category: 'Business Cards' - }, - { - _id: 'prod2', - title: 'Wedding Invitations', - description: 'Elegant wedding invitations with RSVP cards', - price: 45.00, - costOfGoods: 8.00, - printingCost: 6.00, - sku: 'WI001', - stockLevel: 50, - category: 'Invitations' - }, - { - _id: 'prod3', - title: 'Photo Prints 8x10', - description: 'High quality photo prints', - price: 15.00, - costOfGoods: 2.00, - printingCost: 4.50, - sku: 'PP001', - stockLevel: 200, - category: 'Photo Prints' - } -]; - -// Sample orders with printing costs -const sampleOrders = [ - { - _id: 'order1', - orderNumber: '1001', - customer: { - name: 'John Smith', - email: 'john@example.com', - address: { - street1: '123 Main St', - city: 'New York', - state: 'NY', - postalCode: '10001', - country: 'US' - } - }, - items: [ - { - title: 'Custom Business Cards', - quantity: 2, - price: 25.00, - printingCost: 3.50, - costOfGoods: 5.00 - } - ], - total: 50.00, - status: 'completed', - dateOrdered: new Date().toISOString() - }, - { - _id: 'order2', - orderNumber: '1002', - customer: { - name: 'Sarah Johnson', - email: 'sarah@example.com', - address: { - street1: '456 Oak Ave', - city: 'Los Angeles', - state: 'CA', - postalCode: '90210', - country: 'US' - } - }, - items: [ - { - title: 'Wedding Invitations', - quantity: 1, - price: 45.00, - printingCost: 6.00, - costOfGoods: 8.00 - }, - { - title: 'Photo Prints 8x10', - quantity: 3, - price: 15.00, - printingCost: 4.50, - costOfGoods: 2.00 - } - ], - total: 90.00, - status: 'processing', - dateOrdered: new Date(Date.now() - 24*60*60*1000).toISOString() - } -]; - -// Add to localStorage -localStorage.setItem('etsy-tracker-products', JSON.stringify(sampleProducts)); -localStorage.setItem('etsy-tracker-orders', JSON.stringify(sampleOrders)); - -// Dispatch Redux actions to update state -window.location.reload(); - -console.log('Test data added! Page will reload to update the state.'); \ No newline at end of file diff --git a/client/src/components/MissingProductsModal.tsx b/client/src/components/MissingProductsModal.tsx index f753c9c..9b5ad6d 100644 --- a/client/src/components/MissingProductsModal.tsx +++ b/client/src/components/MissingProductsModal.tsx @@ -1,8 +1,8 @@ import React, { useState, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from '../store'; -import { addProduct } from '../store/slices/productSlice'; -import { findPotentialMatches } from '../utils/productMatcher'; +import { addProduct, updateProduct } from '../store/slices/productSlice'; +import { findPotentialMatches, normalizeTitle } from '../utils/productMatcher'; import { X, Plus, AlertCircle, CheckCircle } from 'lucide-react'; import toast from 'react-hot-toast'; import api from '../utils/api'; @@ -90,18 +90,32 @@ export const MissingProductsModal: React.FC = ({ for (const product of missingProducts) { const data = productData[product.title] || {}; - // If user selected to use existing product, add it to matches + // If user selected to use an existing product, save the packing-slip + // title as an alias on it so this and all future imports match it + // deterministically if (data.useExisting) { const existingProduct = products.find(p => p._id === data.useExisting); if (existingProduct) { - existingMatches.push({ - orderItem: { - title: product.title, - quantity: product.quantity, - price: product.price - }, - matchedProduct: existingProduct - }); + let updatedProduct: any = existingProduct; + const normalized = normalizeTitle(product.title); + const alreadyKnown = + normalizeTitle(existingProduct.title) === normalized || + (existingProduct.aliases || []).some(alias => normalizeTitle(alias) === normalized); + + if (!alreadyKnown) { + const newAliases = [...(existingProduct.aliases || []), product.title]; + try { + const res = await api.put(`/products/${existingProduct._id}`, { aliases: newAliases }); + updatedProduct = res.data; + } catch (error) { + console.error('Failed to save alias for product:', existingProduct.title, error); + // Keep the alias locally so the current import still matches + updatedProduct = { ...existingProduct, aliases: newAliases }; + } + dispatch(updateProduct(updatedProduct)); + } + + existingMatches.push(updatedProduct); continue; } } diff --git a/client/src/components/MissingProductsModal.tsx.backup b/client/src/components/MissingProductsModal.tsx.backup deleted file mode 100644 index 13013f6..0000000 --- a/client/src/components/MissingProductsModal.tsx.backup +++ /dev/null @@ -1,344 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { RootState } from '../store'; -import { addProduct } from '../store/slices/productSlice'; -import { findPotentialMatches } from '../utils/productMatcher'; -import { X, Plus, AlertCircle, CheckCircle } from 'lucide-react'; -import toast from 'react-hot-toast'; -import api from '../utils/api'; - -export interface MissingProduct { - title: string; - quantity: number; - price?: number; - size?: string; - orderNumber?: string; -} - -interface MissingProductsModalProps { - missingProducts: MissingProduct[]; - onClose: () => void; - onComplete: (products: any[]) => void; -} - -export const MissingProductsModal: React.FC = ({ - missingProducts, - onClose, - onComplete -}) => { - const dispatch = useDispatch(); - const products = useSelector((state: RootState) => state.products.products); - const [isProcessing, setIsProcessing] = useState(false); - - const [productData, setProductData] = useState<{[key: string]: { - printingCost: number; - category: string; - useExisting?: string; // ID of existing product to use instead of creating new - }}>({}); - - // Find potential matches for each missing product - const suggestions = useMemo(() => { - const result: {[key: string]: ReturnType} = {}; - - missingProducts.forEach(missingProduct => { - result[missingProduct.title] = findPotentialMatches( - missingProduct.title, - products, - 3 // Show top 3 suggestions - ); - }); - - return result; - }, [missingProducts, products]); - - const handleInputChange = (productTitle: string, field: string, value: string) => { - setProductData(prev => ({ - ...prev, - [productTitle]: { - ...prev[productTitle], - [field]: field === 'category' ? value : - field === 'useExisting' ? value : - parseFloat(value) || 0 - } - })); - }; - - const handleUseExisting = (missingTitle: string, existingProductId: string) => { - setProductData(prev => ({ - ...prev, - [missingTitle]: { - ...prev[missingTitle], - useExisting: existingProductId - } - })); - }; - - const handleAddProducts = async () => { - // Prevent multiple clicks - if (isProcessing) return; - setIsProcessing(true); - - try { - const newProducts: any[] = []; - const existingMatches: any[] = []; - - for (const product of missingProducts) { - const data = productData[product.title] || {}; - - // If user selected to use existing product, add it to matches - if (data.useExisting) { - const existingProduct = products.find(p => p._id === data.useExisting); - if (existingProduct) { - existingMatches.push({ - orderItem: { - title: product.title, - quantity: product.quantity, - price: product.price - }, - matchedProduct: existingProduct - }); - continue; - } - } - - // Otherwise create new product - const printingCost = data.printingCost || 0; - const category = data.category || 'Imported Items'; - - try { - const res = await api.post('/products', { - title: product.title, - description: `Imported from order ${product.orderNumber || 'Unknown'}`, - price: product.price || 0, - costOfGoods: 0, - printingCost, - sku: '', - category, - tags: [], - inventory: { quantity: 0, lowStockAlert: 5 }, - isActive: true - }); - dispatch(addProduct(res.data)); - newProducts.push(res.data); - } catch { - // Fall back to local-only if API fails - const fallback = { - _id: `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - title: product.title, - description: `Imported from order ${product.orderNumber || 'Unknown'}`, - price: product.price || 0, - costOfGoods: 0, - printingCost, - sku: '', - category, - tags: [], - inventory: { quantity: 0, lowStockAlert: 5 }, - isActive: true - }; - dispatch(addProduct(fallback)); - newProducts.push(fallback); - } - } - - if (newProducts.length > 0) { - toast.success(`Added ${newProducts.length} new products with printing costs`); - } - if (existingMatches.length > 0) { - toast.success(`Matched ${existingMatches.length} items to existing products`); - } - - // Close modal immediately after successful processing - onComplete([...newProducts, ...existingMatches]); - onClose(); // Explicitly close the modal - - } catch (error) { - console.error('Error processing products:', error); - toast.error('Failed to process products. Please try again.'); - } finally { - setIsProcessing(false); - } - }; - - return ( -
-
-
-

- Missing Products - Add Printing Costs -

- -
- -
-

- The following products from your order are not in your product database. - Please add printing costs (including materials/filament) to complete the import: -

- -
- {missingProducts.map((product, index) => ( -
-
-
- -
- {product.title} -
-
- Quantity ordered: {product.quantity}{product.price ? ` × $${product.price.toFixed(2)} each` : ''} -
-
- - {/* Show suggestions if available */} - {suggestions[product.title] && suggestions[product.title].length > 0 && ( -
-
- - - Possible matches found: - -
-
- {suggestions[product.title].map((suggestion, sugIndex) => ( -
-
-
- {suggestion.product.title} -
-
- {suggestion.reason} ({(suggestion.confidence * 100).toFixed(0)}% match) -
-
- -
- ))} -
-
- )} - - {/* Only show input fields if not using existing product */} - {!productData[product.title]?.useExisting && ( -
-
- - handleInputChange(product.title, 'printingCost', e.target.value)} - /> -

- Include filament, materials, and time costs -

-
-
- - -
-
- )} -
-
- ))} -
-
-
- -
- - handleInputChange(product.title, 'printingCost', e.target.value)} - /> -
- -
- - -
-
- - ))} - - -
- - -
- - - - ); -}; \ No newline at end of file diff --git a/client/src/pages/Analytics.tsx b/client/src/pages/Analytics.tsx index 00423d4..ec65cfd 100644 --- a/client/src/pages/Analytics.tsx +++ b/client/src/pages/Analytics.tsx @@ -258,61 +258,13 @@ const Analytics = () => { return sum + getUpdatedPrintingCost(order); }, 0); - // Calculate expenses excluding only sale transaction fees to avoid double-counting - // (Sale transaction fees are already deducted from order totals in the CSV) - // But we DO want to include Etsy Ads, Listing Fees, GST, subscriptions, etc. - const totalExpenses = filteredExpenses.reduce((sum, expense) => { - // Only exclude transaction fees that are tied to specific orders (already deducted from order totals) - // Include: Listing Fees, Marketing/Ads, GST, and other business expenses - const isSaleTransactionFee = ( - expense.vendor?.toLowerCase().includes('etsy') && - expense.category?.toLowerCase() === 'transaction fees' && - expense.reference && // Has an order reference - (expense.description?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('processing fee')) - ); - - if (isSaleTransactionFee) { - console.log('Excluding sale transaction fee from profit calc:', expense.description, '$' + expense.amount, 'Category:', expense.category); - return sum; // Skip sale transaction fees as they're already deducted from order totals - } - return sum + (expense?.amount || 0); - }, 0); - + // Order totals are gross sale amounts (fees are NOT deducted from them), + // so every expense — including Etsy transaction fees — counts once + const totalExpenses = filteredExpenses.reduce((sum, expense) => sum + (expense?.amount || 0), 0); + const netProfit = totalRevenue - totalExpenses - totalPrintingCosts; const profitMargin = totalRevenue > 0 ? (netProfit / totalRevenue) * 100 : 0; - - // Debug logging to verify the separation - console.log('=== EXPENSE SEPARATION DEBUG ==='); - console.log('Date range filter:', dateRange); - console.log('Filtered orders count:', filteredOrders.length); - console.log('Filtered expenses count:', filteredExpenses.length); - - const allExpensesTotal = filteredExpenses.reduce((sum, expense) => sum + (expense.amount || 0), 0); - const transactionFeesTotal = filteredExpenses.reduce((sum, expense) => { - const isSaleTransactionFee = ( - expense.vendor?.toLowerCase().includes('etsy') && - expense.category?.toLowerCase() === 'transaction fees' && - expense.reference && // Has an order reference - (expense.description?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('processing fee')) - ); - - if (isSaleTransactionFee) { - console.log('Found sale transaction fee:', expense.description, '$' + expense.amount, 'Category:', expense.category); - } - return isSaleTransactionFee ? sum + (expense.amount || 0) : sum; - }, 0); - - console.log('All Expenses Total:', allExpensesTotal); - console.log('Etsy Transaction Fees Total:', transactionFeesTotal); - console.log('Expenses for Profit Calc (excluding transaction fees):', totalExpenses); - console.log('Difference (should equal transaction fees):', allExpensesTotal - totalExpenses); - console.log('Total Revenue:', totalRevenue); - console.log('Total Printing Costs:', totalPrintingCosts); - console.log('Net Profit:', netProfit); - console.log('Profit Margin:', profitMargin.toFixed(1) + '%'); - + const averageOrderValue = filteredOrders.length > 0 ? totalRevenue / filteredOrders.length : 0; const totalCustomers = customers?.length || 0; @@ -376,16 +328,9 @@ const Analytics = () => { const current = monthlyMap.get(monthKey); if (current) { - // For monthly chart, exclude transaction fees to match profit calculation - const isEtsyTransactionFee = ( - (expense.vendor?.toLowerCase() === 'etsy' && - (expense.category?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('transaction fee'))) - ); - - if (!isEtsyTransactionFee) { - current.expenses += expense.amount || 0; - } + // All expenses count, matching the profit calculation + // (order revenue is gross, so fees are not double-counted) + current.expenses += expense.amount || 0; } } }); @@ -417,38 +362,21 @@ const Analytics = () => { const categories = new Map(); filteredExpenses.forEach(expense => { - // Show ALL expenses in categories (including Etsy transaction fees for visibility) - // The profit calculation will handle excluding transaction fees separately const category = expense.category || 'Other'; const current = categories.get(category) || 0; categories.set(category, current + (expense.amount || 0)); }); - + const totalExpenseAmount = Array.from(categories.values()).reduce((sum, amount) => sum + amount, 0); - + const categoryData = Array.from(categories.entries()).map(([category, amount]) => ({ category, amount, percentage: totalExpenseAmount > 0 ? (amount / totalExpenseAmount) * 100 : 0 })).sort((a, b) => b.amount - a.amount); - // Debug: Log detailed expense breakdown - console.log('=== DETAILED EXPENSE BREAKDOWN ==='); - console.log('Total Expenses for Display (all expenses):', totalExpenseAmount.toFixed(2)); - console.log('Total Expenses for Profit Calc (excluding transaction fees):', totalExpenses.toFixed(2)); - console.log('Expense categories breakdown:'); - categoryData.forEach(cat => { - console.log(` ${cat.category}: $${cat.amount.toFixed(2)} (${cat.percentage.toFixed(1)}%)`); - }); - console.log('PROFIT CALCULATION:'); - console.log(`Revenue: $${totalRevenue.toFixed(2)}`); - console.log(`Printing Costs: $${totalPrintingCosts.toFixed(2)}`); - console.log(`Other Expenses (excluding transaction fees): $${totalExpenses.toFixed(2)}`); - console.log(`Net Profit: $${totalRevenue.toFixed(2)} - $${totalPrintingCosts.toFixed(2)} - $${totalExpenses.toFixed(2)} = $${netProfit.toFixed(2)}`); - console.log('==='); - return categoryData; - }, [filteredExpenses, totalExpenses, totalRevenue, totalPrintingCosts, netProfit]); + }, [filteredExpenses]); // Calculate top profitable products based on actual sales performance const topProducts = useMemo(() => { diff --git a/client/src/pages/DataImport.tsx b/client/src/pages/DataImport.tsx index c87b6b4..5e646be 100644 --- a/client/src/pages/DataImport.tsx +++ b/client/src/pages/DataImport.tsx @@ -1,12 +1,12 @@ import React, { useState, useRef } from 'react'; import { csvImportService, ParsedEtsyOrder, ParsedShippingRecord, EtsyFeeRecord } from '../utils/csvImportService'; import { pdfParser, ParsedPackingSlip } from '../utils/pdfParser'; -import { matchOrderItemsToProducts } from '../utils/productMatcher'; +import { matchOrderItemsToProducts, normalizeTitle } from '../utils/productMatcher'; import { MissingProductsModal, MissingProduct } from '../components/MissingProductsModal'; -import { DataManager } from '../utils/dataManager'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from '../store'; import { addOrder, updateOrder, setOrders } from '../store/slices/orderSlice'; +import { updateProduct } from '../store/slices/productSlice'; import { Upload, FileText, Package, Truck, Trash2 } from 'lucide-react'; import toast from 'react-hot-toast'; import api from '../utils/api'; @@ -151,15 +151,12 @@ export default function DataImport() { // Process expenses with bulk API for much faster processing if (expensesToCreate.length > 0) { - console.log(`Creating ${expensesToCreate.length} expenses via bulk API...`); setBackgroundProcessing(`Creating ${expensesToCreate.length} expenses...`); - + try { const response = await api.post('/expenses/bulk', expensesToCreate); const { created, duplicates, errors, message } = response.data; - - console.log(`Bulk expense creation completed: ${created} created, ${duplicates} duplicates, ${errors} errors`); - + if (created > 0 || duplicates > 0) { toast.success(`✅ ${message}`); } else if (errors > 0) { @@ -334,7 +331,6 @@ export default function DataImport() { const parsedSlips: ParsedPackingSlip[] = []; for (const file of pdfFiles) { - console.log(`Processing PDF: ${file.name}`); const slip = await pdfParser.parsePackingSlip(file); if (slip) { parsedSlips.push(slip); @@ -355,16 +351,35 @@ export default function DataImport() { } }; + // Save a packing-slip title as an alias on the matched product, so future + // imports of the same title match deterministically instead of fuzzily + const saveProductAlias = async (productId: string, aliasTitle: string, productList: any[]) => { + const product = productList.find(p => p._id === productId); + if (!product) return; + + const normalized = normalizeTitle(aliasTitle); + const alreadyKnown = + normalizeTitle(product.title) === normalized || + (product.aliases || []).some((alias: string) => normalizeTitle(alias) === normalized); + if (alreadyKnown) return; + + try { + const res = await api.put(`/products/${productId}`, { + aliases: [...(product.aliases || []), aliasTitle] + }); + dispatch(updateProduct(res.data)); + } catch (err) { + console.error('Failed to save product alias:', err); + } + }; + const createOrUpdateOrderFromSlip = async (slip: ParsedPackingSlip, customProducts?: any[]) => { - console.log('Creating/updating order for slip:', slip); - const existingOrder = orders.find(order => order.orderNumber === slip.orderNumber); - + // Check if we have CSV results for this order number to get revenue data let csvOrderData = null; if (results && results.etsyOrders) { csvOrderData = results.etsyOrders.find(csvOrder => csvOrder.orderNumber === slip.orderNumber); - console.log('Found matching CSV data for order:', csvOrderData); } const productsToUse = customProducts || products; @@ -377,12 +392,28 @@ export default function DataImport() { return; } - const orderItems = matches.map((match: any) => ({ - title: match.orderItem.title, - quantity: match.orderItem.quantity, - price: match.orderItem.price || 0, - printingCost: match.matchedProduct?.printingCost || 0 - })); + // Remember fuzzy-matched titles as aliases so future imports of the same + // packing-slip title skip the fuzzy matcher entirely + for (const match of matches) { + if (match.matchedProduct && match.confidence < 1) { + await saveProductAlias(match.matchedProduct._id, match.orderItem.title, productsToUse); + } + } + + // Snapshot costs from the catalog onto the order at import time; profit + // analysis reads these stored values so later catalog edits don't rewrite history + const orderItems = matches.map((match: any) => { + const matchedId = match.matchedProduct?._id; + return { + title: match.orderItem.title, + quantity: match.orderItem.quantity, + price: match.orderItem.price || 0, + // Locally-created fallback products have non-ObjectId ids; omit those + ...(/^[0-9a-f]{24}$/i.test(matchedId || '') && { productId: matchedId }), + printingCost: match.matchedProduct?.printingCost || 0, + costOfGoods: match.matchedProduct?.costOfGoods || 0 + }; + }); // Parse and format the order date let formattedOrderDate = new Date().toISOString(); @@ -433,8 +464,6 @@ export default function DataImport() { const handleMissingProductsSubmit = (newProducts: any[]) => { // Products are already created by the MissingProductsModal // We need to use the updated product list for matching - console.log('Missing products submitted:', newProducts); - // Close modal immediately and reset state setShowMissingProductsModal(false); setMissingProducts([]); @@ -452,26 +481,16 @@ export default function DataImport() { toast.success('Products processed successfully! Order has been updated.'); }; - const handleClearTestData = () => { - if (window.confirm('Clear all existing data for testing? This will download a backup first.')) { - DataManager.clearWithBackup(); - toast.success('Data cleared and backup downloaded!'); - setTimeout(() => window.location.reload(), 1000); - } - }; - const handleClearAllOrders = async () => { const orderCount = orders.length; if (window.confirm(`This will permanently delete all ${orderCount} orders from the database. Are you sure?`)) { - let deleted = 0; - for (const order of orders) { - try { - await api.delete(`/orders/${order._id}`); - deleted++; - } catch {} + try { + const res = await api.delete('/orders'); + dispatch(setOrders([])); + toast.success(`Deleted ${res.data.deleted} orders. Re-upload your packing slips to restore.`); + } catch { + toast.error('Failed to delete orders'); } - dispatch(setOrders([])); - toast.success(`Deleted ${deleted} orders. Re-upload your packing slips to restore.`); } }; @@ -533,80 +552,6 @@ export default function DataImport() { } }; - const debugDataState = () => { - console.log('=== DEBUGGING DATA STATE ==='); - console.log('Current orders in Redux:', orders.length); - orders.forEach((order, i) => { - console.log(`Order ${i + 1}:`, { - orderNumber: order.orderNumber, - total: order.total, - items: order.items?.length || 0, - fees: order.fees, - customer: order.customer?.name - }); - }); - - console.log('CSV Results:', results ? { - etsyOrders: results.etsyOrders?.length || 0, - sampleOrder: results.etsyOrders?.[0] - } : 'No CSV results'); - - console.log('Products:', products.length); - toast.success(`Debug info logged to console. Orders: ${orders.length}, CSV: ${results?.etsyOrders?.length || 0}, Products: ${products.length}`); - }; - - const testActualPDF = async () => { - try { - console.log('=== TESTING PDF PARSER ==='); - - // First, let's check what products we have in the database - console.log('Current products in database:', products.map(p => ({ id: p._id, title: p.title }))); - - console.log('Testing actual packing slip PDF...'); - const response = await fetch('/3748364725.pdf'); - const arrayBuffer = await response.arrayBuffer(); - const blob = new Blob([arrayBuffer], { type: 'application/pdf' }); - const file = new File([blob], '3748364725.pdf', { type: 'application/pdf' }); - - console.log('File size:', file.size, 'bytes'); - - // Let's also extract the raw text to see what we're working with - const { getDocument } = await import('pdfjs-dist'); - const pdf = await getDocument(arrayBuffer).promise; - - let fullText = ''; - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const textContent = await page.getTextContent(); - const pageText = textContent.items.map((item: any) => item.str).join(' '); - fullText += pageText + '\n'; - console.log(`Page ${i} text:`, pageText); - } - - console.log('\n=== FULL EXTRACTED TEXT ==='); - console.log(fullText); - - // Now try our parser - const result = await pdfParser.parsePackingSlip(file); - - console.log('\n=== PARSER RESULT ==='); - console.log('Parse Result:', JSON.stringify(result, null, 2)); - - if (result.items && result.items.length > 0) { - console.log('\n=== PARSED ITEMS ==='); - console.log('Order Number:', result.orderNumber); - result.items.forEach((item, index) => { - console.log(`Item ${index + 1}: ${item.title} (Qty: ${item.quantity})`); - }); - } else { - console.log('❌ No items found - we need to update the parser patterns based on the extracted text above'); - } - - } catch (error) { - console.error('Error testing PDF:', error); - } - }; - return (
@@ -618,68 +563,16 @@ export default function DataImport() {

- {/* Testing Helper */} -
- - - - -

- Fix dates | Clear all | Test parsing | Debug -

-
+
- {/* Date Fix Notice */} - {orders.length > 0 && ( -
-
-
- - - -
-
-

- Date Fix Required for Existing Orders -

-
-

- You have {orders.length} existing orders that may have incorrect dates (showing today's date instead of actual order dates). - To fix this: Click "Clear All Orders" above, then re-upload your packing slip PDFs. - The updated date parsing will now extract the correct order dates. -

-
-
-
-
- )} - {/* Import Options Tabs */}
diff --git a/client/src/store/slices/productSlice.ts b/client/src/store/slices/productSlice.ts index 53bc0ce..45eb4fe 100644 --- a/client/src/store/slices/productSlice.ts +++ b/client/src/store/slices/productSlice.ts @@ -10,6 +10,7 @@ export interface Product { sku: string; category: string; tags: string[]; + aliases?: string[]; inventory: { quantity: number; lowStockAlert: number; diff --git a/client/src/utils/csvImportService.ts b/client/src/utils/csvImportService.ts index bde7058..bc6c8a5 100644 --- a/client/src/utils/csvImportService.ts +++ b/client/src/utils/csvImportService.ts @@ -185,33 +185,37 @@ export class CSVImportService { } }); - // Second pass to calculate fees per order + // Second pass: allocate fees to orders using the order reference in the + // statement's Title/Info columns (e.g. "Order #3748364725" / "order: 3748364725"). + // Fees without an order reference (listing fees, ads GST, postage labels) + // are shop-level expenses and are tracked via parseEtsyFees instead. records.forEach(record => { - if (record.Type === 'Fee' || record.Type === 'GST') { - // Try to match fees to orders by date proximity and context - const feeAmount = Math.abs(this.parseAmount(record.Net)); - const feeDate = record.Date; - - // Find the closest order by date - let closestOrder: ParsedEtsyOrder | null = null; - let closestDateDiff = Infinity; - - for (const order of orderMap.values()) { - const dateDiff = Math.abs(new Date(order.date).getTime() - new Date(feeDate).getTime()); - if (dateDiff < closestDateDiff && dateDiff <= 7 * 24 * 60 * 60 * 1000) { // Within 7 days - closestDateDiff = dateDiff; - closestOrder = order; - } + if (record.Type !== 'Fee' && record.Type !== 'GST') return; + + const feeAmount = Math.abs(this.parseAmount(record.Net)); + if (feeAmount <= 0) return; + + const refSource = `${record.Title} ${record.Info || ''}`; + const refMatch = refSource.match(/Order\s*#(\d+)/i) || refSource.match(/order:?\s*(\d+)/i); + let order = refMatch ? orderMap.get(refMatch[1]) : undefined; + + // Unreferenced transaction GST (e.g. "GST: shipping_transaction") belongs + // to an order but carries no reference; only allocate it when exactly one + // order exists on the same date, so it can't land on the wrong order. + if (!order && !refMatch && record.Title.toLowerCase().includes('transaction')) { + const recordDate = this.parseDate(record.Date); + const sameDayOrders = Array.from(orderMap.values()).filter(o => o.date === recordDate); + if (sameDayOrders.length === 1) { + order = sameDayOrders[0]; } - - if (closestOrder) { - closestOrder.totalFees += feeAmount; - closestOrder.netAmount = closestOrder.saleAmount - closestOrder.totalFees; - - // Check for shipping fees - if (record.Title.toLowerCase().includes('shipping')) { - closestOrder.shippingFee = feeAmount; - } + } + + if (order) { + order.totalFees += feeAmount; + order.netAmount = order.saleAmount - order.totalFees; + + if (record.Title.toLowerCase().includes('shipping')) { + order.shippingFee = feeAmount; } } }); @@ -237,6 +241,9 @@ export class CSVImportService { let reference: string | undefined; const description = record.Title; const lowerTitle = description.toLowerCase(); + // References (order/listing/label numbers) usually live in the Info + // column, e.g. Title "Transaction fee: Moai Bookend", Info "Order #3748364725" + const refSource = `${record.Title} ${record.Info || ''}`; // Categorize different types of Etsy fees if (record.Type === 'Marketing' || lowerTitle.includes('etsy ads')) { @@ -244,24 +251,26 @@ export class CSVImportService { } else if (lowerTitle.includes('listing fee')) { category = 'Listing Fees'; // Extract listing ID if available - const listingMatch = description.match(/listing.*?#?(\d+)/i); + const listingMatch = refSource.match(/listing\s*:?\s*#?(\d+)/i); if (listingMatch) reference = `listing-${listingMatch[1]}`; } else if (lowerTitle.includes('transaction fee')) { category = 'Transaction Fees'; // Extract order number if available - const orderMatch = description.match(/order.*?#?(\d+)/i); + const orderMatch = refSource.match(/order\s*:?\s*#?(\d+)/i); if (orderMatch) reference = orderMatch[1]; } else if (lowerTitle.includes('processing fee')) { category = 'Payment Processing Fees'; // Extract order number if available - const orderMatch = description.match(/order.*?#?(\d+)/i); + const orderMatch = refSource.match(/order\s*:?\s*#?(\d+)/i); if (orderMatch) reference = orderMatch[1]; } else if (record.Type === 'GST' || lowerTitle.includes('gst')) { category = 'Taxes & GST'; + const orderMatch = refSource.match(/order\s*:?\s*#?(\d+)/i); + if (orderMatch) reference = orderMatch[1]; } else if (lowerTitle.includes('shipping') || lowerTitle.includes('postage')) { category = 'Shipping & Postage'; // Extract label/tracking number if available - const labelMatch = description.match(/label.*?#?(\d+)/i); + const labelMatch = refSource.match(/label\s*:?\s*#?(\d+)/i); if (labelMatch) reference = `label-${labelMatch[1]}`; } else { category = 'Other Etsy Fees'; diff --git a/client/src/utils/orderCalculations.ts b/client/src/utils/orderCalculations.ts index 41fc2c9..16fe1a5 100644 --- a/client/src/utils/orderCalculations.ts +++ b/client/src/utils/orderCalculations.ts @@ -41,14 +41,20 @@ export const calculateOrderProfit = (items: OrderItem[], orderTotal: number = 0) return revenue - costs; }; -// Match order items with products to add cost information +// Fill in cost information for order items. +// Costs captured on the order at import time are the source of truth, so that +// later changes to catalog prices don't rewrite historical profit figures. +// Only legacy items with no cost fields at all fall back to a catalog lookup. export const enrichOrderItemsWithCosts = ( - items: OrderItem[], + items: OrderItem[], products: Product[] ): OrderItem[] => { return items.map(item => { - // Try to find matching product by title (could be improved with SKU matching) - const matchingProduct = products.find(product => + if (item.printingCost !== undefined || item.costOfGoods !== undefined) { + return item; + } + + const matchingProduct = products.find(product => product.title.toLowerCase().includes(item.title.toLowerCase()) || item.title.toLowerCase().includes(product.title.toLowerCase()) ); diff --git a/client/src/utils/pdfParser.ts b/client/src/utils/pdfParser.ts index 43a0664..fe45c30 100644 --- a/client/src/utils/pdfParser.ts +++ b/client/src/utils/pdfParser.ts @@ -32,18 +32,13 @@ export interface ParsedPackingSlip { export class PDFPackingSlipParser { async parsePackingSlip(file: File): Promise { - console.log('Starting PDF parsing for file:', file.name, 'Size:', file.size, 'Type:', file.type); - try { const arrayBuffer = await file.arrayBuffer(); - console.log('File read as ArrayBuffer, size:', arrayBuffer.byteLength); - const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); const pdf = await loadingTask.promise; - console.log('PDF loaded successfully, pages:', pdf.numPages); - + let fullText = ''; - + // Extract text from all pages for (let i = 1; i <= pdf.numPages; i++) { try { @@ -53,21 +48,14 @@ export class PDFPackingSlipParser { .map((item: any) => item.str) .join(' '); fullText += pageText + '\n'; - console.log(`Page ${i} text length:`, pageText.length); } catch (pageError) { console.warn(`Error reading page ${i}:`, pageError); } } - - console.log('Total extracted text length:', fullText.length); - console.log('First 500 characters:', fullText.substring(0, 500)); - - const result = this.parsePackingSlipText(fullText); - console.log('Parsing result:', result); - - return result; + + return this.parsePackingSlipText(fullText); } catch (error) { - console.error('Detailed PDF parsing error:', error); + console.error('PDF parsing error:', error); if (error instanceof Error) { throw new Error(`PDF parsing failed: ${error.message}`); } else { @@ -77,8 +65,6 @@ export class PDFPackingSlipParser { } private parsePackingSlipText(text: string): ParsedPackingSlip { - console.log('Raw PDF text:', text); // For debugging - // Initialize result const result: ParsedPackingSlip = { orderNumber: '', @@ -152,19 +138,15 @@ export class PDFPackingSlipParser { private extractItems(text: string): ParsedItem[] { const items: ParsedItem[] = []; - - console.log('Extracting items from text:', text); - + // For Etsy packing slips, look for the pattern after "items" and before "Item total" const itemsSection = text.match(/(\d+)\s+items?\s+(.*?)Item total/is); if (itemsSection) { const itemsText = itemsSection[2]; - console.log('Items section:', itemsText); - + // Look for price patterns first to identify item boundaries const priceMatches = [...itemsText.matchAll(/(\d+)\s+x\s+AU\$(\d+\.?\d*)/g)]; - console.log('Price matches found:', priceMatches.map(m => m[0])); - + if (priceMatches.length > 0) { // Split the text by price patterns to get item descriptions const textParts = itemsText.split(/\d+\s+x\s+AU\$\d+\.?\d*/); @@ -208,7 +190,6 @@ export class PDFPackingSlipParser { .trim(); if (title && quantity > 0) { - console.log('Extracted item:', { title, quantity }); items.push({ title, quantity }); } } @@ -217,8 +198,6 @@ export class PDFPackingSlipParser { // Enhanced fallback: Look for the specific Etsy format in the full text if (items.length === 0) { - console.log('No items found with section method, trying direct pattern matching...'); - // Pattern to find complete item lines in the original text // Look for the specific format: "Item Name Colour: ... Size: ... 1 x AU$15.00" @@ -233,13 +212,11 @@ export class PDFPackingSlipParser { // Note: We ignore the price from packing slip as it's tracked from Etsy CSV if (title && quantity > 0) { - console.log('Direct pattern found item:', { title, quantity }); items.push({ title, quantity }); } } } - - console.log('Final extracted items:', items); + return items; } diff --git a/client/src/utils/productMatcher.ts b/client/src/utils/productMatcher.ts index 66053bf..533d3fb 100644 --- a/client/src/utils/productMatcher.ts +++ b/client/src/utils/productMatcher.ts @@ -26,6 +26,25 @@ export interface MatchingResult { }[]; } +/** + * Normalize a title for exact/alias comparison (whitespace and case insensitive) + */ +export const normalizeTitle = (title: string): string => + title.replace(/\s+/g, ' ').trim().toLowerCase(); + +/** + * Find a product whose title or saved aliases exactly match the item title. + * Aliases are packing-slip titles that were previously matched (automatically + * or by the user), so this lookup is deterministic and fully confident. + */ +const findAliasMatch = (itemTitle: string, products: any[]): any | null => { + const normalized = normalizeTitle(itemTitle); + return products.find(product => + normalizeTitle(product.title) === normalized || + (product.aliases || []).some((alias: string) => normalizeTitle(alias) === normalized) + ) || null; +}; + /** * Match order items against existing products in the database * Considers size as a factor but ignores color variations @@ -39,9 +58,30 @@ export const matchOrderItemsToProducts = ( const missingProducts: any[] = []; for (const item of orderItems) { + // Deterministic lookup first: exact title or saved alias from a previous import + const aliasMatch = findAliasMatch(item.title, products); + if (aliasMatch) { + matches.push({ + orderItem: { + title: item.title, + quantity: item.quantity, + price: item.price + }, + matchedProduct: { + _id: aliasMatch._id, + title: aliasMatch.title, + printingCost: aliasMatch.printingCost || 0, + costOfGoods: aliasMatch.costOfGoods || 0, + size: aliasMatch.size + }, + confidence: 1 + }); + continue; + } + let bestMatch: any = null; let bestConfidence = 0; - + // Enhanced size extraction - look for "Large" or "Small" specifically const itemTitle = item.title.toLowerCase(); let itemSize = ''; @@ -68,11 +108,7 @@ export const matchOrderItemsToProducts = ( .replace(/\s+/g, ' ') .trim() .toLowerCase(); - - console.log('🔍 Matching item:', item.title); - console.log(' Clean title:', cleanItemTitle); - console.log(' Extracted size:', itemSize); - + for (const product of products) { const productTitle = product.title.toLowerCase(); let productSize = ''; @@ -102,7 +138,6 @@ export const matchOrderItemsToProducts = ( let sizeSimilarity = 0; if (itemSize && productSize) { sizeSimilarity = itemSize === productSize ? 1 : 0; - console.log(` Size match: ${itemSize} vs ${productSize} = ${sizeSimilarity}`); } else if (!itemSize && !productSize) { sizeSimilarity = 0.7; // Both have no size specified } else if (itemSize && !productSize) { @@ -128,13 +163,6 @@ export const matchOrderItemsToProducts = ( confidence = (titleSimilarity * 0.8) + (sizeSimilarity * 0.2); } - console.log(` Product: ${product.title}`); - console.log(` Clean: ${cleanProductTitle}`); - console.log(` Product size: ${productSize}`); - console.log(` Title similarity: ${titleSimilarity.toFixed(3)}`); - console.log(` Size similarity: ${sizeSimilarity}`); - console.log(` Final confidence: ${confidence.toFixed(3)}`); - // Require higher confidence for shelf decor products to avoid wrong matches const minConfidence = isShelfDecor ? 0.7 : 0.5; @@ -160,7 +188,6 @@ export const matchOrderItemsToProducts = ( }, confidence: bestConfidence }); - console.log(`✅ Matched "${item.title}" to "${bestMatch.title}" (${(bestConfidence * 100).toFixed(1)}%)`); } else { missingProducts.push({ title: item.title, @@ -168,7 +195,6 @@ export const matchOrderItemsToProducts = ( ...(item.price !== undefined && { price: item.price }), size: itemSize }); - console.log(`❌ No match found for "${item.title}"`); } } diff --git a/client/src/utils/profitAnalysisService.ts b/client/src/utils/profitAnalysisService.ts index a123b1c..e641619 100644 --- a/client/src/utils/profitAnalysisService.ts +++ b/client/src/utils/profitAnalysisService.ts @@ -104,24 +104,11 @@ export class ProfitAnalysisService { if (orderProfit > 0) profitableOrderCount++; }); - // Calculate total expenses (excluding only sale-related transaction fees to avoid double-counting) - const totalExpenses = expenses ? expenses.reduce((sum, expense) => { - // Only exclude transaction fees that are tied to specific orders (already deducted from order totals) - // Include: Listing Fees, Marketing/Ads, GST, and other business expenses - const isSaleTransactionFee = ( - expense.vendor?.toLowerCase().includes('etsy') && - expense.category?.toLowerCase() === 'transaction fees' && - expense.reference && // Has an order reference - (expense.description?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('processing fee')) - ); - - if (isSaleTransactionFee) { - console.log('Excluding sale transaction fee (already in order totals):', expense.description, expense.reference); - return sum; // Skip sale transaction fees as they're already deducted from order totals - } - return sum + (expense?.amount || 0); - }, 0) : 0; + // Order totals are gross sale amounts (fees are NOT deducted from them), + // so every expense counts against revenue exactly once + const totalExpenses = expenses + ? expenses.reduce((sum, expense) => sum + (expense?.amount || 0), 0) + : 0; // Calculate final profit including all expenses const totalProfit = totalRevenue - totalPrintingCosts - totalExpenses; @@ -234,17 +221,6 @@ export class ProfitAnalysisService { const expenseDate = new Date(expense.date); const monthKey = `${expenseDate.getFullYear()}-${String(expenseDate.getMonth() + 1).padStart(2, '0')}`; const monthName = expenseDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' }); - - // Skip only sale transaction fees to avoid double-counting (keep listing fees, ads, etc.) - const isSaleTransactionFee = ( - expense.vendor?.toLowerCase().includes('etsy') && - expense.category?.toLowerCase() === 'transaction fees' && - expense.reference && // Has an order reference - (expense.description?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('processing fee')) - ); - - if (isSaleTransactionFee) return; // Create month entry if it doesn't exist (for expense-only months) if (!monthlyData.has(monthKey)) { diff --git a/client/src/utils/testActualPDF.ts b/client/src/utils/testActualPDF.ts deleted file mode 100644 index 27b8733..0000000 --- a/client/src/utils/testActualPDF.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Test script to parse the actual packing slip PDF -import { pdfParser } from './pdfParser'; - -async function testActualPDF() { - try { - // Simulate a File object for the PDF - const response = await fetch('/3748364725.pdf'); - const arrayBuffer = await response.arrayBuffer(); - const blob = new Blob([arrayBuffer], { type: 'application/pdf' }); - const file = new File([blob], '3748364725.pdf', { type: 'application/pdf' }); - - console.log('Testing actual packing slip PDF...'); - console.log('File size:', file.size, 'bytes'); - - const result = await pdfParser.parsePackingSlip(file); - - console.log('Parse Result:', JSON.stringify(result, null, 2)); - - if (result.items && result.items.length > 0) { - console.log('\n=== PARSED ITEMS ==='); - console.log('Order Number:', result.orderNumber); - console.log('Items:', result.items); - result.items.forEach((item, index: number) => { - console.log(`Item ${index + 1}: ${item.title} (Qty: ${item.quantity})`); - }); - } else { - console.log('No items found in PDF'); - } - - } catch (error) { - console.error('Error testing PDF:', error); - } -} - -// Export for manual testing -export { testActualPDF }; - -// If running directly (not imported), execute the test -if (typeof window !== 'undefined') { - // Browser environment - (window as any).testActualPDF = testActualPDF; - console.log('Test function available as window.testActualPDF()'); -} \ No newline at end of file diff --git a/client/src/utils/testDateParsing.ts b/client/src/utils/testDateParsing.ts deleted file mode 100644 index a8ddcc7..0000000 --- a/client/src/utils/testDateParsing.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Quick test for date parsing from packing slip text -export function testDateParsing() { - // Sample text from actual packing slip - const sampleText = ` - Packing slip for order #3748364725 - Order date 21 Jul, 2025 - Ship to: - David L - `; - - // Test the regex pattern (same as in pdfParser.ts) - const datePattern = /Order [Dd]ate:?\s*(\d{1,2} [A-Z][a-z]{2}, \d{4})/i; - const dateMatch = sampleText.match(datePattern); - - console.log('=== TESTING DATE EXTRACTION ==='); - console.log('Sample text contains:', sampleText.trim()); - console.log('Date pattern:', datePattern.toString()); - console.log('Match found:', dateMatch); - - if (dateMatch && dateMatch[1]) { - const extractedDate = dateMatch[1]; - console.log('Extracted date string:', extractedDate); - - // Test parsing to Date object - try { - const parsedDate = new Date(extractedDate); - console.log('Parsed Date object:', parsedDate); - console.log('Is valid date:', !isNaN(parsedDate.getTime())); - console.log('ISO string:', parsedDate.toISOString()); - - // Test today's date for comparison - const today = new Date(); - console.log('Today for comparison:', today.toISOString()); - console.log('Are dates different?', parsedDate.toDateString() !== today.toDateString()); - - return { - success: true, - extractedDate, - parsedDate: parsedDate.toISOString(), - isValidDate: !isNaN(parsedDate.getTime()), - isDifferentFromToday: parsedDate.toDateString() !== today.toDateString() - }; - } catch (error) { - console.error('Date parsing error:', error); - return { success: false, error }; - } - } else { - console.log('No date match found - this is the problem!'); - return { success: false, error: 'No date match found' }; - } -} - -// Export for use in components -export const dateTestResults = testDateParsing(); \ No newline at end of file diff --git a/client/src/utils/testPDF.ts b/client/src/utils/testPDF.ts deleted file mode 100644 index d63b1c5..0000000 --- a/client/src/utils/testPDF.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Simple test component for PDF parsing -import { pdfParser } from '../utils/pdfParser'; - -export const testPDF = async (file: File) => { - console.log('=== PDF Test Debug ==='); - console.log('File name:', file.name); - console.log('File size:', file.size); - console.log('File type:', file.type); - - try { - const result = await pdfParser.parsePackingSlip(file); - console.log('Parse result:', result); - return result; - } catch (error) { - console.error('Parse error:', error); - throw error; - } -}; \ No newline at end of file diff --git a/server/src/models/Product.ts b/server/src/models/Product.ts index e0b916e..7c6c632 100644 --- a/server/src/models/Product.ts +++ b/server/src/models/Product.ts @@ -9,6 +9,7 @@ export interface IProduct extends Document { sku: string; category: string; tags: string[]; + aliases: string[]; images: string[]; variants: { name: string; @@ -44,6 +45,9 @@ const ProductSchema: Schema = new Schema({ sku: { type: String, trim: true, default: '' }, category: { type: String, default: 'Other' }, tags: [{ type: String, trim: true }], + // Packing-slip item titles previously matched to this product; used for + // deterministic matching on future imports + aliases: [{ type: String, trim: true }], images: [{ type: String }], variants: [{ name: { type: String, required: true }, diff --git a/server/src/routes/orders.ts b/server/src/routes/orders.ts index 2551d82..244d867 100644 --- a/server/src/routes/orders.ts +++ b/server/src/routes/orders.ts @@ -95,6 +95,15 @@ router.put('/:id', async (req: AuthRequest, res: Response) => { } }); +router.delete('/', async (req: AuthRequest, res: Response) => { + try { + const result = await Order.deleteMany({ userId: req.userId }); + res.json({ message: 'All orders deleted', deleted: result.deletedCount }); + } catch (err) { + res.status(500).json({ message: 'Failed to delete orders', error: err }); + } +}); + router.delete('/:id', async (req: AuthRequest, res: Response) => { try { const order = await Order.findOneAndDelete({ _id: req.params.id, userId: req.userId });