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 React, { useState, useMemo } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { RootState } from '../store';
|
import { RootState } from '../store';
|
||||||
import { addProduct } from '../store/slices/productSlice';
|
import { addProduct, updateProduct } from '../store/slices/productSlice';
|
||||||
import { findPotentialMatches } from '../utils/productMatcher';
|
import { findPotentialMatches, normalizeTitle } from '../utils/productMatcher';
|
||||||
import { X, Plus, AlertCircle, CheckCircle } from 'lucide-react';
|
import { X, Plus, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
|
|
@ -90,18 +90,32 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
|
||||||
for (const product of missingProducts) {
|
for (const product of missingProducts) {
|
||||||
const data = productData[product.title] || {};
|
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) {
|
if (data.useExisting) {
|
||||||
const existingProduct = products.find(p => p._id === data.useExisting);
|
const existingProduct = products.find(p => p._id === data.useExisting);
|
||||||
if (existingProduct) {
|
if (existingProduct) {
|
||||||
existingMatches.push({
|
let updatedProduct: any = existingProduct;
|
||||||
orderItem: {
|
const normalized = normalizeTitle(product.title);
|
||||||
title: product.title,
|
const alreadyKnown =
|
||||||
quantity: product.quantity,
|
normalizeTitle(existingProduct.title) === normalized ||
|
||||||
price: product.price
|
(existingProduct.aliases || []).some(alias => normalizeTitle(alias) === normalized);
|
||||||
},
|
|
||||||
matchedProduct: existingProduct
|
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;
|
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);
|
return sum + getUpdatedPrintingCost(order);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Calculate expenses excluding only sale transaction fees to avoid double-counting
|
// Order totals are gross sale amounts (fees are NOT deducted from them),
|
||||||
// (Sale transaction fees are already deducted from order totals in the CSV)
|
// so every expense — including Etsy transaction fees — counts once
|
||||||
// But we DO want to include Etsy Ads, Listing Fees, GST, subscriptions, etc.
|
const totalExpenses = filteredExpenses.reduce((sum, expense) => sum + (expense?.amount || 0), 0);
|
||||||
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);
|
|
||||||
|
|
||||||
const netProfit = totalRevenue - totalExpenses - totalPrintingCosts;
|
const netProfit = totalRevenue - totalExpenses - totalPrintingCosts;
|
||||||
const profitMargin = totalRevenue > 0 ? (netProfit / totalRevenue) * 100 : 0;
|
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 averageOrderValue = filteredOrders.length > 0 ? totalRevenue / filteredOrders.length : 0;
|
||||||
const totalCustomers = customers?.length || 0;
|
const totalCustomers = customers?.length || 0;
|
||||||
|
|
||||||
|
|
@ -376,16 +328,9 @@ const Analytics = () => {
|
||||||
const current = monthlyMap.get(monthKey);
|
const current = monthlyMap.get(monthKey);
|
||||||
|
|
||||||
if (current) {
|
if (current) {
|
||||||
// For monthly chart, exclude transaction fees to match profit calculation
|
// All expenses count, matching the profit calculation
|
||||||
const isEtsyTransactionFee = (
|
// (order revenue is gross, so fees are not double-counted)
|
||||||
(expense.vendor?.toLowerCase() === 'etsy' &&
|
current.expenses += expense.amount || 0;
|
||||||
(expense.category?.toLowerCase().includes('transaction fee') ||
|
|
||||||
expense.description?.toLowerCase().includes('transaction fee')))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isEtsyTransactionFee) {
|
|
||||||
current.expenses += expense.amount || 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -417,38 +362,21 @@ const Analytics = () => {
|
||||||
const categories = new Map();
|
const categories = new Map();
|
||||||
|
|
||||||
filteredExpenses.forEach(expense => {
|
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 category = expense.category || 'Other';
|
||||||
const current = categories.get(category) || 0;
|
const current = categories.get(category) || 0;
|
||||||
categories.set(category, current + (expense.amount || 0));
|
categories.set(category, current + (expense.amount || 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalExpenseAmount = Array.from(categories.values()).reduce((sum, amount) => sum + amount, 0);
|
const totalExpenseAmount = Array.from(categories.values()).reduce((sum, amount) => sum + amount, 0);
|
||||||
|
|
||||||
const categoryData = Array.from(categories.entries()).map(([category, amount]) => ({
|
const categoryData = Array.from(categories.entries()).map(([category, amount]) => ({
|
||||||
category,
|
category,
|
||||||
amount,
|
amount,
|
||||||
percentage: totalExpenseAmount > 0 ? (amount / totalExpenseAmount) * 100 : 0
|
percentage: totalExpenseAmount > 0 ? (amount / totalExpenseAmount) * 100 : 0
|
||||||
})).sort((a, b) => b.amount - a.amount);
|
})).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;
|
return categoryData;
|
||||||
}, [filteredExpenses, totalExpenses, totalRevenue, totalPrintingCosts, netProfit]);
|
}, [filteredExpenses]);
|
||||||
|
|
||||||
// Calculate top profitable products based on actual sales performance
|
// Calculate top profitable products based on actual sales performance
|
||||||
const topProducts = useMemo(() => {
|
const topProducts = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { csvImportService, ParsedEtsyOrder, ParsedShippingRecord, EtsyFeeRecord } from '../utils/csvImportService';
|
import { csvImportService, ParsedEtsyOrder, ParsedShippingRecord, EtsyFeeRecord } from '../utils/csvImportService';
|
||||||
import { pdfParser, ParsedPackingSlip } from '../utils/pdfParser';
|
import { pdfParser, ParsedPackingSlip } from '../utils/pdfParser';
|
||||||
import { matchOrderItemsToProducts } from '../utils/productMatcher';
|
import { matchOrderItemsToProducts, normalizeTitle } from '../utils/productMatcher';
|
||||||
import { MissingProductsModal, MissingProduct } from '../components/MissingProductsModal';
|
import { MissingProductsModal, MissingProduct } from '../components/MissingProductsModal';
|
||||||
import { DataManager } from '../utils/dataManager';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { RootState } from '../store';
|
import { RootState } from '../store';
|
||||||
import { addOrder, updateOrder, setOrders } from '../store/slices/orderSlice';
|
import { addOrder, updateOrder, setOrders } from '../store/slices/orderSlice';
|
||||||
|
import { updateProduct } from '../store/slices/productSlice';
|
||||||
import { Upload, FileText, Package, Truck, Trash2 } from 'lucide-react';
|
import { Upload, FileText, Package, Truck, Trash2 } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
|
|
@ -151,15 +151,12 @@ export default function DataImport() {
|
||||||
|
|
||||||
// Process expenses with bulk API for much faster processing
|
// Process expenses with bulk API for much faster processing
|
||||||
if (expensesToCreate.length > 0) {
|
if (expensesToCreate.length > 0) {
|
||||||
console.log(`Creating ${expensesToCreate.length} expenses via bulk API...`);
|
|
||||||
setBackgroundProcessing(`Creating ${expensesToCreate.length} expenses...`);
|
setBackgroundProcessing(`Creating ${expensesToCreate.length} expenses...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/expenses/bulk', expensesToCreate);
|
const response = await api.post('/expenses/bulk', expensesToCreate);
|
||||||
const { created, duplicates, errors, message } = response.data;
|
const { created, duplicates, errors, message } = response.data;
|
||||||
|
|
||||||
console.log(`Bulk expense creation completed: ${created} created, ${duplicates} duplicates, ${errors} errors`);
|
|
||||||
|
|
||||||
if (created > 0 || duplicates > 0) {
|
if (created > 0 || duplicates > 0) {
|
||||||
toast.success(`✅ ${message}`);
|
toast.success(`✅ ${message}`);
|
||||||
} else if (errors > 0) {
|
} else if (errors > 0) {
|
||||||
|
|
@ -334,7 +331,6 @@ export default function DataImport() {
|
||||||
const parsedSlips: ParsedPackingSlip[] = [];
|
const parsedSlips: ParsedPackingSlip[] = [];
|
||||||
|
|
||||||
for (const file of pdfFiles) {
|
for (const file of pdfFiles) {
|
||||||
console.log(`Processing PDF: ${file.name}`);
|
|
||||||
const slip = await pdfParser.parsePackingSlip(file);
|
const slip = await pdfParser.parsePackingSlip(file);
|
||||||
if (slip) {
|
if (slip) {
|
||||||
parsedSlips.push(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[]) => {
|
const createOrUpdateOrderFromSlip = async (slip: ParsedPackingSlip, customProducts?: any[]) => {
|
||||||
console.log('Creating/updating order for slip:', slip);
|
|
||||||
|
|
||||||
const existingOrder = orders.find(order => order.orderNumber === slip.orderNumber);
|
const existingOrder = orders.find(order => order.orderNumber === slip.orderNumber);
|
||||||
|
|
||||||
// Check if we have CSV results for this order number to get revenue data
|
// Check if we have CSV results for this order number to get revenue data
|
||||||
let csvOrderData = null;
|
let csvOrderData = null;
|
||||||
if (results && results.etsyOrders) {
|
if (results && results.etsyOrders) {
|
||||||
csvOrderData = results.etsyOrders.find(csvOrder => csvOrder.orderNumber === slip.orderNumber);
|
csvOrderData = results.etsyOrders.find(csvOrder => csvOrder.orderNumber === slip.orderNumber);
|
||||||
console.log('Found matching CSV data for order:', csvOrderData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const productsToUse = customProducts || products;
|
const productsToUse = customProducts || products;
|
||||||
|
|
@ -377,12 +392,28 @@ export default function DataImport() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderItems = matches.map((match: any) => ({
|
// Remember fuzzy-matched titles as aliases so future imports of the same
|
||||||
title: match.orderItem.title,
|
// packing-slip title skip the fuzzy matcher entirely
|
||||||
quantity: match.orderItem.quantity,
|
for (const match of matches) {
|
||||||
price: match.orderItem.price || 0,
|
if (match.matchedProduct && match.confidence < 1) {
|
||||||
printingCost: match.matchedProduct?.printingCost || 0
|
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
|
// Parse and format the order date
|
||||||
let formattedOrderDate = new Date().toISOString();
|
let formattedOrderDate = new Date().toISOString();
|
||||||
|
|
@ -433,8 +464,6 @@ export default function DataImport() {
|
||||||
const handleMissingProductsSubmit = (newProducts: any[]) => {
|
const handleMissingProductsSubmit = (newProducts: any[]) => {
|
||||||
// Products are already created by the MissingProductsModal
|
// Products are already created by the MissingProductsModal
|
||||||
// We need to use the updated product list for matching
|
// We need to use the updated product list for matching
|
||||||
console.log('Missing products submitted:', newProducts);
|
|
||||||
|
|
||||||
// Close modal immediately and reset state
|
// Close modal immediately and reset state
|
||||||
setShowMissingProductsModal(false);
|
setShowMissingProductsModal(false);
|
||||||
setMissingProducts([]);
|
setMissingProducts([]);
|
||||||
|
|
@ -452,26 +481,16 @@ export default function DataImport() {
|
||||||
toast.success('Products processed successfully! Order has been updated.');
|
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 handleClearAllOrders = async () => {
|
||||||
const orderCount = orders.length;
|
const orderCount = orders.length;
|
||||||
if (window.confirm(`This will permanently delete all ${orderCount} orders from the database. Are you sure?`)) {
|
if (window.confirm(`This will permanently delete all ${orderCount} orders from the database. Are you sure?`)) {
|
||||||
let deleted = 0;
|
try {
|
||||||
for (const order of orders) {
|
const res = await api.delete('/orders');
|
||||||
try {
|
dispatch(setOrders([]));
|
||||||
await api.delete(`/orders/${order._id}`);
|
toast.success(`Deleted ${res.data.deleted} orders. Re-upload your packing slips to restore.`);
|
||||||
deleted++;
|
} catch {
|
||||||
} 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 (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
@ -618,68 +563,16 @@ export default function DataImport() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Testing Helper */}
|
<button
|
||||||
<div className="flex flex-col gap-2">
|
onClick={handleClearAllOrders}
|
||||||
<button
|
className="flex items-center gap-2 px-3 py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 text-sm"
|
||||||
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"
|
<Trash2 className="w-4 h-4" />
|
||||||
>
|
Clear All Orders
|
||||||
<Trash2 className="w-4 h-4" />
|
</button>
|
||||||
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>
|
||||||
</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 */}
|
{/* Import Options Tabs */}
|
||||||
<div className="bg-white rounded-lg shadow-md mb-6">
|
<div className="bg-white rounded-lg shadow-md mb-6">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export interface Product {
|
||||||
sku: string;
|
sku: string;
|
||||||
category: string;
|
category: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
aliases?: string[];
|
||||||
inventory: {
|
inventory: {
|
||||||
quantity: number;
|
quantity: number;
|
||||||
lowStockAlert: 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 => {
|
records.forEach(record => {
|
||||||
if (record.Type === 'Fee' || record.Type === 'GST') {
|
if (record.Type !== 'Fee' && record.Type !== 'GST') return;
|
||||||
// Try to match fees to orders by date proximity and context
|
|
||||||
const feeAmount = Math.abs(this.parseAmount(record.Net));
|
const feeAmount = Math.abs(this.parseAmount(record.Net));
|
||||||
const feeDate = record.Date;
|
if (feeAmount <= 0) return;
|
||||||
|
|
||||||
// Find the closest order by date
|
const refSource = `${record.Title} ${record.Info || ''}`;
|
||||||
let closestOrder: ParsedEtsyOrder | null = null;
|
const refMatch = refSource.match(/Order\s*#(\d+)/i) || refSource.match(/order:?\s*(\d+)/i);
|
||||||
let closestDateDiff = Infinity;
|
let order = refMatch ? orderMap.get(refMatch[1]) : undefined;
|
||||||
|
|
||||||
for (const order of orderMap.values()) {
|
// Unreferenced transaction GST (e.g. "GST: shipping_transaction") belongs
|
||||||
const dateDiff = Math.abs(new Date(order.date).getTime() - new Date(feeDate).getTime());
|
// to an order but carries no reference; only allocate it when exactly one
|
||||||
if (dateDiff < closestDateDiff && dateDiff <= 7 * 24 * 60 * 60 * 1000) { // Within 7 days
|
// order exists on the same date, so it can't land on the wrong order.
|
||||||
closestDateDiff = dateDiff;
|
if (!order && !refMatch && record.Title.toLowerCase().includes('transaction')) {
|
||||||
closestOrder = order;
|
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;
|
if (order) {
|
||||||
closestOrder.netAmount = closestOrder.saleAmount - closestOrder.totalFees;
|
order.totalFees += feeAmount;
|
||||||
|
order.netAmount = order.saleAmount - order.totalFees;
|
||||||
// Check for shipping fees
|
|
||||||
if (record.Title.toLowerCase().includes('shipping')) {
|
if (record.Title.toLowerCase().includes('shipping')) {
|
||||||
closestOrder.shippingFee = feeAmount;
|
order.shippingFee = feeAmount;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -237,6 +241,9 @@ export class CSVImportService {
|
||||||
let reference: string | undefined;
|
let reference: string | undefined;
|
||||||
const description = record.Title;
|
const description = record.Title;
|
||||||
const lowerTitle = description.toLowerCase();
|
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
|
// Categorize different types of Etsy fees
|
||||||
if (record.Type === 'Marketing' || lowerTitle.includes('etsy ads')) {
|
if (record.Type === 'Marketing' || lowerTitle.includes('etsy ads')) {
|
||||||
|
|
@ -244,24 +251,26 @@ export class CSVImportService {
|
||||||
} else if (lowerTitle.includes('listing fee')) {
|
} else if (lowerTitle.includes('listing fee')) {
|
||||||
category = 'Listing Fees';
|
category = 'Listing Fees';
|
||||||
// Extract listing ID if available
|
// 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]}`;
|
if (listingMatch) reference = `listing-${listingMatch[1]}`;
|
||||||
} else if (lowerTitle.includes('transaction fee')) {
|
} else if (lowerTitle.includes('transaction fee')) {
|
||||||
category = 'Transaction Fees';
|
category = 'Transaction Fees';
|
||||||
// Extract order number if available
|
// 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];
|
if (orderMatch) reference = orderMatch[1];
|
||||||
} else if (lowerTitle.includes('processing fee')) {
|
} else if (lowerTitle.includes('processing fee')) {
|
||||||
category = 'Payment Processing Fees';
|
category = 'Payment Processing Fees';
|
||||||
// Extract order number if available
|
// 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];
|
if (orderMatch) reference = orderMatch[1];
|
||||||
} else if (record.Type === 'GST' || lowerTitle.includes('gst')) {
|
} else if (record.Type === 'GST' || lowerTitle.includes('gst')) {
|
||||||
category = 'Taxes & 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')) {
|
} else if (lowerTitle.includes('shipping') || lowerTitle.includes('postage')) {
|
||||||
category = 'Shipping & Postage';
|
category = 'Shipping & Postage';
|
||||||
// Extract label/tracking number if available
|
// 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]}`;
|
if (labelMatch) reference = `label-${labelMatch[1]}`;
|
||||||
} else {
|
} else {
|
||||||
category = 'Other Etsy Fees';
|
category = 'Other Etsy Fees';
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,20 @@ export const calculateOrderProfit = (items: OrderItem[], orderTotal: number = 0)
|
||||||
return revenue - costs;
|
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 = (
|
export const enrichOrderItemsWithCosts = (
|
||||||
items: OrderItem[],
|
items: OrderItem[],
|
||||||
products: Product[]
|
products: Product[]
|
||||||
): OrderItem[] => {
|
): OrderItem[] => {
|
||||||
return items.map(item => {
|
return items.map(item => {
|
||||||
// Try to find matching product by title (could be improved with SKU matching)
|
if (item.printingCost !== undefined || item.costOfGoods !== undefined) {
|
||||||
const matchingProduct = products.find(product =>
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingProduct = products.find(product =>
|
||||||
product.title.toLowerCase().includes(item.title.toLowerCase()) ||
|
product.title.toLowerCase().includes(item.title.toLowerCase()) ||
|
||||||
item.title.toLowerCase().includes(product.title.toLowerCase())
|
item.title.toLowerCase().includes(product.title.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,13 @@ export interface ParsedPackingSlip {
|
||||||
|
|
||||||
export class PDFPackingSlipParser {
|
export class PDFPackingSlipParser {
|
||||||
async parsePackingSlip(file: File): Promise<ParsedPackingSlip> {
|
async parsePackingSlip(file: File): Promise<ParsedPackingSlip> {
|
||||||
console.log('Starting PDF parsing for file:', file.name, 'Size:', file.size, 'Type:', file.type);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
console.log('File read as ArrayBuffer, size:', arrayBuffer.byteLength);
|
|
||||||
|
|
||||||
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||||
const pdf = await loadingTask.promise;
|
const pdf = await loadingTask.promise;
|
||||||
console.log('PDF loaded successfully, pages:', pdf.numPages);
|
|
||||||
|
|
||||||
let fullText = '';
|
let fullText = '';
|
||||||
|
|
||||||
// Extract text from all pages
|
// Extract text from all pages
|
||||||
for (let i = 1; i <= pdf.numPages; i++) {
|
for (let i = 1; i <= pdf.numPages; i++) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -53,21 +48,14 @@ export class PDFPackingSlipParser {
|
||||||
.map((item: any) => item.str)
|
.map((item: any) => item.str)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
fullText += pageText + '\n';
|
fullText += pageText + '\n';
|
||||||
console.log(`Page ${i} text length:`, pageText.length);
|
|
||||||
} catch (pageError) {
|
} catch (pageError) {
|
||||||
console.warn(`Error reading page ${i}:`, pageError);
|
console.warn(`Error reading page ${i}:`, pageError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Total extracted text length:', fullText.length);
|
return this.parsePackingSlipText(fullText);
|
||||||
console.log('First 500 characters:', fullText.substring(0, 500));
|
|
||||||
|
|
||||||
const result = this.parsePackingSlipText(fullText);
|
|
||||||
console.log('Parsing result:', result);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Detailed PDF parsing error:', error);
|
console.error('PDF parsing error:', error);
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
throw new Error(`PDF parsing failed: ${error.message}`);
|
throw new Error(`PDF parsing failed: ${error.message}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -77,8 +65,6 @@ export class PDFPackingSlipParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
private parsePackingSlipText(text: string): ParsedPackingSlip {
|
private parsePackingSlipText(text: string): ParsedPackingSlip {
|
||||||
console.log('Raw PDF text:', text); // For debugging
|
|
||||||
|
|
||||||
// Initialize result
|
// Initialize result
|
||||||
const result: ParsedPackingSlip = {
|
const result: ParsedPackingSlip = {
|
||||||
orderNumber: '',
|
orderNumber: '',
|
||||||
|
|
@ -152,19 +138,15 @@ export class PDFPackingSlipParser {
|
||||||
|
|
||||||
private extractItems(text: string): ParsedItem[] {
|
private extractItems(text: string): ParsedItem[] {
|
||||||
const items: 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"
|
// 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);
|
const itemsSection = text.match(/(\d+)\s+items?\s+(.*?)Item total/is);
|
||||||
if (itemsSection) {
|
if (itemsSection) {
|
||||||
const itemsText = itemsSection[2];
|
const itemsText = itemsSection[2];
|
||||||
console.log('Items section:', itemsText);
|
|
||||||
|
|
||||||
// Look for price patterns first to identify item boundaries
|
// Look for price patterns first to identify item boundaries
|
||||||
const priceMatches = [...itemsText.matchAll(/(\d+)\s+x\s+AU\$(\d+\.?\d*)/g)];
|
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) {
|
if (priceMatches.length > 0) {
|
||||||
// Split the text by price patterns to get item descriptions
|
// Split the text by price patterns to get item descriptions
|
||||||
const textParts = itemsText.split(/\d+\s+x\s+AU\$\d+\.?\d*/);
|
const textParts = itemsText.split(/\d+\s+x\s+AU\$\d+\.?\d*/);
|
||||||
|
|
@ -208,7 +190,6 @@ export class PDFPackingSlipParser {
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
if (title && quantity > 0) {
|
if (title && quantity > 0) {
|
||||||
console.log('Extracted item:', { title, quantity });
|
|
||||||
items.push({ 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
|
// Enhanced fallback: Look for the specific Etsy format in the full text
|
||||||
if (items.length === 0) {
|
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
|
// Pattern to find complete item lines in the original text
|
||||||
|
|
||||||
// Look for the specific format: "Item Name Colour: ... Size: ... 1 x AU$15.00"
|
// 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
|
// Note: We ignore the price from packing slip as it's tracked from Etsy CSV
|
||||||
|
|
||||||
if (title && quantity > 0) {
|
if (title && quantity > 0) {
|
||||||
console.log('Direct pattern found item:', { title, quantity });
|
|
||||||
items.push({ title, quantity });
|
items.push({ title, quantity });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Final extracted items:', items);
|
|
||||||
return 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
|
* Match order items against existing products in the database
|
||||||
* Considers size as a factor but ignores color variations
|
* Considers size as a factor but ignores color variations
|
||||||
|
|
@ -39,9 +58,30 @@ export const matchOrderItemsToProducts = (
|
||||||
const missingProducts: any[] = [];
|
const missingProducts: any[] = [];
|
||||||
|
|
||||||
for (const item of orderItems) {
|
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 bestMatch: any = null;
|
||||||
let bestConfidence = 0;
|
let bestConfidence = 0;
|
||||||
|
|
||||||
// Enhanced size extraction - look for "Large" or "Small" specifically
|
// Enhanced size extraction - look for "Large" or "Small" specifically
|
||||||
const itemTitle = item.title.toLowerCase();
|
const itemTitle = item.title.toLowerCase();
|
||||||
let itemSize = '';
|
let itemSize = '';
|
||||||
|
|
@ -68,11 +108,7 @@ export const matchOrderItemsToProducts = (
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
console.log('🔍 Matching item:', item.title);
|
|
||||||
console.log(' Clean title:', cleanItemTitle);
|
|
||||||
console.log(' Extracted size:', itemSize);
|
|
||||||
|
|
||||||
for (const product of products) {
|
for (const product of products) {
|
||||||
const productTitle = product.title.toLowerCase();
|
const productTitle = product.title.toLowerCase();
|
||||||
let productSize = '';
|
let productSize = '';
|
||||||
|
|
@ -102,7 +138,6 @@ export const matchOrderItemsToProducts = (
|
||||||
let sizeSimilarity = 0;
|
let sizeSimilarity = 0;
|
||||||
if (itemSize && productSize) {
|
if (itemSize && productSize) {
|
||||||
sizeSimilarity = itemSize === productSize ? 1 : 0;
|
sizeSimilarity = itemSize === productSize ? 1 : 0;
|
||||||
console.log(` Size match: ${itemSize} vs ${productSize} = ${sizeSimilarity}`);
|
|
||||||
} else if (!itemSize && !productSize) {
|
} else if (!itemSize && !productSize) {
|
||||||
sizeSimilarity = 0.7; // Both have no size specified
|
sizeSimilarity = 0.7; // Both have no size specified
|
||||||
} else if (itemSize && !productSize) {
|
} else if (itemSize && !productSize) {
|
||||||
|
|
@ -128,13 +163,6 @@ export const matchOrderItemsToProducts = (
|
||||||
confidence = (titleSimilarity * 0.8) + (sizeSimilarity * 0.2);
|
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
|
// Require higher confidence for shelf decor products to avoid wrong matches
|
||||||
const minConfidence = isShelfDecor ? 0.7 : 0.5;
|
const minConfidence = isShelfDecor ? 0.7 : 0.5;
|
||||||
|
|
||||||
|
|
@ -160,7 +188,6 @@ export const matchOrderItemsToProducts = (
|
||||||
},
|
},
|
||||||
confidence: bestConfidence
|
confidence: bestConfidence
|
||||||
});
|
});
|
||||||
console.log(`✅ Matched "${item.title}" to "${bestMatch.title}" (${(bestConfidence * 100).toFixed(1)}%)`);
|
|
||||||
} else {
|
} else {
|
||||||
missingProducts.push({
|
missingProducts.push({
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|
@ -168,7 +195,6 @@ export const matchOrderItemsToProducts = (
|
||||||
...(item.price !== undefined && { price: item.price }),
|
...(item.price !== undefined && { price: item.price }),
|
||||||
size: itemSize
|
size: itemSize
|
||||||
});
|
});
|
||||||
console.log(`❌ No match found for "${item.title}"`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,24 +104,11 @@ export class ProfitAnalysisService {
|
||||||
if (orderProfit > 0) profitableOrderCount++;
|
if (orderProfit > 0) profitableOrderCount++;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate total expenses (excluding only sale-related transaction fees to avoid double-counting)
|
// Order totals are gross sale amounts (fees are NOT deducted from them),
|
||||||
const totalExpenses = expenses ? expenses.reduce((sum, expense) => {
|
// so every expense counts against revenue exactly once
|
||||||
// Only exclude transaction fees that are tied to specific orders (already deducted from order totals)
|
const totalExpenses = expenses
|
||||||
// Include: Listing Fees, Marketing/Ads, GST, and other business expenses
|
? expenses.reduce((sum, expense) => sum + (expense?.amount || 0), 0)
|
||||||
const isSaleTransactionFee = (
|
: 0;
|
||||||
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;
|
|
||||||
|
|
||||||
// Calculate final profit including all expenses
|
// Calculate final profit including all expenses
|
||||||
const totalProfit = totalRevenue - totalPrintingCosts - totalExpenses;
|
const totalProfit = totalRevenue - totalPrintingCosts - totalExpenses;
|
||||||
|
|
@ -234,17 +221,6 @@ export class ProfitAnalysisService {
|
||||||
const expenseDate = new Date(expense.date);
|
const expenseDate = new Date(expense.date);
|
||||||
const monthKey = `${expenseDate.getFullYear()}-${String(expenseDate.getMonth() + 1).padStart(2, '0')}`;
|
const monthKey = `${expenseDate.getFullYear()}-${String(expenseDate.getMonth() + 1).padStart(2, '0')}`;
|
||||||
const monthName = expenseDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
|
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)
|
// Create month entry if it doesn't exist (for expense-only months)
|
||||||
if (!monthlyData.has(monthKey)) {
|
if (!monthlyData.has(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;
|
sku: string;
|
||||||
category: string;
|
category: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
aliases: string[];
|
||||||
images: string[];
|
images: string[];
|
||||||
variants: {
|
variants: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -44,6 +45,9 @@ const ProductSchema: Schema = new Schema({
|
||||||
sku: { type: String, trim: true, default: '' },
|
sku: { type: String, trim: true, default: '' },
|
||||||
category: { type: String, default: 'Other' },
|
category: { type: String, default: 'Other' },
|
||||||
tags: [{ type: String, trim: true }],
|
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 }],
|
images: [{ type: String }],
|
||||||
variants: [{
|
variants: [{
|
||||||
name: { type: String, required: true },
|
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) => {
|
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const order = await Order.findOneAndDelete({ _id: req.params.id, userId: req.userId });
|
const order = await Order.findOneAndDelete({ _id: req.params.id, userId: req.userId });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue