/* ============================================================ EATME PORTÁL – DAILY SALES SERVICE Denní výkaz tržeb + automatický bonus ze směny. Výpočty: hotovost k odevzdání = hotovost na místě + hotovost Bolt + hotovost Foodora celková tržba = hotovost na místě + kartou na místě + celkem Bolt + celkem Wolt + celkem Foodora "Přijato kartou" je kontrolní údaj a do tržby se nepřičítá podruhé. Bonus: Ne–Čt: 10 % z částky nad 5 000 Kč Pá–So: 10 % z částky nad 10 000 Kč Bonus se dělí rovným dílem mezi všechny unikátní zaměstnance přihlášené na plánované směny daného dne. ============================================================ */ const SALES_SHEETS = Object.freeze({ REPORTS: 'DAILY_SALES_REPORTS', BONUSES: 'DAILY_SALES_BONUSES' }); /* ============================================================ SETUP – SPUSŤ JEDNOU ============================================================ */ function setupDailySales() { ensureSalesSheet_( SALES_SHEETS.REPORTS, [ 'report_id', 'work_date', 'location_id', 'cash_on_site', 'card_on_site', 'card_received', 'cash_bolt', 'cash_foodora', 'total_bolt', 'total_wolt', 'total_foodora', 'cash_to_handover', 'total_revenue', 'card_tip_gross', 'card_tip_net', 'bonus_threshold', 'bonus_pool', 'submitted_by', 'submitted_at', 'updated_at' ] ); ensureSalesSheet_( SALES_SHEETS.BONUSES, [ 'allocation_id', 'report_id', 'work_date', 'employee_id', 'bonus_amount', 'card_tip_amount', 'created_at', 'updated_at' ] ); Logger.log( 'Daily sales setup OK' ); } /* ============================================================ ZAMĚSTNANEC – DNEŠNÍ VÝKAZ ============================================================ */ function getMyDailySalesReport( token, dateValue ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } const requestedDate = normalizeSalesDate_( dateValue ); const employeeId = String( user.employee_id ); /* * Nepracujeme slepě s dnešním kalendářním datem. * U směn přes půlnoc je provozní datum vždy datum ZAČÁTKU směny. * * Příklad: * pátek 18:00 -> sobota 02:00 * work_date zůstává pátek. * * Výkaz je zaměstnanci dostupný od začátku směny * až 12 hodin po jejím skončení. */ const resolved = resolveEmployeeSalesBusinessDate_( employeeId, requestedDate ); if ( !resolved ) { return { scheduled:false, work_date:requestedDate }; } const date = resolved.work_date; const context = getSalesPlanningContext_( date ); const scheduledEmployees = context.employee_ids; const isScheduled = scheduledEmployees.includes( employeeId ); if ( !isScheduled ) { return { scheduled:false, work_date:date }; } const locationId = context.location_id || 'LOCATION_MAIN'; const reports = safeSalesRows_( SALES_SHEETS.REPORTS ); const report = reports.find( function(row) { return ( String( row.work_date ) === date && String( row.location_id || '' ) === String( locationId ) ); } ) || null; const values = report ? salesReportValues_( report ) : emptySalesValues_(); const calculation = calculateSalesReport_( date, values, scheduledEmployees.length ); return { scheduled:true, work_date: date, location_id: locationId, report_id: report ? String( report.report_id || '' ) : '', values: values, cash_to_handover: calculation.cash_to_handover, total_revenue: calculation.total_revenue, bonus_threshold: calculation.bonus_threshold, bonus_pool: calculation.bonus_pool, card_tip_gross: calculation.card_tip_gross, card_tip_net: calculation.card_tip_net, employee_count: scheduledEmployees.length, my_bonus: calculation.employee_count > 0 ? roundMoney_( calculation.bonus_pool / calculation.employee_count ) : 0, my_card_tip: calculation.employee_count > 0 ? roundMoney_( calculation.card_tip_net / calculation.employee_count ) : 0, submitted_at: report ? String( report.submitted_at || '' ) : '', employee_ids: scheduledEmployees }; } /* ============================================================ ULOŽENÍ VÝKAZU ============================================================ */ function saveMyDailySalesReport( token, dateValue, payload ) { const user = requireUser_( token, [ 'EMPLOYEE', 'MANAGER', 'ADMIN' ] ); if ( !user.employee_id ) { throw new Error( 'Účet není propojen se zaměstnancem.' ); } const date = normalizeSalesDate_( dateValue ); const context = getSalesPlanningContext_( date ); const employeeId = String( user.employee_id ); if ( !context.employee_ids.includes( employeeId ) ) { throw new Error( 'Na tento den nejsi přihlášený na směnu.' ); } const values = sanitizeSalesPayload_( payload ); const calculation = calculateSalesReport_( date, values, context.employee_ids.length ); const locationId = context.location_id || 'LOCATION_MAIN'; const reports = safeSalesRows_( SALES_SHEETS.REPORTS ); let report = reports.find( function(row) { return ( String( row.work_date ) === date && String( row.location_id || '' ) === String( locationId ) ); } ) || null; const now = now_(); if ( report ) { updateBy_( SALES_SHEETS.REPORTS, 'report_id', report.report_id, { cash_on_site: values.cash_on_site, card_on_site: values.card_on_site, card_received: values.card_received, cash_bolt: values.cash_bolt, cash_foodora: values.cash_foodora, total_bolt: values.total_bolt, total_wolt: values.total_wolt, total_foodora: values.total_foodora, cash_to_handover: calculation.cash_to_handover, total_revenue: calculation.total_revenue, card_tip_gross: calculation.card_tip_gross, card_tip_net: calculation.card_tip_net, bonus_threshold: calculation.bonus_threshold, bonus_pool: calculation.bonus_pool, submitted_by: user.user_id, submitted_at: now, updated_at: now } ); } else { report = { report_id: uuid_( 'SALE' ), work_date: date, location_id: locationId, cash_on_site: values.cash_on_site, card_on_site: values.card_on_site, card_received: values.card_received, cash_bolt: values.cash_bolt, cash_foodora: values.cash_foodora, total_bolt: values.total_bolt, total_wolt: values.total_wolt, total_foodora: values.total_foodora, cash_to_handover: calculation.cash_to_handover, total_revenue: calculation.total_revenue, card_tip_gross: calculation.card_tip_gross, card_tip_net: calculation.card_tip_net, bonus_threshold: calculation.bonus_threshold, bonus_pool: calculation.bonus_pool, submitted_by: user.user_id, submitted_at: now, updated_at: now }; append_( SALES_SHEETS.REPORTS, report ); } replaceSalesBonusAllocations_( report.report_id, date, context.employee_ids, calculation.bonus_pool, calculation.card_tip_net ); audit_( user.user_id, 'DAILY_SALES_SAVED', 'DAILY_SALES', report.report_id, '', JSON.stringify({ date: date, total_revenue: calculation.total_revenue, cash_to_handover: calculation.cash_to_handover, bonus_pool: calculation.bonus_pool, card_tip_net: calculation.card_tip_net, employees: context.employee_ids }) ); return getMyDailySalesReport( token, date ); } /* ============================================================ ADMIN – HISTORIE TRŽEB ============================================================ */ function getAdminDailySalesReports( token, dateFrom, dateTo ) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const range = normalizeSalesRange_( dateFrom, dateTo ); const reports = safeSalesRows_( SALES_SHEETS.REPORTS ) .filter( function(row) { const date = String( row.work_date || '' ).substring( 0, 10 ); return ( date >= range.from && date <= range.to ); } ) .sort( function(a,b) { return String( b.work_date ).localeCompare( String( a.work_date ) ); } ); const rows = reports.map( function(row) { return { report_id: String( row.report_id || '' ), work_date: String( row.work_date || '' ).substring( 0, 10 ), cash_on_site: salesNumber_( row.cash_on_site ), card_on_site: salesNumber_( row.card_on_site ), card_received: salesNumber_( row.card_received ), cash_bolt: salesNumber_( row.cash_bolt ), cash_foodora: salesNumber_( row.cash_foodora ), total_bolt: salesNumber_( row.total_bolt ), total_wolt: salesNumber_( row.total_wolt ), total_foodora: salesNumber_( row.total_foodora ), cash_to_handover: salesNumber_( row.cash_to_handover ), total_revenue: salesNumber_( row.total_revenue ), bonus_pool: salesNumber_( row.bonus_pool ), card_tip_gross: salesNumber_( row.card_tip_gross ), card_tip_net: salesNumber_( row.card_tip_net ), submitted_at: String( row.submitted_at || '' ) }; } ); const stats = { days: rows.length, total_revenue: 0, cash_to_handover: 0, bonus_pool: 0, card_tip_net: 0, card_on_site: 0 }; rows.forEach( function(row) { stats.total_revenue += row.total_revenue; stats.cash_to_handover += row.cash_to_handover; stats.bonus_pool += row.bonus_pool; stats.card_tip_net += row.card_tip_net; stats.card_on_site += row.card_on_site; } ); Object.keys( stats ).forEach( function(key) { if ( typeof stats[ key ] === 'number' ) { stats[ key ] = roundMoney_( stats[ key ] ); } } ); return { date_from: range.from, date_to: range.to, stats: stats, rows: rows }; } /* ============================================================ ADMIN / MANAGER – ULOŽIT TRŽBU ZA LIBOVOLNÝ DEN Admin nemusí být na směně. Do rozdělení bonusu a karetního spropitného vstupují pouze zaměstnanci skutečně přihlášení na plánované směny daného dne. ============================================================ */ function saveAdminDailySalesReport( token, dateValue, payload ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const date = normalizeSalesDate_( dateValue ); const values = sanitizeSalesPayload_( payload ); const context = getSalesPlanningContext_( date ); const calculation = calculateSalesReport_( date, values, context.employee_ids.length ); const locationId = context.location_id || 'LOCATION_MAIN'; const reports = safeSalesRows_( SALES_SHEETS.REPORTS ); let report = reports.find( function(row) { return ( String( row.work_date || '' ).substring( 0, 10 ) === date && String( row.location_id || '' ) === String( locationId ) ); } ) || null; const now = now_(); const reportData = { cash_on_site: values.cash_on_site, card_on_site: values.card_on_site, card_received: values.card_received, cash_bolt: values.cash_bolt, cash_foodora: values.cash_foodora, total_bolt: values.total_bolt, total_wolt: values.total_wolt, total_foodora: values.total_foodora, cash_to_handover: calculation.cash_to_handover, total_revenue: calculation.total_revenue, card_tip_gross: calculation.card_tip_gross, card_tip_net: calculation.card_tip_net, bonus_threshold: calculation.bonus_threshold, bonus_pool: calculation.bonus_pool, submitted_by: user.user_id, submitted_at: now, updated_at: now }; let action = 'DAILY_SALES_ADMIN_CREATED'; let oldValue = ''; if ( report ) { action = 'DAILY_SALES_ADMIN_UPDATED'; oldValue = { total_revenue: salesNumber_( report.total_revenue ), cash_to_handover: salesNumber_( report.cash_to_handover ), card_tip_net: salesNumber_( report.card_tip_net ), bonus_pool: salesNumber_( report.bonus_pool ) }; updateBy_( SALES_SHEETS.REPORTS, 'report_id', report.report_id, reportData ); } else { report = { report_id: uuid_( 'SALE' ), work_date: date, location_id: locationId, cash_on_site: reportData.cash_on_site, card_on_site: reportData.card_on_site, card_received: reportData.card_received, cash_bolt: reportData.cash_bolt, cash_foodora: reportData.cash_foodora, total_bolt: reportData.total_bolt, total_wolt: reportData.total_wolt, total_foodora: reportData.total_foodora, cash_to_handover: reportData.cash_to_handover, total_revenue: reportData.total_revenue, card_tip_gross: reportData.card_tip_gross, card_tip_net: reportData.card_tip_net, bonus_threshold: reportData.bonus_threshold, bonus_pool: reportData.bonus_pool, submitted_by: reportData.submitted_by, submitted_at: reportData.submitted_at, updated_at: reportData.updated_at }; append_( SALES_SHEETS.REPORTS, report ); } replaceSalesBonusAllocations_( report.report_id, date, context.employee_ids, calculation.bonus_pool, calculation.card_tip_net ); audit_( user.user_id, action, 'DAILY_SALES', report.report_id, oldValue, { work_date: date, total_revenue: calculation.total_revenue, cash_to_handover: calculation.cash_to_handover, card_tip_net: calculation.card_tip_net, bonus_pool: calculation.bonus_pool, scheduled_employee_ids: context.employee_ids } ); return { ok: true, report_id: report.report_id, work_date: date, employee_count: context.employee_ids.length, employee_ids: context.employee_ids, total_revenue: calculation.total_revenue, cash_to_handover: calculation.cash_to_handover, card_tip_net: calculation.card_tip_net, bonus_pool: calculation.bonus_pool }; } /* ============================================================ ADMIN – DETAIL JEDNOHO VÝKAZU TRŽEB ============================================================ */ function getAdminDailySalesReportDetail( token, reportId ) { requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const reports = safeSalesRows_( SALES_SHEETS.REPORTS ); const report = reports.find( function(row) { return ( String( row.report_id || '' ) === String( reportId || '' ) ); } ); if ( !report ) { throw new Error( 'Výkaz tržeb nebyl nalezen.' ); } return { report_id: String( report.report_id || '' ), work_date: String( report.work_date || '' ).substring( 0, 10 ), location_id: String( report.location_id || '' ), values: salesReportValues_( report ), cash_to_handover: salesNumber_( report.cash_to_handover ), total_revenue: salesNumber_( report.total_revenue ), card_tip_gross: salesNumber_( report.card_tip_gross ), card_tip_net: salesNumber_( report.card_tip_net ), bonus_threshold: salesNumber_( report.bonus_threshold ), bonus_pool: salesNumber_( report.bonus_pool ), submitted_at: String( report.submitted_at || '' ), updated_at: String( report.updated_at || '' ) }; } /* ============================================================ ADMIN – ÚPRAVA VÝKAZU TRŽEB Po změně se znovu přepočítá: - hotovost k odevzdání - celková tržba - karetní spropitné po odečtení 21 % - bonus z tržeb - rozdělení dýšek a bonusů mezi zaměstnance ============================================================ */ function updateAdminDailySalesReport( token, reportId, payload ) { const user = requireUser_( token, [ 'ADMIN', 'MANAGER' ] ); const reports = safeSalesRows_( SALES_SHEETS.REPORTS ); const report = reports.find( function(row) { return ( String( row.report_id || '' ) === String( reportId || '' ) ); } ); if ( !report ) { throw new Error( 'Výkaz tržeb nebyl nalezen.' ); } const date = String( report.work_date || '' ).substring( 0, 10 ); const values = sanitizeSalesPayload_( payload ); const context = getSalesPlanningContext_( date ); const calculation = calculateSalesReport_( date, values, context.employee_ids.length ); const oldValue = { cash_on_site: salesNumber_( report.cash_on_site ), card_on_site: salesNumber_( report.card_on_site ), card_received: salesNumber_( report.card_received ), cash_bolt: salesNumber_( report.cash_bolt ), cash_foodora: salesNumber_( report.cash_foodora ), total_bolt: salesNumber_( report.total_bolt ), total_wolt: salesNumber_( report.total_wolt ), total_foodora: salesNumber_( report.total_foodora ), total_revenue: salesNumber_( report.total_revenue ), card_tip_net: salesNumber_( report.card_tip_net ), bonus_pool: salesNumber_( report.bonus_pool ) }; updateBy_( SALES_SHEETS.REPORTS, 'report_id', report.report_id, { cash_on_site: values.cash_on_site, card_on_site: values.card_on_site, card_received: values.card_received, cash_bolt: values.cash_bolt, cash_foodora: values.cash_foodora, total_bolt: values.total_bolt, total_wolt: values.total_wolt, total_foodora: values.total_foodora, cash_to_handover: calculation.cash_to_handover, total_revenue: calculation.total_revenue, card_tip_gross: calculation.card_tip_gross, card_tip_net: calculation.card_tip_net, bonus_threshold: calculation.bonus_threshold, bonus_pool: calculation.bonus_pool, updated_at: now_() } ); replaceSalesBonusAllocations_( report.report_id, date, context.employee_ids, calculation.bonus_pool, calculation.card_tip_net ); audit_( user.user_id, 'DAILY_SALES_ADMIN_UPDATED', 'DAILY_SALES', report.report_id, oldValue, { cash_on_site: values.cash_on_site, card_on_site: values.card_on_site, card_received: values.card_received, cash_bolt: values.cash_bolt, cash_foodora: values.cash_foodora, total_bolt: values.total_bolt, total_wolt: values.total_wolt, total_foodora: values.total_foodora, total_revenue: calculation.total_revenue, card_tip_net: calculation.card_tip_net, bonus_pool: calculation.bonus_pool } ); return getAdminDailySalesReportDetail( token, report.report_id ); } /* ============================================================ BONUS PRO TÝDENNÍ MZDU ============================================================ */ function getSalesBonusForEmployeeWeek_( employeeId, weekStart, weekEnd ) { try { const rows = safeSalesRows_( SALES_SHEETS.BONUSES ); return roundMoney_( rows .filter( function(row) { const date = String( row.work_date || '' ); return ( String( row.employee_id ) === String( employeeId ) && date >= String( weekStart ) && date <= String( weekEnd ) ); } ) .reduce( function(sum,row) { return ( sum + salesNumber_( row.bonus_amount ) ); }, 0 ) ); } catch(error) { return 0; } } function getCardTipForEmployeeWeek_( employeeId, weekStart, weekEnd ) { try { const rows = safeSalesRows_( SALES_SHEETS.BONUSES ); return roundMoney_( rows .filter( function(row) { const date = String( row.work_date || '' ); return ( String( row.employee_id ) === String( employeeId ) && date >= String( weekStart ) && date <= String( weekEnd ) ); } ) .reduce( function(sum,row) { return ( sum + salesNumber_( row.card_tip_amount ) ); }, 0 ) ); } catch(error) { return 0; } } /* ============================================================ VÝPOČTY ============================================================ */ function calculateSalesReport_( date, values, employeeCount ) { const cashToHandover = roundMoney_( values.cash_on_site + values.cash_bolt + values.cash_foodora ); const totalRevenue = roundMoney_( values.cash_on_site + values.card_on_site + values.total_bolt + values.total_wolt + values.total_foodora ); const cardTipGross = roundMoney_( Math.max( 0, values.card_received - values.card_on_site ) ); /* * Ze spropitného placeného kartou se odečte 21 %. * Zaměstnancům se tedy rozděluje 79 % rozdílu. */ const cardTipNet = roundMoney_( cardTipGross * 0.79 ); const day = salesDayOfWeek_( date ); const isWeekend = ( day === 5 || day === 6 ); const threshold = isWeekend ? 10000 : 5000; const bonusPool = roundMoney_( Math.max( 0, totalRevenue - threshold ) * 0.10 ); return { cash_to_handover: cashToHandover, total_revenue: totalRevenue, bonus_threshold: threshold, bonus_pool: bonusPool, card_tip_gross: cardTipGross, card_tip_net: cardTipNet, employee_count: Number( employeeCount || 0 ) }; } /* ============================================================ PROVOZNÍ DATUM TRŽBY PRO ZAMĚSTNANCE Hledáme jeho skutečně přihlášenou plánovanou směnu. U směny přes půlnoc patří celý výkaz k datu začátku směny. Dostupnost: - od začátku směny - do 12 hodin po jejím plánovaném konci Pokud je více relevantních směn, vybereme tu nejnovější. ============================================================ */ function resolveEmployeeSalesBusinessDate_( employeeId, requestedDate ) { employeeId = String( employeeId || '' ); if ( !employeeId ) { return null; } const slots = displayRows_( 'SHIFT_SLOTS' ); const signups = displayRows_( 'SHIFT_SIGNUPS' ); const employeeSlotIds = {}; signups.forEach( function(signup) { if ( String( signup.employee_id || '' ) !== employeeId ) { return; } if ( String( signup.status || '' ) !== 'APPROVED' ) { return; } employeeSlotIds[ String( signup.slot_id || '' ) ] = true; } ); const now = new Date(); /* * Pro jistotu pracujeme s requestedDate +/- 1 den, * aby frontend mohl stále posílat dnešní datum. */ const requested = salesDateOnlyToDate_( requestedDate ); const allowedDates = {}; if ( requested ) { [ -1, 0, 1 ].forEach( function(offset) { const date = new Date( requested ); date.setDate( date.getDate() + offset ); allowedDates[ Utilities.formatDate( date, CFG.TZ, 'yyyy-MM-dd' ) ] = true; } ); } const candidates = []; slots.forEach( function(slot) { if ( !employeeSlotIds[ String( slot.slot_id || '' ) ] ) { return; } const workDate = String( slot.date || '' ).substring( 0, 10 ); if ( !workDate ) { return; } if ( requested && !allowedDates[ workDate ] ) { return; } const start = salesSlotDateTime_( workDate, slot.start_time ); let end = salesSlotDateTime_( workDate, slot.end_time ); if ( !start || !end ) { return; } /* * Pokud je konec <= začátek, směna přechází přes půlnoc. */ if ( end.getTime() <= start.getTime() ) { end = new Date( end.getTime() + 24 * 60 * 60 * 1000 ); } const availableFrom = start; const availableUntil = new Date( end.getTime() + 12 * 60 * 60 * 1000 ); if ( now.getTime() < availableFrom.getTime() || now.getTime() > availableUntil.getTime() ) { return; } candidates.push( { work_date: workDate, start_at: start, end_at: end, slot_id: String( slot.slot_id || '' ) } ); } ); if ( !candidates.length ) { return null; } candidates.sort( function(a,b) { return ( b.start_at.getTime() - a.start_at.getTime() ); } ); return candidates[0]; } function salesDateOnlyToDate_( value ) { const match = String( value || '' ).match( /^(\d{4})-(\d{2})-(\d{2})$/ ); if ( !match ) { return null; } return new Date( Number( match[1] ), Number( match[2] ) - 1, Number( match[3] ), 12, 0, 0, 0 ); } function salesSlotDateTime_( workDate, timeValue ) { const dateMatch = String( workDate || '' ).match( /^(\d{4})-(\d{2})-(\d{2})$/ ); const timeMatch = String( timeValue || '' ).match( /(\d{1,2}):(\d{2})/ ); if ( !dateMatch || !timeMatch ) { return null; } return new Date( Number( dateMatch[1] ), Number( dateMatch[2] ) - 1, Number( dateMatch[3] ), Number( timeMatch[1] ), Number( timeMatch[2] ), 0, 0 ); } /* ============================================================ PLÁNOVANÍ LIDÉ PRO DEN ============================================================ */ function getSalesPlanningContext_( date ) { const slots = displayRows_( 'SHIFT_SLOTS' ); const signups = displayRows_( 'SHIFT_SIGNUPS' ); const daySlots = slots.filter( function(slot) { return ( String( slot.date || '' ).substring( 0, 10 ) === date ); } ); const slotMap = {}; let locationId = ''; daySlots.forEach( function(slot) { slotMap[ String( slot.slot_id ) ] = true; if ( !locationId && slot.location_id ) { locationId = String( slot.location_id ); } } ); const employeeMap = {}; signups.forEach( function(signup) { if ( String( signup.status ) !== 'APPROVED' ) { return; } if ( !slotMap[ String( signup.slot_id ) ] ) { return; } const employeeId = String( signup.employee_id || '' ); if ( employeeId ) { employeeMap[ employeeId ] = true; } } ); return { location_id: locationId, employee_ids: Object.keys( employeeMap ) }; } /* ============================================================ ROZDĚLENÍ BONUSU ============================================================ */ function replaceSalesBonusAllocations_( reportId, date, employeeIds, bonusPool, cardTipNet ) { const existing = safeSalesRows_( SALES_SHEETS.BONUSES ) .filter( function(row) { return ( String( row.report_id ) === String( reportId ) ); } ); /* * Existující alokace nulujeme. * Tím zůstává auditovatelná historie v jednom listu a * následně přepíšeme / doplníme správné řádky. */ existing.forEach( function(row) { updateBy_( SALES_SHEETS.BONUSES, 'allocation_id', row.allocation_id, { bonus_amount: 0, card_tip_amount: 0, updated_at: now_() } ); } ); if ( !employeeIds.length ) { return; } const share = roundMoney_( bonusPool / employeeIds.length ); const tipShare = roundMoney_( cardTipNet / employeeIds.length ); employeeIds.forEach( function(employeeId,index) { const old = existing.find( function(row) { return ( String( row.employee_id ) === String( employeeId ) ); } ); /* * Kvůli haléřovému zaokrouhlení dostane poslední člověk * případný rozdíl tak, aby součet přesně seděl. */ const amount = index === employeeIds.length - 1 ? roundMoney_( bonusPool - share * ( employeeIds.length - 1 ) ) : share; const tipAmount = index === employeeIds.length - 1 ? roundMoney_( cardTipNet - tipShare * ( employeeIds.length - 1 ) ) : tipShare; if ( old ) { updateBy_( SALES_SHEETS.BONUSES, 'allocation_id', old.allocation_id, { bonus_amount: amount, card_tip_amount: tipAmount, updated_at: now_() } ); } else { append_( SALES_SHEETS.BONUSES, { allocation_id: uuid_( 'SBON' ), report_id: reportId, work_date: date, employee_id: employeeId, bonus_amount: amount, card_tip_amount: tipAmount, created_at: now_(), updated_at: now_() } ); } } ); } /* ============================================================ HELPERS ============================================================ */ function emptySalesValues_() { return { cash_on_site:0, card_on_site:0, card_received:0, cash_bolt:0, cash_foodora:0, total_bolt:0, total_wolt:0, total_foodora:0 }; } function salesReportValues_( row ) { return { cash_on_site: salesNumber_( row.cash_on_site ), card_on_site: salesNumber_( row.card_on_site ), card_received: salesNumber_( row.card_received ), cash_bolt: salesNumber_( row.cash_bolt ), cash_foodora: salesNumber_( row.cash_foodora ), total_bolt: salesNumber_( row.total_bolt ), total_wolt: salesNumber_( row.total_wolt ), total_foodora: salesNumber_( row.total_foodora ) }; } function sanitizeSalesPayload_( payload ) { payload = payload || {}; return { cash_on_site: salesNumber_( payload.cash_on_site ), card_on_site: salesNumber_( payload.card_on_site ), card_received: salesNumber_( payload.card_received ), cash_bolt: salesNumber_( payload.cash_bolt ), cash_foodora: salesNumber_( payload.cash_foodora ), total_bolt: salesNumber_( payload.total_bolt ), total_wolt: salesNumber_( payload.total_wolt ), total_foodora: salesNumber_( payload.total_foodora ) }; } function salesNumber_( value ) { const number = Number( String( value == null ? 0 : value ) .replace( /\s/g, '' ) .replace( ',', '.' ) ); if ( !isFinite( number ) || number < 0 ) { return 0; } return number; } function roundMoney_( value ) { return Math.round( Number( value || 0 ) * 100 ) / 100; } function salesDayOfWeek_( date ) { const parts = String( date ).split( '-' ); return new Date( Number( parts[0] ), Number( parts[1] ) - 1, Number( parts[2] ) ).getDay(); } function normalizeSalesRange_( dateFrom, dateTo ) { const today = Utilities.formatDate( new Date(), CFG.TZ, 'yyyy-MM-dd' ); const now = new Date(); const monthStart = Utilities.formatDate( new Date( now.getFullYear(), now.getMonth(), 1 ), CFG.TZ, 'yyyy-MM-dd' ); let from = String( dateFrom || '' ).trim(); let to = String( dateTo || '' ).trim(); if ( !/^\d{4}-\d{2}-\d{2}$/.test( from ) ) { from = monthStart; } if ( !/^\d{4}-\d{2}-\d{2}$/.test( to ) ) { to = today; } if ( from > to ) { const swap = from; from = to; to = swap; } return { from: from, to: to }; } function normalizeSalesDate_( dateValue ) { const value = String( dateValue || '' ).trim(); if ( /^\d{4}-\d{2}-\d{2}$/.test( value ) ) { return value; } return Utilities.formatDate( new Date(), CFG.TZ, 'yyyy-MM-dd' ); } function safeSalesRows_( sheetName ) { try { return displayRows_( sheetName ); } catch(error) { return []; } } /* ============================================================ DATABÁZOVÝ SPREADSHEET PRO TRŽBY Nepoužíváme getPlanningSpreadsheet_(). Nejprve využijeme existující sh_(), které už celý portál používá pro přístup k databázi. Díky tomu není potřeba ručně nastavovat SPREADSHEET_ID. ============================================================ */ function getSalesSpreadsheet_() { /* * 1) Nejspolehlivější varianta: * vezmeme parent spreadsheet existujícího databázového listu. */ try { const usersSheet = sh_( CFG.SHEETS.USERS ); if ( usersSheet ) { const spreadsheet = usersSheet.getParent(); if ( spreadsheet ) { return spreadsheet; } } } catch(error) { /* * Pokračujeme další možností. */ } /* * 2) Zkusíme EMPLOYEES. */ try { const employeesSheet = sh_( CFG.SHEETS.EMPLOYEES ); if ( employeesSheet ) { const spreadsheet = employeesSheet.getParent(); if ( spreadsheet ) { return spreadsheet; } } } catch(error) { /* * Pokračujeme další možností. */ } /* * 3) Pokud je Apps Script přímo navázaný na tabulku. */ const active = SpreadsheetApp .getActiveSpreadsheet(); if ( active ) { return active; } /* * 4) Poslední kompatibilní fallback pro standalone projekt. */ const propertyId = PropertiesService .getScriptProperties() .getProperty( 'SPREADSHEET_ID' ); if ( propertyId ) { return SpreadsheetApp .openById( propertyId ); } throw new Error( 'Nepodařilo se otevřít databázový spreadsheet pro tržby.' ); } function ensureSalesSheet_( sheetName, headers ) { const ss = getSalesSpreadsheet_(); let sheet = ss.getSheetByName( sheetName ); if ( !sheet ) { sheet = ss.insertSheet( sheetName ); } const currentColumns = Math.max( sheet.getLastColumn(), 1 ); let existing = sheet .getRange( 1, 1, 1, currentColumns ) .getDisplayValues()[0] .map( function(value) { return String( value ).trim(); } ); if ( existing.length === 1 && !existing[0] ) { existing = []; } headers.forEach( function(header) { if ( existing.includes( header ) ) { return; } const col = existing.length + 1; sheet .getRange( 1, col ) .setValue( header ); existing.push( header ); } ); sheet.setFrozenRows( 1 ); }