feat: migrate museum sales from NocoDB to Hono ERP API

- Replace NocoDB museum data (Districts/Museums/DailyStats) with ERP API
- Client fetches via server proxy (/api/erp/sales) — no credentials in browser
- Aggregate transaction-level ERP data into daily/museum/channel records
- Replace "district" dimension with "channel" (B2C/HiHala, POS, B2B, etc.)
- Add product-to-museum mapping (46 products → 6 museums)
- NocoDB retained only for PilgrimStats
- Remove old server/index.js (replaced by modular TS in server/src/)
- Update all components, types, and locale files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-26 16:43:34 +03:00
parent a84caaa31e
commit f6b7d4ba8d
10 changed files with 271 additions and 588 deletions
+39 -170
View File
@@ -1,4 +1,5 @@
// Data source: NocoDB only
// Data source: Hono ERP API (via server proxy) for museum sales
// NocoDB: PilgrimStats only
// Offline mode: caches data to localStorage for resilience
import type {
@@ -10,28 +11,23 @@ import type {
CacheResult,
FetchResult,
GroupedData,
DistrictMuseumMap,
UmrahData,
NocoDBDistrict,
NocoDBMuseum,
NocoDBDailyStat,
DataErrorType
} from '../types';
import { DataError } from '../types';
import { fetchWithRetry } from '../utils/fetchHelpers';
import { fetchFromERP } from './erpService';
// NocoDB config (PilgrimStats only)
const NOCODB_URL = import.meta.env.VITE_NOCODB_URL || '';
const NOCODB_TOKEN = import.meta.env.VITE_NOCODB_TOKEN || '';
const NOCODB_BASE_ID = import.meta.env.VITE_NOCODB_BASE_ID || '';
const VAT_RATE = 1.15;
// Table IDs discovered dynamically from NocoDB meta API
let discoveredTables: Record<string, string> | null = null;
async function discoverTableIds(): Promise<Record<string, string>> {
if (discoveredTables) return discoveredTables;
if (!NOCODB_BASE_ID) throw new Error('VITE_NOCODB_BASE_ID not configured');
const res = await fetchWithRetry(
@@ -45,20 +41,14 @@ async function discoverTableIds(): Promise<Record<string, string>> {
tables[t.title] = t.id;
}
const required = ['Districts', 'Museums', 'DailyStats'];
for (const name of required) {
if (!tables[name]) throw new Error(`Required table '${name}' not found in NocoDB base`);
}
discoveredTables = tables;
console.log('Discovered NocoDB tables:', Object.keys(tables).map(k => `${k}=${tables[k]}`).join(', '));
return tables;
}
// Cache keys
const CACHE_KEY = 'hihala_data_cache';
const CACHE_TIMESTAMP_KEY = 'hihala_data_cache_timestamp';
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// Default umrah data (overridden by NocoDB PilgrimStats when available)
export let umrahData: UmrahData = {
@@ -66,7 +56,6 @@ export let umrahData: UmrahData = {
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
};
// Fetch pilgrim stats from NocoDB and update umrahData
export async function fetchPilgrimStats(): Promise<UmrahData> {
try {
const tables = await discoverTableIds();
@@ -78,11 +67,11 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
const res = await fetchWithRetry(url, { headers: { 'xc-token': NOCODB_TOKEN } });
const json = await res.json();
const records = json.list || [];
const data: UmrahData = { 2024: {}, 2025: {} };
for (const r of records) {
const year = r.Year as number;
const qStr = r.Quarter as string; // "Q1", "Q2", etc.
const qStr = r.Quarter as string;
const qNum = parseInt(qStr.replace('Q', ''));
const total = r.TotalPilgrims as number;
if (year && qNum && total) {
@@ -90,8 +79,7 @@ export async function fetchPilgrimStats(): Promise<UmrahData> {
data[year][qNum] = total;
}
}
// Update the global umrahData
umrahData = data;
console.log('PilgrimStats loaded from NocoDB:', data);
return data;
@@ -119,15 +107,15 @@ function loadFromCache(): CacheResult | null {
try {
const cached = localStorage.getItem(CACHE_KEY);
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
if (!cached) return null;
const data: MuseumRecord[] = JSON.parse(cached);
const age = timestamp ? Date.now() - parseInt(timestamp) : Infinity;
const isStale = age > CACHE_MAX_AGE_MS;
console.log(`Loaded ${data.length} rows from cache (age: ${Math.round(age / 1000 / 60)} min, stale: ${isStale})`);
return { data, isStale, timestamp: parseInt(timestamp || '0') };
} catch (err) {
console.warn('Failed to load from cache:', (err as Error).message);
@@ -138,14 +126,14 @@ function loadFromCache(): CacheResult | null {
export function getCacheStatus(): CacheStatus {
const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
const cached = localStorage.getItem(CACHE_KEY);
if (!cached || !timestamp) {
return { available: false, timestamp: null, age: null, rows: 0 };
}
const ts = parseInt(timestamp);
const data: MuseumRecord[] = JSON.parse(cached);
return {
available: true,
timestamp: new Date(ts).toISOString(),
@@ -161,93 +149,6 @@ export function clearCache(): void {
console.log('Cache cleared');
}
// ============================================
// NocoDB Data Fetching
// ============================================
async function fetchNocoDBTable<T>(tableId: string, limit: number = 1000): Promise<T[]> {
let allRecords: T[] = [];
let offset = 0;
while (true) {
const response = await fetchWithRetry(
`${NOCODB_URL}/api/v2/tables/${tableId}/records?limit=${limit}&offset=${offset}`,
{ headers: { 'xc-token': NOCODB_TOKEN } }
);
const json = await response.json();
const records: T[] = json.list || [];
allRecords = allRecords.concat(records);
if (records.length < limit) break;
offset += limit;
}
return allRecords;
}
interface MuseumMapEntry {
code: string;
name: string;
district: string;
}
async function fetchFromNocoDB(): Promise<MuseumRecord[]> {
console.log('Fetching from NocoDB...');
const tables = await discoverTableIds();
// Fetch all three tables in parallel
const [districts, museums, dailyStats] = await Promise.all([
fetchNocoDBTable<NocoDBDistrict>(tables['Districts']),
fetchNocoDBTable<NocoDBMuseum>(tables['Museums']),
fetchNocoDBTable<NocoDBDailyStat>(tables['DailyStats'])
]);
// Build lookup maps
const districtMap: Record<number, string> = {};
districts.forEach(d => { districtMap[d.Id] = d.Name; });
const museumMap: Record<number, MuseumMapEntry> = {};
museums.forEach(m => {
museumMap[m.Id] = {
code: m.Code,
name: m.Name,
district: districtMap[m.DistrictId ?? m['nc_epk____Districts_id'] ?? 0] || 'Unknown'
};
});
// Join data into flat structure
const data: MuseumRecord[] = dailyStats.map(row => {
const museum = museumMap[row.MuseumId ?? row['nc_epk____Museums_id'] ?? 0] || { code: '', name: '', district: '' };
const date = row.Date;
const year = date ? date.substring(0, 4) : '';
const month = date ? parseInt(date.substring(5, 7)) : 0;
const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
// GrossRevenue = including VAT, NetRevenue = excluding VAT
const grossRevenue = row.GrossRevenue || 0;
const netRevenue = row.NetRevenue || (grossRevenue / VAT_RATE);
return {
date: date,
museum_code: museum.code,
museum_name: museum.name,
district: museum.district,
visits: row.Visits,
tickets: row.Tickets,
revenue_gross: grossRevenue,
revenue_net: netRevenue,
revenue_incl_tax: grossRevenue, // Legacy compatibility
year: year,
quarter: quarter
};
}).filter(r => r.date && r.museum_name);
console.log(`Loaded ${data.length} rows from NocoDB`);
return data;
}
// ============================================
// Error Classification
// ============================================
@@ -266,22 +167,12 @@ function classifyError(err: Error): DataErrorType {
// ============================================
export async function fetchData(): Promise<FetchResult> {
// Check if NocoDB is configured
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
const cached = loadFromCache();
if (cached) {
console.warn('NocoDB not configured, using cached data');
return { data: cached.data, fromCache: true, cacheTimestamp: cached.timestamp };
}
throw new DataError('NocoDB not configured', 'config');
}
try {
const data = await fetchFromNocoDB();
const data = await fetchFromERP();
saveToCache(data);
return { data, fromCache: false };
} catch (err) {
console.error('NocoDB fetch failed:', (err as Error).message);
console.error('ERP fetch failed:', (err as Error).message);
const cached = loadFromCache();
if (cached) {
@@ -294,13 +185,8 @@ export async function fetchData(): Promise<FetchResult> {
}
}
// Force refresh (bypass cache read, but still write to cache)
export async function refreshData(): Promise<FetchResult> {
if (!NOCODB_URL || !NOCODB_TOKEN || !NOCODB_BASE_ID) {
throw new Error('NocoDB not configured');
}
const data = await fetchFromNocoDB();
const data = await fetchFromERP();
saveToCache(data);
return { data, fromCache: false };
}
@@ -312,7 +198,7 @@ export async function refreshData(): Promise<FetchResult> {
export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord[] {
return data.filter(row => {
if (filters.year && filters.year !== 'all' && row.year !== filters.year) return false;
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
if (filters.channel && filters.channel !== 'all' && row.channel !== filters.channel) return false;
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
if (filters.quarter && filters.quarter !== 'all' && row.quarter !== filters.quarter) return false;
return true;
@@ -320,15 +206,15 @@ export function filterData(data: MuseumRecord[], filters: Filters): MuseumRecord
}
export function filterDataByDateRange(
data: MuseumRecord[],
startDate: string,
endDate: string,
data: MuseumRecord[],
startDate: string,
endDate: string,
filters: Partial<DateRangeFilters> = {}
): MuseumRecord[] {
return data.filter(row => {
if (!row.date) return false;
if (row.date < startDate || row.date > endDate) return false;
if (filters.district && filters.district !== 'all' && row.district !== filters.district) return false;
if (filters.channel && filters.channel !== 'all' && row.channel !== filters.channel) return false;
if (filters.museum && filters.museum !== 'all' && row.museum_name !== filters.museum) return false;
return true;
});
@@ -336,7 +222,7 @@ export function filterDataByDateRange(
export function calculateMetrics(data: MuseumRecord[], includeVAT: boolean = true): Metrics {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const revenue = data.reduce((sum, row) => sum + (row[revenueField] || row.revenue_incl_tax || 0), 0);
const revenue = data.reduce((sum, row) => sum + (row[revenueField] || 0), 0);
const visitors = data.reduce((sum, row) => sum + (row.visits || 0), 0);
const tickets = data.reduce((sum, row) => sum + (row.tickets || 0), 0);
const avgRevPerVisitor = visitors > 0 ? revenue / visitors : 0;
@@ -383,17 +269,17 @@ export function formatCompactCurrency(num: number): string {
export function getWeekStart(dateStr: string): string | null {
if (!dateStr || !dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) return null;
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
const dayOfWeek = date.getDay();
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(year, month - 1, day + diff);
const y = monday.getFullYear();
const m = String(monday.getMonth() + 1).padStart(2, '0');
const d = String(monday.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
@@ -405,7 +291,7 @@ export function groupByWeek(data: MuseumRecord[], includeVAT: boolean = true): R
const weekStart = getWeekStart(row.date);
if (!weekStart) return;
if (!grouped[weekStart]) grouped[weekStart] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[weekStart].revenue += row[revenueField] || row.revenue_incl_tax || 0;
grouped[weekStart].revenue += row[revenueField] || 0;
grouped[weekStart].visitors += row.visits || 0;
grouped[weekStart].tickets += row.tickets || 0;
});
@@ -418,22 +304,22 @@ export function groupByMuseum(data: MuseumRecord[], includeVAT: boolean = true):
data.forEach(row => {
if (!row.museum_name) return;
if (!grouped[row.museum_name]) grouped[row.museum_name] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[row.museum_name].revenue += row[revenueField] || row.revenue_incl_tax || 0;
grouped[row.museum_name].revenue += row[revenueField] || 0;
grouped[row.museum_name].visitors += row.visits || 0;
grouped[row.museum_name].tickets += row.tickets || 0;
});
return grouped;
}
export function groupByDistrict(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
export function groupByChannel(data: MuseumRecord[], includeVAT: boolean = true): Record<string, GroupedData> {
const revenueField = includeVAT ? 'revenue_gross' : 'revenue_net';
const grouped: Record<string, GroupedData> = {};
data.forEach(row => {
if (!row.district) return;
if (!grouped[row.district]) grouped[row.district] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[row.district].revenue += row[revenueField] || row.revenue_incl_tax || 0;
grouped[row.district].visitors += row.visits || 0;
grouped[row.district].tickets += row.tickets || 0;
if (!row.channel) return;
if (!grouped[row.channel]) grouped[row.channel] = { revenue: 0, visitors: 0, tickets: 0 };
grouped[row.channel].revenue += row[revenueField] || 0;
grouped[row.channel].visitors += row.visits || 0;
grouped[row.channel].tickets += row.tickets || 0;
});
return grouped;
}
@@ -447,29 +333,12 @@ export function getUniqueYears(data: MuseumRecord[]): string[] {
return years.sort((a, b) => parseInt(a) - parseInt(b));
}
export function getUniqueDistricts(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.district).filter(Boolean))].sort();
export function getUniqueChannels(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.channel).filter(Boolean))].sort();
}
export function getDistrictMuseumMap(data: MuseumRecord[]): DistrictMuseumMap {
const map: Record<string, Set<string>> = {};
data.forEach(row => {
if (!row.district || !row.museum_name) return;
if (!map[row.district]) map[row.district] = new Set();
map[row.district].add(row.museum_name);
});
const result: DistrictMuseumMap = {};
Object.keys(map).forEach(d => {
result[d] = [...map[d]].sort();
});
return result;
}
export function getMuseumsForDistrict(districtMuseumMap: DistrictMuseumMap, district: string): string[] {
if (district === 'all') {
return Object.values(districtMuseumMap).flat().sort();
}
return districtMuseumMap[district] || [];
export function getUniqueMuseums(data: MuseumRecord[]): string[] {
return [...new Set(data.map(r => r.museum_name).filter(Boolean))].sort();
}
export function getLatestYear(data: MuseumRecord[]): string {
+107
View File
@@ -0,0 +1,107 @@
import { getMuseumFromProduct, getChannelLabel } from '../config/museumMapping';
import type { ERPSaleRecord, MuseumRecord } from '../types';
function generateMonthBoundaries(startYear: number, startMonth: number): Array<[string, string]> {
const now = new Date();
const endYear = now.getFullYear();
const endMonth = now.getMonth() + 1;
const boundaries: Array<[string, string]> = [];
let y = startYear;
let m = startMonth;
while (y < endYear || (y === endYear && m <= endMonth)) {
const start = `${y}-${String(m).padStart(2, '0')}-01T00:00:00`;
const nextM = m === 12 ? 1 : m + 1;
const nextY = m === 12 ? y + 1 : y;
const end = `${nextY}-${String(nextM).padStart(2, '0')}-01T00:00:00`;
boundaries.push([start, end]);
y = nextY;
m = nextM;
}
return boundaries;
}
async function fetchChunk(startDate: string, endDate: string): Promise<ERPSaleRecord[]> {
const params = new URLSearchParams({ startDate, endDate });
const res = await fetch(`/api/erp/sales?${params}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error || `ERP proxy returned ${res.status}`);
}
return res.json();
}
export function aggregateTransactions(sales: ERPSaleRecord[]): MuseumRecord[] {
const map = new Map<string, { visits: number; tickets: number; revenue_gross: number; revenue_net: number }>();
for (const sale of sales) {
const date = sale.TransactionDate.split(' ')[0];
const channel = getChannelLabel(sale.OperatingAreaName);
for (const product of sale.Products) {
const museum = getMuseumFromProduct(product.ProductDescription);
const key = `${date}|${museum}|${channel}`;
let entry = map.get(key);
if (!entry) {
entry = { visits: 0, tickets: 0, revenue_gross: 0, revenue_net: 0 };
map.set(key, entry);
}
entry.visits += product.PeopleCount;
entry.tickets += product.UnitQuantity;
entry.revenue_gross += product.TotalPrice;
entry.revenue_net += product.TotalPrice - product.TaxAmount;
}
}
const records: MuseumRecord[] = [];
for (const [key, entry] of map) {
const [date, museum_name, channel] = key.split('|');
const year = date.substring(0, 4);
const month = parseInt(date.substring(5, 7));
const quarter = month <= 3 ? '1' : month <= 6 ? '2' : month <= 9 ? '3' : '4';
records.push({
date,
museum_name,
channel,
visits: entry.visits,
tickets: entry.tickets,
revenue_gross: entry.revenue_gross,
revenue_net: entry.revenue_net,
year,
quarter,
});
}
return records;
}
export async function fetchFromERP(): Promise<MuseumRecord[]> {
console.log('Fetching from ERP API via proxy...');
const months = generateMonthBoundaries(2024, 1);
// Fetch all months in parallel (batched in groups of 4 to avoid overwhelming)
const batchSize = 4;
const allSales: ERPSaleRecord[] = [];
for (let i = 0; i < months.length; i += batchSize) {
const batch = months.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(([start, end]) => fetchChunk(start, end))
);
for (const chunk of results) {
allSales.push(...chunk);
}
}
console.log(`Fetched ${allSales.length} transactions, aggregating...`);
const records = aggregateTransactions(allSales);
console.log(`Aggregated into ${records.length} daily records`);
return records;
}