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:
fahed
2026-03-31 13:53:25 +03:00
parent 1f1e0756d0
commit 4f4559023b
4 changed files with 57 additions and 15 deletions

View File

@@ -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> = {

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;