Files
eatme/gscript/DailySalesService.js
Michal Pemcak f917ed06a8 Add shift planning, month closure + payroll, and a dark modern redesign
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.
2026-08-16 18:41:11 +02:00

3279 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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č
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
);
}