Ports two modules from the friend's Google Apps Script build (kept as reference in gscript/) onto the TypeScript stack, rewritten cleanly against this app's own data model rather than copied 1:1: - Shift planning: weekly template (Sun-Thu evening, Fri/Sat two slots), lazy idempotent generation per period (no cron needed), employee signup/cancel with collision + capacity checks, admin calendar view with slot editing and manual assignment. - Month closure + payroll: employee confirms the month (blocked while a shift is still open), admin locks and finalizes pay (base hours * rate + tips/bonus/other), reopen to undo a premature lock, mark paid. Pay rates are versioned by date, defaulting the first-ever rate to apply retroactively to the employee's whole history. - A shift left open more than 12h (forgotten clock-out) is auto-closed at clock_in + 12h, checked lazily on read instead of a background job. - Full dark, sharp-edged modern restyle (theme.css replaces tui.css) with an amber accent, keeping every existing class name so no component logic needed to change. Backend test coverage (jest) for all three workflows: shift planning, closure/payroll, and the forgotten-clock-out auto-close.
3279 lines
44 KiB
JavaScript
3279 lines
44 KiB
JavaScript
/* ============================================================
|
||
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
|
||
);
|
||
|
||
}
|