Add alias matching, cost snapshots, reference-based fee allocation; remove debug tooling
- Products store packing-slip title aliases; matcher checks exact title/alias first so previously-matched items skip fuzzy matching entirely - Packing-slip imports snapshot printingCost/costOfGoods/productId onto order items; profit analysis reads stored costs so catalog edits don't rewrite history - Etsy statement fees allocate to orders via Order # references in Title/Info instead of date proximity; shop-level fees (listings, ads) no longer leak into order fees - Remove broken transaction-fee exclusion guard (order totals are gross, so all expenses count once) - Remove debug buttons, date-fix banner, test files, and console.log noise; Clear All Orders now uses a bulk DELETE /orders endpoint Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d325d547be
commit
b27ede4131
16 changed files with 216 additions and 945 deletions
|
|
@ -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.');
|
||||
|
|
@ -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<MissingProductsModalProps> = ({
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MissingProductsModalProps> = ({
|
||||
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<typeof findPotentialMatches>} = {};
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Missing Products - Add Printing Costs
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 mb-4">
|
||||
The following products from your order are not in your product database.
|
||||
Please add printing costs (including materials/filament) to complete the import:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{missingProducts.map((product, index) => (
|
||||
<div key={index} className="border rounded-lg p-4 bg-gray-50">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Product Name
|
||||
</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
{product.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Quantity ordered: {product.quantity}{product.price ? ` × $${product.price.toFixed(2)} each` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show suggestions if available */}
|
||||
{suggestions[product.title] && suggestions[product.title].length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">
|
||||
Possible matches found:
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{suggestions[product.title].map((suggestion, sugIndex) => (
|
||||
<div key={sugIndex} className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-gray-900">
|
||||
{suggestion.product.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{suggestion.reason} ({(suggestion.confidence * 100).toFixed(0)}% match)
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUseExisting(product.title, suggestion.product._id)}
|
||||
className={`px-3 py-1 text-xs rounded-md border transition-colors ${
|
||||
productData[product.title]?.useExisting === suggestion.product._id
|
||||
? 'bg-green-100 border-green-300 text-green-700'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{productData[product.title]?.useExisting === suggestion.product._id ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Selected
|
||||
</span>
|
||||
) : (
|
||||
'Use This'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only show input fields if not using existing product */}
|
||||
{!productData[product.title]?.useExisting && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Printing Cost ($)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={productData[product.title]?.printingCost || ''}
|
||||
onChange={(e) => handleInputChange(product.title, 'printingCost', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Include filament, materials, and time costs
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
value={productData[product.title]?.category || ''}
|
||||
onChange={(e) => handleInputChange(product.title, 'category', e.target.value)}
|
||||
>
|
||||
<option value="">Select category</option>
|
||||
<option value="Imported Items">Imported Items</option>
|
||||
<option value="3D Printed">3D Printed</option>
|
||||
<option value="Home & Living">Home & Living</option>
|
||||
<option value="Art & Collectibles">Art & Collectibles</option>
|
||||
<option value="Jewelry">Jewelry</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Printing Cost per Item (inc. materials)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => handleInputChange(product.title, 'printingCost', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => handleInputChange(product.title, 'category', e.target.value)}
|
||||
>
|
||||
<option value="">Select category...</option>
|
||||
<option value="3D Prints">3D Prints</option>
|
||||
<option value="Jewelry">Jewelry</option>
|
||||
<option value="Home Decor">Home Decor</option>
|
||||
<option value="Organizers">Organizers</option>
|
||||
<option value="Planters">Planters</option>
|
||||
<option value="Custom Items">Custom Items</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddProducts}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Products to Database
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,18 +328,11 @@ 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) {
|
||||
// All expenses count, matching the profit calculation
|
||||
// (order revenue is gross, so fees are not double-counted)
|
||||
current.expenses += expense.amount || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate profit for each month
|
||||
|
|
@ -417,8 +362,6 @@ 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));
|
||||
|
|
@ -432,23 +375,8 @@ const Analytics = () => {
|
|||
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(() => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
}
|
||||
};
|
||||
|
||||
const createOrUpdateOrderFromSlip = async (slip: ParsedPackingSlip, customProducts?: any[]) => {
|
||||
console.log('Creating/updating order for slip:', slip);
|
||||
// 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[]) => {
|
||||
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) => ({
|
||||
// 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,
|
||||
printingCost: match.matchedProduct?.printingCost || 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 {}
|
||||
}
|
||||
const res = await api.delete('/orders');
|
||||
dispatch(setOrders([]));
|
||||
toast.success(`Deleted ${deleted} orders. Re-upload your packing slips to restore.`);
|
||||
toast.success(`Deleted ${res.data.deleted} orders. Re-upload your packing slips to restore.`);
|
||||
} catch {
|
||||
toast.error('Failed to delete orders');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
|
|
@ -618,8 +563,6 @@ export default function DataImport() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Testing Helper */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleClearAllOrders}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 text-sm"
|
||||
|
|
@ -627,58 +570,8 @@ export default function DataImport() {
|
|||
<Trash2 className="w-4 h-4" />
|
||||
Clear All Orders
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearTestData}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 text-sm"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear for Testing
|
||||
</button>
|
||||
<button
|
||||
onClick={testActualPDF}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 text-sm"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Test PDF Parser
|
||||
</button>
|
||||
<button
|
||||
onClick={debugDataState}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 text-sm"
|
||||
>
|
||||
<Package className="w-4 h-4" />
|
||||
Debug Data
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 max-w-32 text-center">
|
||||
Fix dates | Clear all | Test parsing | Debug
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Fix Notice */}
|
||||
{orders.length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">
|
||||
Date Fix Required for Existing Orders
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
You have {orders.length} existing orders that may have incorrect dates (showing today's date instead of actual order dates).
|
||||
To fix this: <strong>Click "Clear All Orders"</strong> above, then re-upload your packing slip PDFs.
|
||||
The updated date parsing will now extract the correct order dates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Options Tabs */}
|
||||
<div className="bg-white rounded-lg shadow-md mb-6">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface Product {
|
|||
sku: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
aliases?: string[];
|
||||
inventory: {
|
||||
quantity: number;
|
||||
lowStockAlert: number;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
if (record.Type !== 'Fee' && record.Type !== 'GST') return;
|
||||
|
||||
const feeAmount = Math.abs(this.parseAmount(record.Net));
|
||||
const feeDate = record.Date;
|
||||
if (feeAmount <= 0) return;
|
||||
|
||||
// Find the closest order by date
|
||||
let closestOrder: ParsedEtsyOrder | null = null;
|
||||
let closestDateDiff = Infinity;
|
||||
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;
|
||||
|
||||
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;
|
||||
// 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;
|
||||
if (order) {
|
||||
order.totalFees += feeAmount;
|
||||
order.netAmount = order.saleAmount - order.totalFees;
|
||||
|
||||
// Check for shipping fees
|
||||
if (record.Title.toLowerCase().includes('shipping')) {
|
||||
closestOrder.shippingFee = feeAmount;
|
||||
}
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -41,13 +41,19 @@ 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[],
|
||||
products: Product[]
|
||||
): OrderItem[] => {
|
||||
return items.map(item => {
|
||||
// Try to find matching product by title (could be improved with SKU matching)
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -32,15 +32,10 @@ export interface ParsedPackingSlip {
|
|||
|
||||
export class PDFPackingSlipParser {
|
||||
async parsePackingSlip(file: File): Promise<ParsedPackingSlip> {
|
||||
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 = '';
|
||||
|
||||
|
|
@ -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: '',
|
||||
|
|
@ -153,17 +139,13 @@ 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +58,27 @@ 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;
|
||||
|
||||
|
|
@ -69,10 +109,6 @@ export const matchOrderItemsToProducts = (
|
|||
.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}"`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -235,17 +222,6 @@ export class ProfitAnalysisService {
|
|||
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)) {
|
||||
monthlyData.set(monthKey, {
|
||||
|
|
|
|||
|
|
@ -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()');
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue