feat: combo ticket 50/50 split + Best of Creation museum
- Combo tickets (matching multiple museums) split revenue/visits evenly - Each museum gets its own row tagged with TicketType=combo, ComboWith - Added Best of Creation (متحف خير الخلق) to museum mapping - Holy Quraan Museum now shows 3.3M total (was 971K without combo share) - ComboMuseums column tracks split factor for auditing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// Definitive mapping of ERP product descriptions to museum names.
|
||||
// Priority order matters — first match wins (handles combo tickets).
|
||||
// Combo tickets matching multiple museums split revenue/visits 50/50.
|
||||
|
||||
const MUSEUM_KEYWORDS: [string, string[]][] = [
|
||||
['Revelation Exhibition', ['Revelation', 'الوحي']],
|
||||
@@ -7,19 +8,35 @@ const MUSEUM_KEYWORDS: [string, string[]][] = [
|
||||
['Holy Quraan Museum', ['Holy Quraan', 'القرآن الكريم']],
|
||||
['Trail To Hira Cave', ['Trail To Hira', 'غار حراء']],
|
||||
['Makkah Greets Us', ['Makkah Greets']],
|
||||
['Best of Creation', ['Best of Creation', 'خير الخلق']],
|
||||
['VIP Experience', ['VIP Experience']],
|
||||
];
|
||||
|
||||
export const MUSEUM_NAMES = MUSEUM_KEYWORDS.map(([name]) => name);
|
||||
|
||||
export function getMuseumFromProduct(productDescription: string): string {
|
||||
export interface MuseumMatch {
|
||||
museums: string[];
|
||||
split: number; // 1 = full, 0.5 = split between 2 museums
|
||||
}
|
||||
|
||||
export function getMuseumsFromProduct(productDescription: string): MuseumMatch {
|
||||
const desc = productDescription.trim();
|
||||
const matched: string[] = [];
|
||||
|
||||
for (const [museum, keywords] of MUSEUM_KEYWORDS) {
|
||||
for (const kw of keywords) {
|
||||
if (desc.includes(kw)) return museum;
|
||||
if (desc.includes(kw)) {
|
||||
matched.push(museum);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'Other';
|
||||
|
||||
if (matched.length === 0) return { museums: ['Other'], split: 1 };
|
||||
if (matched.length === 1) return { museums: matched, split: 1 };
|
||||
|
||||
// Multiple museums matched — split evenly
|
||||
return { museums: matched, split: 1 / matched.length };
|
||||
}
|
||||
|
||||
export const CHANNEL_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchSales } from './erpClient';
|
||||
import { discoverTableIds, deleteRowsByMonth, deleteAllRows, insertRecords } from './nocodbClient';
|
||||
import { getMuseumFromProduct, getChannelLabel } from '../config/museumMapping';
|
||||
import { getMuseumsFromProduct, getChannelLabel } from '../config/museumMapping';
|
||||
import type { ERPSaleRecord, AggregatedRecord } from '../types';
|
||||
|
||||
function generateMonthBoundaries(startYear: number, startMonth: number): Array<[string, string]> {
|
||||
@@ -43,19 +43,38 @@ export function aggregateTransactions(sales: ERPSaleRecord[]): AggregatedRecord[
|
||||
const channel = getChannelLabel(sale.OperatingAreaName);
|
||||
|
||||
for (const product of sale.Products) {
|
||||
const museum = getMuseumFromProduct(product.ProductDescription);
|
||||
const key = `${date}|${museum}|${channel}`;
|
||||
const { museums, split } = getMuseumsFromProduct(product.ProductDescription);
|
||||
const isCombo = museums.length > 1;
|
||||
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { Date: date, MuseumName: museum, Channel: channel, Visits: 0, Tickets: 0, GrossRevenue: 0, NetRevenue: 0 };
|
||||
map.set(key, entry);
|
||||
for (const museum of museums) {
|
||||
const comboWith = isCombo
|
||||
? museums.filter(m => m !== museum).join(', ')
|
||||
: '';
|
||||
const ticketType = isCombo ? 'combo' : 'single';
|
||||
const key = `${date}|${museum}|${channel}|${ticketType}`;
|
||||
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
Date: date,
|
||||
MuseumName: museum,
|
||||
Channel: channel,
|
||||
TicketType: ticketType,
|
||||
ComboMuseums: museums.length,
|
||||
ComboWith: comboWith,
|
||||
Visits: 0,
|
||||
Tickets: 0,
|
||||
GrossRevenue: 0,
|
||||
NetRevenue: 0,
|
||||
};
|
||||
map.set(key, entry);
|
||||
}
|
||||
|
||||
entry.Visits += product.PeopleCount * split;
|
||||
entry.Tickets += product.UnitQuantity * split;
|
||||
entry.GrossRevenue += product.TotalPrice * split;
|
||||
entry.NetRevenue += (product.TotalPrice - product.TaxAmount) * split;
|
||||
}
|
||||
|
||||
entry.Visits += product.PeopleCount;
|
||||
entry.Tickets += product.UnitQuantity;
|
||||
entry.GrossRevenue += product.TotalPrice;
|
||||
entry.NetRevenue += product.TotalPrice - product.TaxAmount;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface AggregatedRecord {
|
||||
Date: string;
|
||||
MuseumName: string;
|
||||
Channel: string;
|
||||
TicketType: 'single' | 'combo';
|
||||
ComboMuseums: number;
|
||||
ComboWith: string;
|
||||
Visits: number;
|
||||
Tickets: number;
|
||||
GrossRevenue: number;
|
||||
|
||||
@@ -79,6 +79,9 @@ export interface NocoDBDailySale {
|
||||
Date: string;
|
||||
MuseumName: string;
|
||||
Channel: string;
|
||||
TicketType: string;
|
||||
ComboMuseums: number;
|
||||
ComboWith: string;
|
||||
Visits: number;
|
||||
Tickets: number;
|
||||
GrossRevenue: number;
|
||||
|
||||
Reference in New Issue
Block a user