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.
3606 lines
47 KiB
JavaScript
3606 lines
47 KiB
JavaScript
/* ============================================================
|
||
EATME PORTÁL – WEEKLY PAYROLL SERVICE
|
||
|
||
Týdenní výkaz:
|
||
- období Po–Ne
|
||
- zaměstnanec potvrzuje nejpozději 12 hodin po skončení
|
||
poslední plánované směny v daném týdnu
|
||
- MANAGER / ADMIN doplní spropitné, bonus, ostatní
|
||
- ADMIN označí týden jako PAID
|
||
|
||
Měsíční PAYROLL / MONTH_CLOSURES zůstávají oddělené.
|
||
============================================================ */
|
||
|
||
const WEEKLY_SHEETS = {
|
||
CLOSURES: 'WEEKLY_CLOSURES'
|
||
};
|
||
|
||
|
||
/* ============================================================
|
||
JEDNORÁZOVÝ SETUP
|
||
============================================================ */
|
||
|
||
function setupWeeklyPayroll() {
|
||
|
||
ensureWeeklySheet_(
|
||
WEEKLY_SHEETS.CLOSURES,
|
||
[
|
||
'weekly_id',
|
||
'employee_id',
|
||
'week_start',
|
||
'week_end',
|
||
'last_shift_end',
|
||
'deadline_at',
|
||
'worked_minutes',
|
||
'base_amount',
|
||
'tips_amount',
|
||
'card_tip_amount',
|
||
'bonus_amount',
|
||
'sales_bonus_amount',
|
||
'other_amount',
|
||
'final_amount',
|
||
'employee_confirmed_at',
|
||
'status',
|
||
'paid_at',
|
||
'paid_by',
|
||
'employee_notified_at',
|
||
'reminder_sent_at',
|
||
'overdue_notified_at',
|
||
'updated_at'
|
||
]
|
||
);
|
||
|
||
|
||
ensureWeeklyPayrollColumns_();
|
||
|
||
|
||
createWeeklyDeadlineTrigger_();
|
||
|
||
|
||
Logger.log(
|
||
'Weekly payroll setup OK'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
DOPLNĚNÍ NOVÝCH SLOUPCŮ DO EXISTUJÍCÍHO LISTU
|
||
============================================================ */
|
||
|
||
function ensureWeeklyPayrollColumns_() {
|
||
|
||
const sheet =
|
||
sh_(
|
||
WEEKLY_SHEETS.CLOSURES
|
||
);
|
||
|
||
|
||
const required =
|
||
[
|
||
'employee_notified_at',
|
||
'reminder_sent_at',
|
||
'overdue_notified_at'
|
||
];
|
||
|
||
|
||
const lastColumn =
|
||
Math.max(
|
||
1,
|
||
sheet.getLastColumn()
|
||
);
|
||
|
||
|
||
const headers =
|
||
sheet
|
||
.getRange(
|
||
1,
|
||
1,
|
||
1,
|
||
lastColumn
|
||
)
|
||
.getDisplayValues()[0]
|
||
.map(
|
||
function(value) {
|
||
|
||
return String(
|
||
value
|
||
).trim();
|
||
|
||
}
|
||
);
|
||
|
||
|
||
required.forEach(
|
||
function(header) {
|
||
|
||
if (
|
||
headers.includes(
|
||
header
|
||
)
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const column =
|
||
sheet.getLastColumn() +
|
||
1;
|
||
|
||
|
||
sheet
|
||
.getRange(
|
||
1,
|
||
column
|
||
)
|
||
.setValue(
|
||
header
|
||
);
|
||
|
||
|
||
headers.push(
|
||
header
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
HODINOVÝ TRIGGER PRO OVERDUE STAV
|
||
============================================================ */
|
||
|
||
function createWeeklyDeadlineTrigger_() {
|
||
|
||
const handler =
|
||
'refreshWeeklyDeadlineStatuses';
|
||
|
||
|
||
ScriptApp
|
||
.getProjectTriggers()
|
||
.filter(
|
||
function(trigger) {
|
||
|
||
return (
|
||
trigger.getHandlerFunction() ===
|
||
handler
|
||
);
|
||
|
||
}
|
||
)
|
||
.forEach(
|
||
function(trigger) {
|
||
|
||
ScriptApp.deleteTrigger(
|
||
trigger
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
ScriptApp
|
||
.newTrigger(
|
||
handler
|
||
)
|
||
.timeBased()
|
||
.everyHours(
|
||
1
|
||
)
|
||
.create();
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ZAMĚSTNANEC – AKTUÁLNÍ / ZVOLENÝ TÝDEN
|
||
============================================================ */
|
||
|
||
function getMyWeeklyReport(
|
||
token,
|
||
dateValue
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'EMPLOYEE',
|
||
'MANAGER',
|
||
'ADMIN'
|
||
]
|
||
);
|
||
|
||
|
||
if (
|
||
!user.employee_id
|
||
) {
|
||
|
||
throw new Error(
|
||
'Účet není propojen se zaměstnancem.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const week =
|
||
getWeekRange_(
|
||
dateValue
|
||
);
|
||
|
||
|
||
const data =
|
||
buildWeeklyData_(
|
||
user.employee_id,
|
||
week.start,
|
||
week.end
|
||
);
|
||
|
||
|
||
if (
|
||
!data.has_activity
|
||
) {
|
||
|
||
return {
|
||
|
||
has_activity:false,
|
||
|
||
week_start:
|
||
week.start,
|
||
|
||
week_end:
|
||
week.end,
|
||
|
||
status:
|
||
'NO_ACTIVITY',
|
||
|
||
worked_minutes:
|
||
0,
|
||
|
||
shift_count:
|
||
0,
|
||
|
||
base_amount:
|
||
0,
|
||
|
||
tips_amount:
|
||
0,
|
||
|
||
card_tip_amount:
|
||
0,
|
||
|
||
bonus_amount:
|
||
0,
|
||
|
||
sales_bonus_amount:
|
||
0,
|
||
|
||
other_amount:
|
||
0,
|
||
|
||
final_amount:
|
||
0,
|
||
|
||
last_shift_end:
|
||
'',
|
||
|
||
deadline_at:
|
||
'',
|
||
|
||
employee_confirmed_at:
|
||
'',
|
||
|
||
paid_at:
|
||
'',
|
||
|
||
can_confirm:
|
||
false,
|
||
|
||
overdue:
|
||
false
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
const closure =
|
||
ensureWeeklyClosure_(
|
||
user.employee_id,
|
||
week.start,
|
||
week.end,
|
||
data
|
||
);
|
||
|
||
|
||
const now =
|
||
new Date();
|
||
|
||
|
||
const deadline =
|
||
parseWeeklyDateTime_(
|
||
closure.deadline_at
|
||
);
|
||
|
||
|
||
const lastShiftEnd =
|
||
parseWeeklyDateTime_(
|
||
closure.last_shift_end
|
||
);
|
||
|
||
|
||
const status =
|
||
computeWeeklyStatus_(
|
||
closure,
|
||
now
|
||
);
|
||
|
||
|
||
if (
|
||
status !==
|
||
String(
|
||
closure.status
|
||
)
|
||
) {
|
||
|
||
updateBy_(
|
||
WEEKLY_SHEETS.CLOSURES,
|
||
'weekly_id',
|
||
closure.weekly_id,
|
||
{
|
||
|
||
status:
|
||
status,
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const salesBonusAmount =
|
||
getSalesBonusForEmployeeWeek_(
|
||
user.employee_id,
|
||
week.start,
|
||
week.end
|
||
);
|
||
|
||
|
||
const cardTipAmount =
|
||
getCardTipForEmployeeWeek_(
|
||
user.employee_id,
|
||
week.start,
|
||
week.end
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
has_activity:true,
|
||
|
||
weekly_id:
|
||
String(
|
||
closure.weekly_id ||
|
||
''
|
||
),
|
||
|
||
week_start:
|
||
String(
|
||
closure.week_start ||
|
||
week.start
|
||
),
|
||
|
||
week_end:
|
||
String(
|
||
closure.week_end ||
|
||
week.end
|
||
),
|
||
|
||
worked_minutes:
|
||
Number(
|
||
data.worked_minutes ||
|
||
0
|
||
),
|
||
|
||
shift_count:
|
||
Number(
|
||
data.shift_count ||
|
||
0
|
||
),
|
||
|
||
base_amount:
|
||
Number(
|
||
data.base_amount ||
|
||
0
|
||
),
|
||
|
||
tips_amount:
|
||
weeklyNumber_(
|
||
closure.tips_amount
|
||
),
|
||
|
||
card_tip_amount:
|
||
cardTipAmount,
|
||
|
||
bonus_amount:
|
||
weeklyNumber_(
|
||
closure.bonus_amount
|
||
),
|
||
|
||
sales_bonus_amount:
|
||
salesBonusAmount,
|
||
|
||
other_amount:
|
||
weeklyNumber_(
|
||
closure.other_amount
|
||
),
|
||
|
||
final_amount:
|
||
Number(
|
||
data.base_amount ||
|
||
0
|
||
) +
|
||
weeklyNumber_(
|
||
closure.tips_amount
|
||
) +
|
||
cardTipAmount +
|
||
weeklyNumber_(
|
||
closure.bonus_amount
|
||
) +
|
||
salesBonusAmount +
|
||
weeklyNumber_(
|
||
closure.other_amount
|
||
),
|
||
|
||
last_shift_end:
|
||
String(
|
||
closure.last_shift_end ||
|
||
''
|
||
),
|
||
|
||
deadline_at:
|
||
String(
|
||
closure.deadline_at ||
|
||
''
|
||
),
|
||
|
||
employee_confirmed_at:
|
||
String(
|
||
closure.employee_confirmed_at ||
|
||
''
|
||
),
|
||
|
||
paid_at:
|
||
String(
|
||
closure.paid_at ||
|
||
''
|
||
),
|
||
|
||
status:
|
||
status,
|
||
|
||
can_confirm:
|
||
(
|
||
lastShiftEnd &&
|
||
now >=
|
||
lastShiftEnd &&
|
||
![
|
||
'EMPLOYEE_CONFIRMED',
|
||
'READY_TO_PAY',
|
||
'PAID'
|
||
].includes(
|
||
status
|
||
)
|
||
),
|
||
|
||
overdue:
|
||
(
|
||
status ===
|
||
'OVERDUE'
|
||
),
|
||
|
||
deadline_remaining_minutes:
|
||
deadline
|
||
? Math.round(
|
||
(
|
||
deadline.getTime() -
|
||
now.getTime()
|
||
) /
|
||
60000
|
||
)
|
||
: null
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ZAMĚSTNANEC – POTVRZENÍ TÝDNE
|
||
============================================================ */
|
||
|
||
function confirmMyWeeklyReport(
|
||
token,
|
||
weekStart
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'EMPLOYEE',
|
||
'MANAGER',
|
||
'ADMIN'
|
||
]
|
||
);
|
||
|
||
|
||
if (
|
||
!user.employee_id
|
||
) {
|
||
|
||
throw new Error(
|
||
'Účet není propojen se zaměstnancem.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const week =
|
||
getWeekRange_(
|
||
weekStart
|
||
);
|
||
|
||
|
||
const data =
|
||
buildWeeklyData_(
|
||
user.employee_id,
|
||
week.start,
|
||
week.end
|
||
);
|
||
|
||
|
||
if (
|
||
!data.has_activity
|
||
) {
|
||
|
||
throw new Error(
|
||
'Za tento týden není co potvrdit.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const closure =
|
||
ensureWeeklyClosure_(
|
||
user.employee_id,
|
||
week.start,
|
||
week.end,
|
||
data
|
||
);
|
||
|
||
|
||
if (
|
||
[
|
||
'EMPLOYEE_CONFIRMED',
|
||
'READY_TO_PAY',
|
||
'PAID'
|
||
].includes(
|
||
String(
|
||
closure.status
|
||
)
|
||
)
|
||
) {
|
||
|
||
return {
|
||
ok:true
|
||
};
|
||
|
||
}
|
||
|
||
|
||
const now =
|
||
new Date();
|
||
|
||
|
||
const lastShiftEnd =
|
||
parseWeeklyDateTime_(
|
||
closure.last_shift_end
|
||
);
|
||
|
||
|
||
if (
|
||
!lastShiftEnd ||
|
||
now <
|
||
lastShiftEnd
|
||
) {
|
||
|
||
throw new Error(
|
||
'Týdenní výkaz lze potvrdit až po skončení poslední směny v týdnu.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
updateBy_(
|
||
WEEKLY_SHEETS.CLOSURES,
|
||
'weekly_id',
|
||
closure.weekly_id,
|
||
{
|
||
|
||
worked_minutes:
|
||
data.worked_minutes,
|
||
|
||
base_amount:
|
||
data.base_amount,
|
||
|
||
final_amount:
|
||
data.base_amount +
|
||
weeklyNumber_(
|
||
closure.tips_amount
|
||
) +
|
||
weeklyNumber_(
|
||
closure.bonus_amount
|
||
) +
|
||
weeklyNumber_(
|
||
closure.other_amount
|
||
),
|
||
|
||
employee_confirmed_at:
|
||
now_(),
|
||
|
||
status:
|
||
'EMPLOYEE_CONFIRMED',
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'WEEKLY_REPORT_CONFIRMED',
|
||
'WEEKLY_CLOSURE',
|
||
closure.weekly_id,
|
||
closure.status,
|
||
'EMPLOYEE_CONFIRMED'
|
||
);
|
||
|
||
|
||
notifyManagers_(
|
||
user.employee_id,
|
||
'Týdenní výkaz potvrzen',
|
||
'Zaměstnanec potvrdil týdenní výkaz za období ' +
|
||
week.start +
|
||
' až ' +
|
||
week.end +
|
||
'.'
|
||
);
|
||
|
||
|
||
return {
|
||
ok:true
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
MANAGER / ADMIN – TÝDENNÍ PŘEHLED
|
||
============================================================ */
|
||
|
||
/* ============================================================
|
||
ADMIN / MANAGER – DETAIL DOCHÁZKY PRO TÝDENNÍ VÝPLATU
|
||
|
||
Vrací pouze směny, které vstupují do mzdového základu.
|
||
============================================================ */
|
||
|
||
function getAdminWeeklyAttendanceDetail(
|
||
token,
|
||
employeeId,
|
||
weekStart
|
||
) {
|
||
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
employeeId =
|
||
String(
|
||
employeeId ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
!employeeId
|
||
) {
|
||
|
||
throw new Error(
|
||
'Chybí zaměstnanec.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const week =
|
||
getWeekRange_(
|
||
weekStart
|
||
);
|
||
|
||
|
||
const context =
|
||
buildWeeklyRequestContext_();
|
||
|
||
|
||
const employee =
|
||
context.employees
|
||
.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.employee_id
|
||
) ===
|
||
employeeId
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!employee
|
||
) {
|
||
|
||
throw new Error(
|
||
'Zaměstnanec nebyl nalezen.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const shifts =
|
||
context.shiftsByEmployee[
|
||
employeeId
|
||
] ||
|
||
[];
|
||
|
||
|
||
const rates =
|
||
context.ratesByEmployee[
|
||
employeeId
|
||
] ||
|
||
[];
|
||
|
||
|
||
const dayMap =
|
||
{};
|
||
|
||
|
||
shifts.forEach(
|
||
function(shift) {
|
||
|
||
const workDate =
|
||
normalizeDateOnly_(
|
||
shift.work_date
|
||
);
|
||
|
||
|
||
if (
|
||
!workDate ||
|
||
workDate <
|
||
week.start ||
|
||
workDate >
|
||
week.end
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
/*
|
||
* Musíme používat stejnou logiku jako buildWeeklyDataFromContext_(),
|
||
* jinak by se detail mohl lišit od částky ve výplatě.
|
||
*/
|
||
if (
|
||
String(
|
||
shift.status
|
||
) ===
|
||
'OPEN'
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const minutes =
|
||
weeklyNumber_(
|
||
shift.worked_minutes
|
||
);
|
||
|
||
|
||
const breakMinutes =
|
||
weeklyNumber_(
|
||
shift.break_minutes
|
||
);
|
||
|
||
|
||
const clockIn =
|
||
parseEmployeeDate_(
|
||
shift.clock_in
|
||
);
|
||
|
||
|
||
const clockOut =
|
||
parseEmployeeDate_(
|
||
shift.clock_out
|
||
);
|
||
|
||
|
||
const rate =
|
||
employeeRateAt_(
|
||
rates,
|
||
employeeId,
|
||
clockIn ||
|
||
new Date(
|
||
workDate
|
||
)
|
||
);
|
||
|
||
|
||
const amount =
|
||
(
|
||
minutes /
|
||
60
|
||
) *
|
||
rate;
|
||
|
||
|
||
if (
|
||
!dayMap[
|
||
workDate
|
||
]
|
||
) {
|
||
|
||
dayMap[
|
||
workDate
|
||
] =
|
||
{
|
||
|
||
work_date:
|
||
workDate,
|
||
|
||
first_clock_in:
|
||
null,
|
||
|
||
last_clock_out:
|
||
null,
|
||
|
||
break_minutes:
|
||
0,
|
||
|
||
worked_minutes:
|
||
0,
|
||
|
||
base_amount:
|
||
0,
|
||
|
||
rates:
|
||
[],
|
||
|
||
shift_count:
|
||
0
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
const day =
|
||
dayMap[
|
||
workDate
|
||
];
|
||
|
||
|
||
if (
|
||
clockIn &&
|
||
(
|
||
!day.first_clock_in ||
|
||
clockIn <
|
||
day.first_clock_in
|
||
)
|
||
) {
|
||
|
||
day.first_clock_in =
|
||
clockIn;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
clockOut &&
|
||
(
|
||
!day.last_clock_out ||
|
||
clockOut >
|
||
day.last_clock_out
|
||
)
|
||
) {
|
||
|
||
day.last_clock_out =
|
||
clockOut;
|
||
|
||
}
|
||
|
||
|
||
day.break_minutes +=
|
||
breakMinutes;
|
||
|
||
|
||
day.worked_minutes +=
|
||
minutes;
|
||
|
||
|
||
day.base_amount +=
|
||
amount;
|
||
|
||
|
||
day.rates.push(
|
||
rate
|
||
);
|
||
|
||
|
||
day.shift_count++;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const rows =
|
||
Object.keys(
|
||
dayMap
|
||
)
|
||
.sort()
|
||
.map(
|
||
function(date) {
|
||
|
||
const day =
|
||
dayMap[
|
||
date
|
||
];
|
||
|
||
|
||
const uniqueRates =
|
||
Array.from(
|
||
new Set(
|
||
day.rates.map(
|
||
function(rate) {
|
||
|
||
return Number(
|
||
rate ||
|
||
0
|
||
);
|
||
|
||
}
|
||
)
|
||
)
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
work_date:
|
||
day.work_date,
|
||
|
||
clock_in:
|
||
day.first_clock_in
|
||
? formatWeeklyDateTime_(
|
||
day.first_clock_in
|
||
)
|
||
: '',
|
||
|
||
clock_out:
|
||
day.last_clock_out
|
||
? formatWeeklyDateTime_(
|
||
day.last_clock_out
|
||
)
|
||
: '',
|
||
|
||
break_minutes:
|
||
Math.round(
|
||
day.break_minutes
|
||
),
|
||
|
||
worked_minutes:
|
||
Math.round(
|
||
day.worked_minutes
|
||
),
|
||
|
||
hourly_rate:
|
||
uniqueRates.length ===
|
||
1
|
||
? uniqueRates[0]
|
||
: null,
|
||
|
||
effective_hourly_rate:
|
||
day.worked_minutes >
|
||
0
|
||
? (
|
||
day.base_amount /
|
||
(
|
||
day.worked_minutes /
|
||
60
|
||
)
|
||
)
|
||
: 0,
|
||
|
||
base_amount:
|
||
Math.round(
|
||
day.base_amount
|
||
),
|
||
|
||
shift_count:
|
||
day.shift_count
|
||
|
||
};
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const totalWorkedMinutes =
|
||
rows.reduce(
|
||
function(sum,row) {
|
||
|
||
return (
|
||
sum +
|
||
Number(
|
||
row.worked_minutes ||
|
||
0
|
||
)
|
||
);
|
||
|
||
},
|
||
0
|
||
);
|
||
|
||
|
||
const totalBreakMinutes =
|
||
rows.reduce(
|
||
function(sum,row) {
|
||
|
||
return (
|
||
sum +
|
||
Number(
|
||
row.break_minutes ||
|
||
0
|
||
)
|
||
);
|
||
|
||
},
|
||
0
|
||
);
|
||
|
||
|
||
const totalBaseAmount =
|
||
rows.reduce(
|
||
function(sum,row) {
|
||
|
||
return (
|
||
sum +
|
||
Number(
|
||
row.base_amount ||
|
||
0
|
||
)
|
||
);
|
||
|
||
},
|
||
0
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
employee_id:
|
||
employeeId,
|
||
|
||
employee_name:
|
||
(
|
||
String(
|
||
employee.first_name ||
|
||
''
|
||
) +
|
||
' ' +
|
||
String(
|
||
employee.last_name ||
|
||
''
|
||
)
|
||
).trim(),
|
||
|
||
week_start:
|
||
week.start,
|
||
|
||
week_end:
|
||
week.end,
|
||
|
||
rows:
|
||
rows,
|
||
|
||
totals:
|
||
{
|
||
|
||
worked_minutes:
|
||
totalWorkedMinutes,
|
||
|
||
break_minutes:
|
||
totalBreakMinutes,
|
||
|
||
base_amount:
|
||
totalBaseAmount,
|
||
|
||
day_count:
|
||
rows.length
|
||
|
||
}
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ADMIN / MANAGER – TÝDENNÍ VÝPLATY
|
||
============================================================ */
|
||
|
||
function getAdminWeeklyPayroll(
|
||
token,
|
||
dateValue
|
||
) {
|
||
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
const week =
|
||
getWeekRange_(
|
||
dateValue
|
||
);
|
||
|
||
|
||
/*
|
||
* VŠECHNA DATA NAČTEME JEDNOU.
|
||
* Původní verze znovu četla několik listů pro každého zaměstnance.
|
||
*/
|
||
const context =
|
||
buildWeeklyRequestContext_();
|
||
|
||
|
||
const employees =
|
||
context.employees
|
||
.filter(
|
||
function(employee) {
|
||
|
||
return (
|
||
String(
|
||
employee.active
|
||
).toUpperCase() ===
|
||
'TRUE'
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const result =
|
||
[];
|
||
|
||
|
||
const now =
|
||
new Date();
|
||
|
||
|
||
employees.forEach(
|
||
function(employee) {
|
||
|
||
const data =
|
||
buildWeeklyDataFromContext_(
|
||
employee.employee_id,
|
||
week.start,
|
||
week.end,
|
||
context
|
||
);
|
||
|
||
|
||
if (
|
||
!data.has_activity
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const closure =
|
||
ensureWeeklyClosureWithContext_(
|
||
employee.employee_id,
|
||
week.start,
|
||
week.end,
|
||
data,
|
||
context
|
||
);
|
||
|
||
|
||
const status =
|
||
computeWeeklyStatus_(
|
||
closure,
|
||
now
|
||
);
|
||
|
||
|
||
const salesBonusAmount =
|
||
getSalesBonusForEmployeeWeek_(
|
||
employee.employee_id,
|
||
week.start,
|
||
week.end
|
||
);
|
||
|
||
|
||
const cardTipAmount =
|
||
getCardTipForEmployeeWeek_(
|
||
employee.employee_id,
|
||
week.start,
|
||
week.end
|
||
);
|
||
|
||
|
||
result.push(
|
||
{
|
||
|
||
weekly_id:
|
||
String(
|
||
closure.weekly_id ||
|
||
''
|
||
),
|
||
|
||
employee_id:
|
||
String(
|
||
employee.employee_id ||
|
||
''
|
||
),
|
||
|
||
employee_name:
|
||
(
|
||
String(
|
||
employee.first_name ||
|
||
''
|
||
) +
|
||
' ' +
|
||
String(
|
||
employee.last_name ||
|
||
''
|
||
)
|
||
).trim(),
|
||
|
||
week_start:
|
||
week.start,
|
||
|
||
week_end:
|
||
week.end,
|
||
|
||
worked_minutes:
|
||
Number(
|
||
data.worked_minutes ||
|
||
0
|
||
),
|
||
|
||
shift_count:
|
||
Number(
|
||
data.shift_count ||
|
||
0
|
||
),
|
||
|
||
base_amount:
|
||
Number(
|
||
data.base_amount ||
|
||
0
|
||
),
|
||
|
||
tips_amount:
|
||
weeklyNumber_(
|
||
closure.tips_amount
|
||
),
|
||
|
||
card_tip_amount:
|
||
cardTipAmount,
|
||
|
||
bonus_amount:
|
||
weeklyNumber_(
|
||
closure.bonus_amount
|
||
),
|
||
|
||
sales_bonus_amount:
|
||
salesBonusAmount,
|
||
|
||
other_amount:
|
||
weeklyNumber_(
|
||
closure.other_amount
|
||
),
|
||
|
||
final_amount:
|
||
Number(
|
||
data.base_amount ||
|
||
0
|
||
) +
|
||
weeklyNumber_(
|
||
closure.tips_amount
|
||
) +
|
||
cardTipAmount +
|
||
weeklyNumber_(
|
||
closure.bonus_amount
|
||
) +
|
||
salesBonusAmount +
|
||
weeklyNumber_(
|
||
closure.other_amount
|
||
),
|
||
|
||
last_shift_end:
|
||
String(
|
||
closure.last_shift_end ||
|
||
''
|
||
),
|
||
|
||
deadline_at:
|
||
String(
|
||
closure.deadline_at ||
|
||
''
|
||
),
|
||
|
||
employee_confirmed_at:
|
||
String(
|
||
closure.employee_confirmed_at ||
|
||
''
|
||
),
|
||
|
||
paid_at:
|
||
String(
|
||
closure.paid_at ||
|
||
''
|
||
),
|
||
|
||
status:
|
||
status
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const sortedRows =
|
||
result.sort(
|
||
function(a,b) {
|
||
|
||
return a.employee_name.localeCompare(
|
||
b.employee_name,
|
||
'cs'
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const stats =
|
||
{
|
||
|
||
total:
|
||
sortedRows.length,
|
||
|
||
waiting:
|
||
sortedRows.filter(
|
||
function(row) {
|
||
|
||
return [
|
||
'IN_PROGRESS',
|
||
'WAITING_EMPLOYEE'
|
||
].includes(
|
||
row.status
|
||
);
|
||
|
||
}
|
||
).length,
|
||
|
||
overdue:
|
||
sortedRows.filter(
|
||
function(row) {
|
||
|
||
return row.status ===
|
||
'OVERDUE';
|
||
|
||
}
|
||
).length,
|
||
|
||
confirmed:
|
||
sortedRows.filter(
|
||
function(row) {
|
||
|
||
return [
|
||
'EMPLOYEE_CONFIRMED',
|
||
'READY_TO_PAY'
|
||
].includes(
|
||
row.status
|
||
);
|
||
|
||
}
|
||
).length,
|
||
|
||
paid:
|
||
sortedRows.filter(
|
||
function(row) {
|
||
|
||
return row.status ===
|
||
'PAID';
|
||
|
||
}
|
||
).length,
|
||
|
||
total_amount:
|
||
sortedRows.reduce(
|
||
function(sum,row) {
|
||
|
||
return sum +
|
||
Number(
|
||
row.final_amount ||
|
||
0
|
||
);
|
||
|
||
},
|
||
0
|
||
)
|
||
|
||
};
|
||
|
||
|
||
return {
|
||
|
||
week_start:
|
||
week.start,
|
||
|
||
week_end:
|
||
week.end,
|
||
|
||
stats:
|
||
stats,
|
||
|
||
rows:
|
||
sortedRows
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
MANAGER / ADMIN – ODMĚNY
|
||
============================================================ */
|
||
|
||
function saveWeeklyAdjustments(
|
||
token,
|
||
weeklyId,
|
||
tipsAmount,
|
||
bonusAmount,
|
||
otherAmount
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
const closure =
|
||
findWeeklyClosureById_(
|
||
weeklyId
|
||
);
|
||
|
||
|
||
if (
|
||
!closure
|
||
) {
|
||
|
||
throw new Error(
|
||
'Týdenní výkaz nebyl nalezen.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
String(
|
||
closure.status
|
||
) ===
|
||
'PAID'
|
||
) {
|
||
|
||
throw new Error(
|
||
'Vyplacený týden už nelze měnit.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
![
|
||
'EMPLOYEE_CONFIRMED',
|
||
'READY_TO_PAY'
|
||
].includes(
|
||
String(
|
||
closure.status
|
||
)
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Odměny lze zadat až po potvrzení výkazu zaměstnancem.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
tipsAmount =
|
||
weeklyNumber_(
|
||
tipsAmount
|
||
);
|
||
|
||
|
||
bonusAmount =
|
||
weeklyNumber_(
|
||
bonusAmount
|
||
);
|
||
|
||
|
||
otherAmount =
|
||
weeklyNumber_(
|
||
otherAmount
|
||
);
|
||
|
||
|
||
const baseAmount =
|
||
weeklyNumber_(
|
||
closure.base_amount
|
||
);
|
||
|
||
|
||
const salesBonusAmount =
|
||
getSalesBonusForEmployeeWeek_(
|
||
closure.employee_id,
|
||
closure.week_start,
|
||
closure.week_end
|
||
);
|
||
|
||
|
||
const cardTipAmount =
|
||
getCardTipForEmployeeWeek_(
|
||
closure.employee_id,
|
||
closure.week_start,
|
||
closure.week_end
|
||
);
|
||
|
||
|
||
const finalAmount =
|
||
baseAmount +
|
||
tipsAmount +
|
||
cardTipAmount +
|
||
bonusAmount +
|
||
salesBonusAmount +
|
||
otherAmount;
|
||
|
||
|
||
updateBy_(
|
||
WEEKLY_SHEETS.CLOSURES,
|
||
'weekly_id',
|
||
weeklyId,
|
||
{
|
||
|
||
tips_amount:
|
||
tipsAmount,
|
||
|
||
card_tip_amount:
|
||
cardTipAmount,
|
||
|
||
bonus_amount:
|
||
bonusAmount,
|
||
|
||
sales_bonus_amount:
|
||
salesBonusAmount,
|
||
|
||
other_amount:
|
||
otherAmount,
|
||
|
||
final_amount:
|
||
finalAmount,
|
||
|
||
status:
|
||
'READY_TO_PAY',
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'WEEKLY_ADJUSTMENTS_SAVED',
|
||
'WEEKLY_CLOSURE',
|
||
weeklyId,
|
||
'',
|
||
JSON.stringify({
|
||
|
||
tips_amount:
|
||
tipsAmount,
|
||
|
||
card_tip_amount:
|
||
cardTipAmount,
|
||
|
||
bonus_amount:
|
||
bonusAmount,
|
||
|
||
sales_bonus_amount:
|
||
salesBonusAmount,
|
||
|
||
other_amount:
|
||
otherAmount,
|
||
|
||
final_amount:
|
||
finalAmount
|
||
|
||
})
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
ok:true,
|
||
|
||
final_amount:
|
||
finalAmount
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ADMIN – OZNAČIT VYPLACENO
|
||
============================================================ */
|
||
|
||
function markWeeklyPaid(
|
||
token,
|
||
weeklyId
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN'
|
||
]
|
||
);
|
||
|
||
|
||
const closure =
|
||
findWeeklyClosureById_(
|
||
weeklyId
|
||
);
|
||
|
||
|
||
if (
|
||
!closure
|
||
) {
|
||
|
||
throw new Error(
|
||
'Týdenní výkaz nebyl nalezen.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
![
|
||
'EMPLOYEE_CONFIRMED',
|
||
'READY_TO_PAY'
|
||
].includes(
|
||
String(
|
||
closure.status
|
||
)
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Výkaz zatím není připravený k výplatě.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
updateBy_(
|
||
WEEKLY_SHEETS.CLOSURES,
|
||
'weekly_id',
|
||
weeklyId,
|
||
{
|
||
|
||
status:
|
||
'PAID',
|
||
|
||
paid_at:
|
||
now_(),
|
||
|
||
paid_by:
|
||
user.user_id,
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'WEEKLY_MARKED_PAID',
|
||
'WEEKLY_CLOSURE',
|
||
weeklyId,
|
||
closure.status,
|
||
'PAID'
|
||
);
|
||
|
||
|
||
notifyEmployee_(
|
||
closure.employee_id,
|
||
'Týdenní výplata vyplacena',
|
||
'Výplata za týden ' +
|
||
closure.week_start +
|
||
' až ' +
|
||
closure.week_end +
|
||
' byla označena jako vyplacená.'
|
||
);
|
||
|
||
|
||
return {
|
||
ok:true
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
AUTOMATICKÁ KONTROLA DEADLINŮ
|
||
============================================================ */
|
||
|
||
function refreshWeeklyDeadlineStatuses() {
|
||
|
||
ensureWeeklyPayrollColumns_();
|
||
|
||
|
||
const closures =
|
||
displayRows_(
|
||
WEEKLY_SHEETS.CLOSURES
|
||
);
|
||
|
||
|
||
const now =
|
||
new Date();
|
||
|
||
|
||
closures.forEach(
|
||
function(closure) {
|
||
|
||
const current =
|
||
String(
|
||
closure.status ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
[
|
||
'EMPLOYEE_CONFIRMED',
|
||
'READY_TO_PAY',
|
||
'PAID'
|
||
].includes(
|
||
current
|
||
)
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const lastShiftEnd =
|
||
parseWeeklyDateTime_(
|
||
closure.last_shift_end
|
||
);
|
||
|
||
|
||
const deadline =
|
||
parseWeeklyDateTime_(
|
||
closure.deadline_at
|
||
);
|
||
|
||
|
||
if (
|
||
!lastShiftEnd ||
|
||
!deadline
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const status =
|
||
computeWeeklyStatus_(
|
||
closure,
|
||
now
|
||
);
|
||
|
||
|
||
const update =
|
||
{};
|
||
|
||
|
||
/*
|
||
* 1) Jakmile skončí poslední směna, zaměstnanec dostane
|
||
* upozornění, že má 12 hodin na potvrzení.
|
||
*/
|
||
if (
|
||
now >=
|
||
lastShiftEnd &&
|
||
!closure.employee_notified_at
|
||
) {
|
||
|
||
notifyEmployee_(
|
||
closure.employee_id,
|
||
'Potvrď týdenní výkaz',
|
||
'Skončila tvoje poslední směna v týdnu. Týdenní výkaz potvrď nejpozději do ' +
|
||
formatWeeklyDateTime_(
|
||
deadline
|
||
) +
|
||
'.'
|
||
);
|
||
|
||
|
||
update.employee_notified_at =
|
||
now_();
|
||
|
||
}
|
||
|
||
|
||
/*
|
||
* 2) Dvě hodiny před deadlinem pošleme jednu připomínku.
|
||
*/
|
||
const reminderAt =
|
||
new Date(
|
||
deadline.getTime() -
|
||
2 *
|
||
60 *
|
||
60 *
|
||
1000
|
||
);
|
||
|
||
|
||
if (
|
||
now >=
|
||
reminderAt &&
|
||
now <
|
||
deadline &&
|
||
!closure.reminder_sent_at
|
||
) {
|
||
|
||
notifyEmployee_(
|
||
closure.employee_id,
|
||
'Týdenní výkaz čeká na potvrzení',
|
||
'Na potvrzení týdenního výkazu zbývají méně než 2 hodiny. Deadline je ' +
|
||
formatWeeklyDateTime_(
|
||
deadline
|
||
) +
|
||
'.'
|
||
);
|
||
|
||
|
||
update.reminder_sent_at =
|
||
now_();
|
||
|
||
}
|
||
|
||
|
||
/*
|
||
* 3) Po překročení deadlinu upozorníme manažery.
|
||
*/
|
||
if (
|
||
now >
|
||
deadline &&
|
||
!closure.overdue_notified_at
|
||
) {
|
||
|
||
notifyManagers_(
|
||
closure.employee_id,
|
||
'Týdenní výkaz po termínu',
|
||
'Zaměstnanec nepotvrdil týdenní výkaz za období ' +
|
||
closure.week_start +
|
||
' až ' +
|
||
closure.week_end +
|
||
' do stanoveného termínu.'
|
||
);
|
||
|
||
|
||
update.overdue_notified_at =
|
||
now_();
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
status !==
|
||
current
|
||
) {
|
||
|
||
update.status =
|
||
status;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
Object.keys(
|
||
update
|
||
).length
|
||
) {
|
||
|
||
update.updated_at =
|
||
now_();
|
||
|
||
|
||
updateBy_(
|
||
WEEKLY_SHEETS.CLOSURES,
|
||
'weekly_id',
|
||
closure.weekly_id,
|
||
update
|
||
);
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
VÝPOČET DAT TÝDNE
|
||
============================================================ */
|
||
|
||
function buildWeeklyData_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd
|
||
) {
|
||
|
||
const context =
|
||
buildWeeklyRequestContext_();
|
||
|
||
|
||
return buildWeeklyDataFromContext_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd,
|
||
context
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
POSLEDNÍ PLÁNOVANÁ SMĚNA TÝDNE
|
||
============================================================ */
|
||
|
||
function getLastPlannedShiftEnd_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd
|
||
) {
|
||
|
||
const context =
|
||
buildWeeklyRequestContext_();
|
||
|
||
|
||
return getLastPlannedShiftEndFromContext_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd,
|
||
context
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ZALOŽENÍ / OBNOVENÍ TÝDENNÍHO ZÁZNAMU
|
||
============================================================ */
|
||
|
||
function ensureWeeklyClosure_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd,
|
||
data
|
||
) {
|
||
|
||
const context =
|
||
buildWeeklyRequestContext_();
|
||
|
||
|
||
return ensureWeeklyClosureWithContext_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd,
|
||
data,
|
||
context
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
REQUEST CONTEXT – JEDNO ČTENÍ TABULEK NA REQUEST
|
||
============================================================ */
|
||
|
||
function buildWeeklyRequestContext_() {
|
||
|
||
const employees =
|
||
displayRows_(
|
||
CFG.SHEETS.EMPLOYEES
|
||
);
|
||
|
||
|
||
const shifts =
|
||
displayRows_(
|
||
CFG.SHEETS.SHIFTS
|
||
);
|
||
|
||
|
||
const rates =
|
||
displayRows_(
|
||
CFG.SHEETS.PAY_RATES
|
||
);
|
||
|
||
|
||
const slots =
|
||
displayRows_(
|
||
'SHIFT_SLOTS'
|
||
);
|
||
|
||
|
||
const signups =
|
||
displayRows_(
|
||
'SHIFT_SIGNUPS'
|
||
);
|
||
|
||
|
||
const closures =
|
||
displayRows_(
|
||
WEEKLY_SHEETS.CLOSURES
|
||
);
|
||
|
||
|
||
const shiftsByEmployee =
|
||
{};
|
||
|
||
|
||
shifts.forEach(
|
||
function(shift) {
|
||
|
||
const employeeId =
|
||
String(
|
||
shift.employee_id ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
!employeeId
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
!shiftsByEmployee[
|
||
employeeId
|
||
]
|
||
) {
|
||
|
||
shiftsByEmployee[
|
||
employeeId
|
||
] =
|
||
[];
|
||
|
||
}
|
||
|
||
|
||
shiftsByEmployee[
|
||
employeeId
|
||
].push(
|
||
shift
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const ratesByEmployee =
|
||
{};
|
||
|
||
|
||
rates.forEach(
|
||
function(rate) {
|
||
|
||
const employeeId =
|
||
String(
|
||
rate.employee_id ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
!employeeId
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
!ratesByEmployee[
|
||
employeeId
|
||
]
|
||
) {
|
||
|
||
ratesByEmployee[
|
||
employeeId
|
||
] =
|
||
[];
|
||
|
||
}
|
||
|
||
|
||
ratesByEmployee[
|
||
employeeId
|
||
].push(
|
||
rate
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const slotMap =
|
||
{};
|
||
|
||
|
||
slots.forEach(
|
||
function(slot) {
|
||
|
||
slotMap[
|
||
String(
|
||
slot.slot_id
|
||
)
|
||
] =
|
||
slot;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const plannedSlotsByEmployee =
|
||
{};
|
||
|
||
|
||
signups.forEach(
|
||
function(signup) {
|
||
|
||
if (
|
||
String(
|
||
signup.status
|
||
) !==
|
||
'APPROVED'
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const employeeId =
|
||
String(
|
||
signup.employee_id ||
|
||
''
|
||
);
|
||
|
||
|
||
const slot =
|
||
slotMap[
|
||
String(
|
||
signup.slot_id
|
||
)
|
||
];
|
||
|
||
|
||
if (
|
||
!employeeId ||
|
||
!slot
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
!plannedSlotsByEmployee[
|
||
employeeId
|
||
]
|
||
) {
|
||
|
||
plannedSlotsByEmployee[
|
||
employeeId
|
||
] =
|
||
[];
|
||
|
||
}
|
||
|
||
|
||
plannedSlotsByEmployee[
|
||
employeeId
|
||
].push(
|
||
slot
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const closuresByKey =
|
||
{};
|
||
|
||
|
||
closures.forEach(
|
||
function(closure) {
|
||
|
||
closuresByKey[
|
||
weeklyClosureKey_(
|
||
closure.employee_id,
|
||
closure.week_start
|
||
)
|
||
] =
|
||
closure;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
employees:
|
||
employees,
|
||
|
||
shiftsByEmployee:
|
||
shiftsByEmployee,
|
||
|
||
ratesByEmployee:
|
||
ratesByEmployee,
|
||
|
||
plannedSlotsByEmployee:
|
||
plannedSlotsByEmployee,
|
||
|
||
closuresByKey:
|
||
closuresByKey
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
VÝPOČET TÝDNE Z PŘEDNAČTENÝCH DAT
|
||
============================================================ */
|
||
|
||
function buildWeeklyDataFromContext_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd,
|
||
context
|
||
) {
|
||
|
||
const employeeKey =
|
||
String(
|
||
employeeId
|
||
);
|
||
|
||
|
||
const shifts =
|
||
context.shiftsByEmployee[
|
||
employeeKey
|
||
] ||
|
||
[];
|
||
|
||
|
||
const rates =
|
||
context.ratesByEmployee[
|
||
employeeKey
|
||
] ||
|
||
[];
|
||
|
||
|
||
let workedMinutes =
|
||
0;
|
||
|
||
|
||
let baseAmount =
|
||
0;
|
||
|
||
|
||
let shiftCount =
|
||
0;
|
||
|
||
|
||
let latestActualEnd =
|
||
null;
|
||
|
||
|
||
shifts.forEach(
|
||
function(shift) {
|
||
|
||
const workDate =
|
||
normalizeDateOnly_(
|
||
shift.work_date
|
||
);
|
||
|
||
|
||
if (
|
||
!workDate ||
|
||
workDate <
|
||
weekStart ||
|
||
workDate >
|
||
weekEnd
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
String(
|
||
shift.status
|
||
) ===
|
||
'OPEN'
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const minutes =
|
||
weeklyNumber_(
|
||
shift.worked_minutes
|
||
);
|
||
|
||
|
||
workedMinutes +=
|
||
minutes;
|
||
|
||
|
||
if (
|
||
minutes >
|
||
0
|
||
) {
|
||
|
||
shiftCount++;
|
||
|
||
}
|
||
|
||
|
||
const clockIn =
|
||
parseEmployeeDate_(
|
||
shift.clock_in
|
||
);
|
||
|
||
|
||
const rate =
|
||
employeeRateAt_(
|
||
rates,
|
||
employeeId,
|
||
clockIn ||
|
||
new Date(
|
||
workDate
|
||
)
|
||
);
|
||
|
||
|
||
baseAmount +=
|
||
(
|
||
minutes /
|
||
60
|
||
) *
|
||
rate;
|
||
|
||
|
||
const clockOut =
|
||
parseEmployeeDate_(
|
||
shift.clock_out
|
||
);
|
||
|
||
|
||
if (
|
||
clockOut &&
|
||
(
|
||
!latestActualEnd ||
|
||
clockOut >
|
||
latestActualEnd
|
||
)
|
||
) {
|
||
|
||
latestActualEnd =
|
||
clockOut;
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const plannedEnd =
|
||
getLastPlannedShiftEndFromContext_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd,
|
||
context
|
||
);
|
||
|
||
|
||
let lastShiftEnd =
|
||
plannedEnd ||
|
||
latestActualEnd;
|
||
|
||
|
||
if (
|
||
plannedEnd &&
|
||
latestActualEnd &&
|
||
latestActualEnd >
|
||
plannedEnd
|
||
) {
|
||
|
||
lastShiftEnd =
|
||
latestActualEnd;
|
||
|
||
}
|
||
|
||
|
||
const deadline =
|
||
lastShiftEnd
|
||
? new Date(
|
||
lastShiftEnd.getTime() +
|
||
12 *
|
||
60 *
|
||
60 *
|
||
1000
|
||
)
|
||
: null;
|
||
|
||
|
||
return {
|
||
|
||
has_activity:
|
||
(
|
||
shiftCount >
|
||
0 ||
|
||
!!plannedEnd
|
||
),
|
||
|
||
worked_minutes:
|
||
Math.round(
|
||
workedMinutes
|
||
),
|
||
|
||
shift_count:
|
||
shiftCount,
|
||
|
||
base_amount:
|
||
Math.round(
|
||
baseAmount
|
||
),
|
||
|
||
last_shift_end:
|
||
lastShiftEnd
|
||
? formatWeeklyDateTime_(
|
||
lastShiftEnd
|
||
)
|
||
: '',
|
||
|
||
deadline_at:
|
||
deadline
|
||
? formatWeeklyDateTime_(
|
||
deadline
|
||
)
|
||
: ''
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
POSLEDNÍ PLÁNOVANÁ SMĚNA Z PŘEDNAČTENÝCH DAT
|
||
============================================================ */
|
||
|
||
function getLastPlannedShiftEndFromContext_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd,
|
||
context
|
||
) {
|
||
|
||
const slots =
|
||
context.plannedSlotsByEmployee[
|
||
String(
|
||
employeeId
|
||
)
|
||
] ||
|
||
[];
|
||
|
||
|
||
let latest =
|
||
null;
|
||
|
||
|
||
slots.forEach(
|
||
function(slot) {
|
||
|
||
const date =
|
||
normalizeDateOnly_(
|
||
slot.date
|
||
);
|
||
|
||
|
||
if (
|
||
!date ||
|
||
date <
|
||
weekStart ||
|
||
date >
|
||
weekEnd
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const end =
|
||
weeklySlotEnd_(
|
||
slot
|
||
);
|
||
|
||
|
||
if (
|
||
end &&
|
||
(
|
||
!latest ||
|
||
end >
|
||
latest
|
||
)
|
||
) {
|
||
|
||
latest =
|
||
end;
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return latest;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
UZÁVĚRKA Z REQUEST CONTEXTU
|
||
|
||
Důležité:
|
||
- tabulku WEEKLY_CLOSURES nečteme znovu pro každého člověka
|
||
- update děláme pouze pokud se skutečně změnila data
|
||
============================================================ */
|
||
|
||
function ensureWeeklyClosureWithContext_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd,
|
||
data,
|
||
context
|
||
) {
|
||
|
||
const key =
|
||
weeklyClosureKey_(
|
||
employeeId,
|
||
weekStart
|
||
);
|
||
|
||
|
||
let closure =
|
||
context.closuresByKey[
|
||
key
|
||
];
|
||
|
||
|
||
if (
|
||
!closure
|
||
) {
|
||
|
||
const weeklyId =
|
||
uuid_(
|
||
'WEEK'
|
||
);
|
||
|
||
|
||
closure =
|
||
{
|
||
|
||
weekly_id:
|
||
weeklyId,
|
||
|
||
employee_id:
|
||
employeeId,
|
||
|
||
week_start:
|
||
weekStart,
|
||
|
||
week_end:
|
||
weekEnd,
|
||
|
||
last_shift_end:
|
||
data.last_shift_end,
|
||
|
||
deadline_at:
|
||
data.deadline_at,
|
||
|
||
worked_minutes:
|
||
data.worked_minutes,
|
||
|
||
base_amount:
|
||
data.base_amount,
|
||
|
||
tips_amount:
|
||
0,
|
||
|
||
bonus_amount:
|
||
0,
|
||
|
||
sales_bonus_amount:
|
||
getSalesBonusForEmployeeWeek_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd
|
||
),
|
||
|
||
other_amount:
|
||
0,
|
||
|
||
final_amount:
|
||
data.base_amount,
|
||
|
||
employee_confirmed_at:
|
||
'',
|
||
|
||
status:
|
||
initialWeeklyStatus_(
|
||
data
|
||
),
|
||
|
||
paid_at:
|
||
'',
|
||
|
||
paid_by:
|
||
'',
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
};
|
||
|
||
|
||
append_(
|
||
WEEKLY_SHEETS.CLOSURES,
|
||
closure
|
||
);
|
||
|
||
|
||
context.closuresByKey[
|
||
key
|
||
] =
|
||
closure;
|
||
|
||
|
||
return closure;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
[
|
||
'EMPLOYEE_CONFIRMED',
|
||
'READY_TO_PAY',
|
||
'PAID'
|
||
].includes(
|
||
String(
|
||
closure.status
|
||
)
|
||
)
|
||
) {
|
||
|
||
return closure;
|
||
|
||
}
|
||
|
||
|
||
const finalAmount =
|
||
data.base_amount +
|
||
weeklyNumber_(
|
||
closure.tips_amount
|
||
) +
|
||
weeklyNumber_(
|
||
closure.bonus_amount
|
||
) +
|
||
getSalesBonusForEmployeeWeek_(
|
||
employeeId,
|
||
weekStart,
|
||
weekEnd
|
||
) +
|
||
weeklyNumber_(
|
||
closure.other_amount
|
||
);
|
||
|
||
|
||
const changed =
|
||
String(
|
||
closure.week_end ||
|
||
''
|
||
) !==
|
||
String(
|
||
weekEnd
|
||
) ||
|
||
|
||
String(
|
||
closure.last_shift_end ||
|
||
''
|
||
) !==
|
||
String(
|
||
data.last_shift_end ||
|
||
''
|
||
) ||
|
||
|
||
String(
|
||
closure.deadline_at ||
|
||
''
|
||
) !==
|
||
String(
|
||
data.deadline_at ||
|
||
''
|
||
) ||
|
||
|
||
Number(
|
||
closure.worked_minutes ||
|
||
0
|
||
) !==
|
||
Number(
|
||
data.worked_minutes ||
|
||
0
|
||
) ||
|
||
|
||
Number(
|
||
closure.base_amount ||
|
||
0
|
||
) !==
|
||
Number(
|
||
data.base_amount ||
|
||
0
|
||
) ||
|
||
|
||
Number(
|
||
closure.final_amount ||
|
||
0
|
||
) !==
|
||
Number(
|
||
finalAmount ||
|
||
0
|
||
);
|
||
|
||
|
||
if (
|
||
changed
|
||
) {
|
||
|
||
const update =
|
||
{
|
||
|
||
week_end:
|
||
weekEnd,
|
||
|
||
last_shift_end:
|
||
data.last_shift_end,
|
||
|
||
deadline_at:
|
||
data.deadline_at,
|
||
|
||
worked_minutes:
|
||
data.worked_minutes,
|
||
|
||
base_amount:
|
||
data.base_amount,
|
||
|
||
final_amount:
|
||
finalAmount,
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
};
|
||
|
||
|
||
updateBy_(
|
||
WEEKLY_SHEETS.CLOSURES,
|
||
'weekly_id',
|
||
closure.weekly_id,
|
||
update
|
||
);
|
||
|
||
|
||
Object.keys(
|
||
update
|
||
).forEach(
|
||
function(field) {
|
||
|
||
closure[
|
||
field
|
||
] =
|
||
update[
|
||
field
|
||
];
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return closure;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
KLÍČ TÝDENNÍ UZÁVĚRKY
|
||
============================================================ */
|
||
|
||
function weeklyClosureKey_(
|
||
employeeId,
|
||
weekStart
|
||
) {
|
||
|
||
return (
|
||
String(
|
||
employeeId ||
|
||
''
|
||
) +
|
||
'|' +
|
||
String(
|
||
weekStart ||
|
||
''
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
STAVY
|
||
============================================================ */
|
||
|
||
function initialWeeklyStatus_(
|
||
data
|
||
) {
|
||
|
||
const now =
|
||
new Date();
|
||
|
||
|
||
const lastShiftEnd =
|
||
parseWeeklyDateTime_(
|
||
data.last_shift_end
|
||
);
|
||
|
||
|
||
const deadline =
|
||
parseWeeklyDateTime_(
|
||
data.deadline_at
|
||
);
|
||
|
||
|
||
if (
|
||
lastShiftEnd &&
|
||
now <
|
||
lastShiftEnd
|
||
) {
|
||
|
||
return 'IN_PROGRESS';
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
deadline &&
|
||
now >
|
||
deadline
|
||
) {
|
||
|
||
return 'OVERDUE';
|
||
|
||
}
|
||
|
||
|
||
return 'WAITING_EMPLOYEE';
|
||
|
||
}
|
||
|
||
|
||
function computeWeeklyStatus_(
|
||
closure,
|
||
now
|
||
) {
|
||
|
||
const current =
|
||
String(
|
||
closure.status ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
[
|
||
'EMPLOYEE_CONFIRMED',
|
||
'READY_TO_PAY',
|
||
'PAID'
|
||
].includes(
|
||
current
|
||
)
|
||
) {
|
||
|
||
return current;
|
||
|
||
}
|
||
|
||
|
||
const lastShiftEnd =
|
||
parseWeeklyDateTime_(
|
||
closure.last_shift_end
|
||
);
|
||
|
||
|
||
const deadline =
|
||
parseWeeklyDateTime_(
|
||
closure.deadline_at
|
||
);
|
||
|
||
|
||
if (
|
||
lastShiftEnd &&
|
||
now <
|
||
lastShiftEnd
|
||
) {
|
||
|
||
return 'IN_PROGRESS';
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
deadline &&
|
||
now >
|
||
deadline
|
||
) {
|
||
|
||
return 'OVERDUE';
|
||
|
||
}
|
||
|
||
|
||
return 'WAITING_EMPLOYEE';
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
TÝDEN PO–NE
|
||
============================================================ */
|
||
|
||
function getWeekRange_(
|
||
dateValue
|
||
) {
|
||
|
||
let date;
|
||
|
||
|
||
if (
|
||
dateValue &&
|
||
/^\d{4}-\d{2}-\d{2}$/.test(
|
||
String(
|
||
dateValue
|
||
)
|
||
)
|
||
) {
|
||
|
||
const parts =
|
||
String(
|
||
dateValue
|
||
).split(
|
||
'-'
|
||
);
|
||
|
||
|
||
date =
|
||
new Date(
|
||
Number(
|
||
parts[0]
|
||
),
|
||
Number(
|
||
parts[1]
|
||
) - 1,
|
||
Number(
|
||
parts[2]
|
||
),
|
||
12,
|
||
0,
|
||
0
|
||
);
|
||
|
||
} else {
|
||
|
||
date =
|
||
new Date();
|
||
|
||
}
|
||
|
||
|
||
const day =
|
||
date.getDay();
|
||
|
||
|
||
const mondayOffset =
|
||
day === 0
|
||
? -6
|
||
: 1 - day;
|
||
|
||
|
||
const monday =
|
||
new Date(
|
||
date
|
||
);
|
||
|
||
|
||
monday.setDate(
|
||
monday.getDate() +
|
||
mondayOffset
|
||
);
|
||
|
||
|
||
const sunday =
|
||
new Date(
|
||
monday
|
||
);
|
||
|
||
|
||
sunday.setDate(
|
||
sunday.getDate() +
|
||
6
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
start:
|
||
Utilities.formatDate(
|
||
monday,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
),
|
||
|
||
end:
|
||
Utilities.formatDate(
|
||
sunday,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
)
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
HELPERS
|
||
============================================================ */
|
||
|
||
function findWeeklyClosureById_(
|
||
weeklyId
|
||
) {
|
||
|
||
return displayRows_(
|
||
WEEKLY_SHEETS.CLOSURES
|
||
)
|
||
.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.weekly_id
|
||
) ===
|
||
String(
|
||
weeklyId
|
||
)
|
||
);
|
||
|
||
}
|
||
) ||
|
||
null;
|
||
|
||
}
|
||
|
||
|
||
function normalizeDateOnly_(
|
||
value
|
||
) {
|
||
|
||
if (
|
||
!value
|
||
) {
|
||
|
||
return '';
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
value instanceof Date
|
||
) {
|
||
|
||
return Utilities.formatDate(
|
||
value,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const text =
|
||
String(
|
||
value
|
||
);
|
||
|
||
|
||
const iso =
|
||
text.match(
|
||
/^(\d{4}-\d{2}-\d{2})/
|
||
);
|
||
|
||
|
||
if (
|
||
iso
|
||
) {
|
||
|
||
return iso[1];
|
||
|
||
}
|
||
|
||
|
||
const date =
|
||
new Date(
|
||
text
|
||
);
|
||
|
||
|
||
if (
|
||
isNaN(
|
||
date.getTime()
|
||
)
|
||
) {
|
||
|
||
return '';
|
||
|
||
}
|
||
|
||
|
||
return Utilities.formatDate(
|
||
date,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function weeklySlotEnd_(
|
||
slot
|
||
) {
|
||
|
||
const date =
|
||
normalizeDateOnly_(
|
||
slot.date
|
||
);
|
||
|
||
|
||
if (
|
||
!date
|
||
) {
|
||
|
||
return null;
|
||
|
||
}
|
||
|
||
|
||
const dateParts =
|
||
date.split(
|
||
'-'
|
||
);
|
||
|
||
|
||
const startParts =
|
||
String(
|
||
slot.start_time ||
|
||
'00:00'
|
||
).split(
|
||
':'
|
||
);
|
||
|
||
|
||
const endParts =
|
||
String(
|
||
slot.end_time ||
|
||
'00:00'
|
||
).split(
|
||
':'
|
||
);
|
||
|
||
|
||
const start =
|
||
new Date(
|
||
Number(
|
||
dateParts[0]
|
||
),
|
||
Number(
|
||
dateParts[1]
|
||
) - 1,
|
||
Number(
|
||
dateParts[2]
|
||
),
|
||
Number(
|
||
startParts[0]
|
||
),
|
||
Number(
|
||
startParts[1] ||
|
||
0
|
||
),
|
||
0
|
||
);
|
||
|
||
|
||
const end =
|
||
new Date(
|
||
Number(
|
||
dateParts[0]
|
||
),
|
||
Number(
|
||
dateParts[1]
|
||
) - 1,
|
||
Number(
|
||
dateParts[2]
|
||
),
|
||
Number(
|
||
endParts[0]
|
||
),
|
||
Number(
|
||
endParts[1] ||
|
||
0
|
||
),
|
||
0
|
||
);
|
||
|
||
|
||
if (
|
||
end <=
|
||
start
|
||
) {
|
||
|
||
end.setDate(
|
||
end.getDate() +
|
||
1
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return end;
|
||
|
||
}
|
||
|
||
|
||
function formatWeeklyDateTime_(
|
||
date
|
||
) {
|
||
|
||
return Utilities.formatDate(
|
||
date,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd HH:mm:ss'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function parseWeeklyDateTime_(
|
||
value
|
||
) {
|
||
|
||
if (
|
||
!value
|
||
) {
|
||
|
||
return null;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
value instanceof Date
|
||
) {
|
||
|
||
return value;
|
||
|
||
}
|
||
|
||
|
||
const text =
|
||
String(
|
||
value
|
||
).trim();
|
||
|
||
|
||
const match =
|
||
text.match(
|
||
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/
|
||
);
|
||
|
||
|
||
if (
|
||
match
|
||
) {
|
||
|
||
return new Date(
|
||
Number(
|
||
match[1]
|
||
),
|
||
Number(
|
||
match[2]
|
||
) - 1,
|
||
Number(
|
||
match[3]
|
||
),
|
||
Number(
|
||
match[4]
|
||
),
|
||
Number(
|
||
match[5]
|
||
),
|
||
Number(
|
||
match[6] ||
|
||
0
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const date =
|
||
new Date(
|
||
text
|
||
);
|
||
|
||
|
||
return isNaN(
|
||
date.getTime()
|
||
)
|
||
? null
|
||
: date;
|
||
|
||
}
|
||
|
||
|
||
function weeklyNumber_(
|
||
value
|
||
) {
|
||
|
||
const number =
|
||
Number(
|
||
String(
|
||
value == null
|
||
? 0
|
||
: value
|
||
)
|
||
.replace(
|
||
',',
|
||
'.'
|
||
)
|
||
);
|
||
|
||
|
||
return isFinite(
|
||
number
|
||
)
|
||
? number
|
||
: 0;
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
VYTVOŘENÍ LISTU
|
||
============================================================ */
|
||
|
||
function ensureWeeklySheet_(
|
||
sheetName,
|
||
headers
|
||
) {
|
||
|
||
const employeeSheet =
|
||
sh_(
|
||
CFG.SHEETS.EMPLOYEES
|
||
);
|
||
|
||
|
||
const spreadsheet =
|
||
employeeSheet
|
||
.getParent();
|
||
|
||
|
||
let sheet =
|
||
spreadsheet
|
||
.getSheetByName(
|
||
sheetName
|
||
);
|
||
|
||
|
||
if (
|
||
!sheet
|
||
) {
|
||
|
||
sheet =
|
||
spreadsheet
|
||
.insertSheet(
|
||
sheetName
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
sheet.getLastRow() ===
|
||
0
|
||
) {
|
||
|
||
sheet
|
||
.getRange(
|
||
1,
|
||
1,
|
||
1,
|
||
headers.length
|
||
)
|
||
.setValues(
|
||
[
|
||
headers
|
||
]
|
||
);
|
||
|
||
|
||
sheet.setFrozenRows(
|
||
1
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return sheet;
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
DIAGNOSTIKA
|
||
============================================================ */
|
||
|
||
function testWeeklyPayroll() {
|
||
|
||
Logger.log(
|
||
'WEEKLY_CLOSURES: ' +
|
||
JSON.stringify(
|
||
displayRows_(
|
||
WEEKLY_SHEETS.CLOSURES
|
||
)
|
||
)
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'WeeklyPayrollService OK'
|
||
);
|
||
|
||
}
|