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.
|
// Definitive mapping of ERP product descriptions to museum names.
|
||||||
// Priority order matters — first match wins (handles combo tickets).
|
// Priority order matters — first match wins (handles combo tickets).
|
||||||
|
// Combo tickets matching multiple museums split revenue/visits 50/50.
|
||||||
|
|
||||||
const MUSEUM_KEYWORDS: [string, string[]][] = [
|
const MUSEUM_KEYWORDS: [string, string[]][] = [
|
||||||
['Revelation Exhibition', ['Revelation', 'الوحي']],
|
['Revelation Exhibition', ['Revelation', 'الوحي']],
|
||||||
@@ -7,19 +8,35 @@ const MUSEUM_KEYWORDS: [string, string[]][] = [
|
|||||||
['Holy Quraan Museum', ['Holy Quraan', 'القرآن الكريم']],
|
['Holy Quraan Museum', ['Holy Quraan', 'القرآن الكريم']],
|
||||||
['Trail To Hira Cave', ['Trail To Hira', 'غار حراء']],
|
['Trail To Hira Cave', ['Trail To Hira', 'غار حراء']],
|
||||||
['Makkah Greets Us', ['Makkah Greets']],
|
['Makkah Greets Us', ['Makkah Greets']],
|
||||||
|
['Best of Creation', ['Best of Creation', 'خير الخلق']],
|
||||||
['VIP Experience', ['VIP Experience']],
|
['VIP Experience', ['VIP Experience']],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MUSEUM_NAMES = MUSEUM_KEYWORDS.map(([name]) => name);
|
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 desc = productDescription.trim();
|
||||||
|
const matched: string[] = [];
|
||||||
|
|
||||||
for (const [museum, keywords] of MUSEUM_KEYWORDS) {
|
for (const [museum, keywords] of MUSEUM_KEYWORDS) {
|
||||||
for (const kw of 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> = {
|
export const CHANNEL_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fetchSales } from './erpClient';
|
import { fetchSales } from './erpClient';
|
||||||
import { discoverTableIds, deleteRowsByMonth, deleteAllRows, insertRecords } from './nocodbClient';
|
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';
|
import type { ERPSaleRecord, AggregatedRecord } from '../types';
|
||||||
|
|
||||||
function generateMonthBoundaries(startYear: number, startMonth: number): Array<[string, string]> {
|
function generateMonthBoundaries(startYear: number, startMonth: number): Array<[string, string]> {
|
||||||
@@ -43,19 +43,38 @@ export function aggregateTransactions(sales: ERPSaleRecord[]): AggregatedRecord[
|
|||||||
const channel = getChannelLabel(sale.OperatingAreaName);
|
const channel = getChannelLabel(sale.OperatingAreaName);
|
||||||
|
|
||||||
for (const product of sale.Products) {
|
for (const product of sale.Products) {
|
||||||
const museum = getMuseumFromProduct(product.ProductDescription);
|
const { museums, split } = getMuseumsFromProduct(product.ProductDescription);
|
||||||
const key = `${date}|${museum}|${channel}`;
|
const isCombo = museums.length > 1;
|
||||||
|
|
||||||
let entry = map.get(key);
|
for (const museum of museums) {
|
||||||
if (!entry) {
|
const comboWith = isCombo
|
||||||
entry = { Date: date, MuseumName: museum, Channel: channel, Visits: 0, Tickets: 0, GrossRevenue: 0, NetRevenue: 0 };
|
? museums.filter(m => m !== museum).join(', ')
|
||||||
map.set(key, entry);
|
: '';
|
||||||
|
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;
|
Date: string;
|
||||||
MuseumName: string;
|
MuseumName: string;
|
||||||
Channel: string;
|
Channel: string;
|
||||||
|
TicketType: 'single' | 'combo';
|
||||||
|
ComboMuseums: number;
|
||||||
|
ComboWith: string;
|
||||||
Visits: number;
|
Visits: number;
|
||||||
Tickets: number;
|
Tickets: number;
|
||||||
GrossRevenue: number;
|
GrossRevenue: number;
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export interface NocoDBDailySale {
|
|||||||
Date: string;
|
Date: string;
|
||||||
MuseumName: string;
|
MuseumName: string;
|
||||||
Channel: string;
|
Channel: string;
|
||||||
|
TicketType: string;
|
||||||
|
ComboMuseums: number;
|
||||||
|
ComboWith: string;
|
||||||
Visits: number;
|
Visits: number;
|
||||||
Tickets: number;
|
Tickets: number;
|
||||||
GrossRevenue: number;
|
GrossRevenue: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user