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.
1554 lines
21 KiB
JavaScript
1554 lines
21 KiB
JavaScript
/* ============================================================
|
||
EATME PORTÁL – PAYROLL SERVICE
|
||
|
||
Role:
|
||
- MANAGER / ADMIN: spropitné, bonusy, jiné položky před uzamčením
|
||
- ACCOUNTANT / ADMIN: účetní zpracování mzdy
|
||
============================================================ */
|
||
|
||
|
||
/* ============================================================
|
||
MANAGER / ADMIN – NAČTENÍ ODMĚN PRO KONKRÉTNÍ MĚSÍC
|
||
============================================================ */
|
||
|
||
function getPayrollAdjustments(
|
||
token,
|
||
employeeId,
|
||
period
|
||
) {
|
||
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
period =
|
||
String(
|
||
period || ''
|
||
).trim();
|
||
|
||
|
||
if (
|
||
!/^\d{4}-\d{2}$/.test(
|
||
period
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Neplatné období.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const payrolls =
|
||
displayRows_(
|
||
CFG.SHEETS.PAYROLL
|
||
);
|
||
|
||
|
||
const payroll =
|
||
payrolls.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.employee_id
|
||
) ===
|
||
String(
|
||
employeeId
|
||
) &&
|
||
String(
|
||
row.period
|
||
) ===
|
||
period
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const summary =
|
||
getClosureSummary_(
|
||
employeeId,
|
||
period
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
payroll_id:
|
||
payroll
|
||
? String(
|
||
payroll.payroll_id || ''
|
||
)
|
||
: '',
|
||
|
||
employee_id:
|
||
String(
|
||
employeeId || ''
|
||
),
|
||
|
||
period:
|
||
period,
|
||
|
||
base_amount:
|
||
Number(
|
||
summary.earned_estimate ||
|
||
0
|
||
),
|
||
|
||
tips_amount:
|
||
payroll
|
||
? numberFromSheet_(
|
||
payroll.tips_amount
|
||
)
|
||
: 0,
|
||
|
||
bonus_amount:
|
||
payroll
|
||
? numberFromSheet_(
|
||
payroll.bonus_amount
|
||
)
|
||
: 0,
|
||
|
||
other_amount:
|
||
payroll
|
||
? numberFromSheet_(
|
||
payroll.other_amount
|
||
)
|
||
: 0,
|
||
|
||
final_amount:
|
||
payroll
|
||
? numberFromSheet_(
|
||
payroll.final_amount
|
||
)
|
||
: Number(
|
||
summary.earned_estimate ||
|
||
0
|
||
),
|
||
|
||
status:
|
||
payroll
|
||
? String(
|
||
payroll.status || ''
|
||
)
|
||
: 'DRAFT_MANAGER'
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
MANAGER / ADMIN – ULOŽENÍ SPROPITNÉHO A ODMĚN
|
||
============================================================ */
|
||
|
||
function savePayrollAdjustments(
|
||
token,
|
||
employeeId,
|
||
period,
|
||
tipsAmount,
|
||
bonusAmount,
|
||
otherAmount
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
period =
|
||
String(
|
||
period || ''
|
||
).trim();
|
||
|
||
|
||
if (
|
||
!/^\d{4}-\d{2}$/.test(
|
||
period
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Neplatné období.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
tipsAmount =
|
||
payrollNumber_(
|
||
tipsAmount
|
||
);
|
||
|
||
|
||
bonusAmount =
|
||
payrollNumber_(
|
||
bonusAmount
|
||
);
|
||
|
||
|
||
otherAmount =
|
||
payrollNumber_(
|
||
otherAmount
|
||
);
|
||
|
||
|
||
const closures =
|
||
displayRows_(
|
||
CFG.SHEETS.MONTH_CLOSURES
|
||
);
|
||
|
||
|
||
const closure =
|
||
closures.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.employee_id
|
||
) ===
|
||
String(
|
||
employeeId
|
||
) &&
|
||
String(
|
||
row.period
|
||
) ===
|
||
period
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!closure
|
||
) {
|
||
|
||
throw new Error(
|
||
'Měsíční uzávěrka nebyla nalezena.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
String(
|
||
closure.status
|
||
) ===
|
||
'LOCKED'
|
||
) {
|
||
|
||
throw new Error(
|
||
'Uzamčenou mzdu už nelze měnit.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
![
|
||
'EMPLOYEE_CONFIRMED',
|
||
'MANAGER_APPROVED'
|
||
].includes(
|
||
String(
|
||
closure.status
|
||
)
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Odměny lze zadat až po potvrzení docházky zaměstnancem.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const summary =
|
||
getClosureSummary_(
|
||
employeeId,
|
||
period
|
||
);
|
||
|
||
|
||
const baseAmount =
|
||
Number(
|
||
summary.earned_estimate ||
|
||
0
|
||
);
|
||
|
||
|
||
const finalAmount =
|
||
baseAmount +
|
||
tipsAmount +
|
||
bonusAmount +
|
||
otherAmount;
|
||
|
||
|
||
const payrolls =
|
||
displayRows_(
|
||
CFG.SHEETS.PAYROLL
|
||
);
|
||
|
||
|
||
const existing =
|
||
payrolls.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.employee_id
|
||
) ===
|
||
String(
|
||
employeeId
|
||
) &&
|
||
String(
|
||
row.period
|
||
) ===
|
||
period
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
existing &&
|
||
[
|
||
'READY_FOR_ACCOUNTANT',
|
||
'PROCESSING',
|
||
'PROCESSED',
|
||
'PAID'
|
||
].includes(
|
||
String(
|
||
existing.status
|
||
)
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Mzdový podklad už byl předán účetní a nelze jej tímto způsobem měnit.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
existing
|
||
) {
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.PAYROLL,
|
||
'payroll_id',
|
||
existing.payroll_id,
|
||
{
|
||
|
||
approved_minutes:
|
||
summary.worked_minutes,
|
||
|
||
base_amount:
|
||
baseAmount,
|
||
|
||
tips_amount:
|
||
tipsAmount,
|
||
|
||
bonus_amount:
|
||
bonusAmount,
|
||
|
||
other_amount:
|
||
otherAmount,
|
||
|
||
final_amount:
|
||
finalAmount,
|
||
|
||
status:
|
||
'DRAFT_MANAGER',
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
} else {
|
||
|
||
append_(
|
||
CFG.SHEETS.PAYROLL,
|
||
{
|
||
|
||
payroll_id:
|
||
uuid_(
|
||
'PAY'
|
||
),
|
||
|
||
employee_id:
|
||
employeeId,
|
||
|
||
period:
|
||
period,
|
||
|
||
approved_minutes:
|
||
summary.worked_minutes,
|
||
|
||
base_amount:
|
||
baseAmount,
|
||
|
||
bonus_amount:
|
||
bonusAmount,
|
||
|
||
tips_amount:
|
||
tipsAmount,
|
||
|
||
other_amount:
|
||
otherAmount,
|
||
|
||
final_amount:
|
||
finalAmount,
|
||
|
||
status:
|
||
'DRAFT_MANAGER',
|
||
|
||
payment_date:
|
||
'',
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'PAYROLL_ADJUSTMENTS_SAVED',
|
||
'PAYROLL',
|
||
String(
|
||
employeeId
|
||
) +
|
||
':' +
|
||
period,
|
||
'',
|
||
JSON.stringify({
|
||
|
||
tips_amount:
|
||
tipsAmount,
|
||
|
||
bonus_amount:
|
||
bonusAmount,
|
||
|
||
other_amount:
|
||
otherAmount,
|
||
|
||
final_amount:
|
||
finalAmount
|
||
|
||
})
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
ok:
|
||
true,
|
||
|
||
final_amount:
|
||
finalAmount
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ADMIN – UZAMČENÍ A PŘEDÁNÍ ÚČETNÍ
|
||
Tato funkce nahrazuje původní lockMonthClosure z frontendu.
|
||
============================================================ */
|
||
|
||
function adminLockClosureAndPreparePayroll(
|
||
token,
|
||
closureId
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN'
|
||
]
|
||
);
|
||
|
||
|
||
const lock =
|
||
LockService
|
||
.getScriptLock();
|
||
|
||
|
||
lock.waitLock(
|
||
10000
|
||
);
|
||
|
||
|
||
try {
|
||
|
||
const closures =
|
||
displayRows_(
|
||
CFG.SHEETS.MONTH_CLOSURES
|
||
);
|
||
|
||
|
||
const closure =
|
||
closures.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.closure_id
|
||
) ===
|
||
String(
|
||
closureId
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!closure
|
||
) {
|
||
|
||
throw new Error(
|
||
'Uzávěrka nebyla nalezena.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
String(
|
||
closure.status
|
||
) !==
|
||
'MANAGER_APPROVED'
|
||
) {
|
||
|
||
throw new Error(
|
||
'Uzávěrku lze předat účetní až po schválení.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const summary =
|
||
getClosureSummary_(
|
||
closure.employee_id,
|
||
closure.period
|
||
);
|
||
|
||
|
||
const payrolls =
|
||
displayRows_(
|
||
CFG.SHEETS.PAYROLL
|
||
);
|
||
|
||
|
||
const existing =
|
||
payrolls.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.employee_id
|
||
) ===
|
||
String(
|
||
closure.employee_id
|
||
) &&
|
||
String(
|
||
row.period
|
||
) ===
|
||
String(
|
||
closure.period
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const tipsAmount =
|
||
existing
|
||
? numberFromSheet_(
|
||
existing.tips_amount
|
||
)
|
||
: 0;
|
||
|
||
|
||
const bonusAmount =
|
||
existing
|
||
? numberFromSheet_(
|
||
existing.bonus_amount
|
||
)
|
||
: 0;
|
||
|
||
|
||
const otherAmount =
|
||
existing
|
||
? numberFromSheet_(
|
||
existing.other_amount
|
||
)
|
||
: 0;
|
||
|
||
|
||
const baseAmount =
|
||
Number(
|
||
summary.earned_estimate ||
|
||
0
|
||
);
|
||
|
||
|
||
const finalAmount =
|
||
baseAmount +
|
||
tipsAmount +
|
||
bonusAmount +
|
||
otherAmount;
|
||
|
||
|
||
if (
|
||
existing
|
||
) {
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.PAYROLL,
|
||
'payroll_id',
|
||
existing.payroll_id,
|
||
{
|
||
|
||
approved_minutes:
|
||
summary.worked_minutes,
|
||
|
||
base_amount:
|
||
baseAmount,
|
||
|
||
tips_amount:
|
||
tipsAmount,
|
||
|
||
bonus_amount:
|
||
bonusAmount,
|
||
|
||
other_amount:
|
||
otherAmount,
|
||
|
||
final_amount:
|
||
finalAmount,
|
||
|
||
status:
|
||
'READY_FOR_ACCOUNTANT',
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
} else {
|
||
|
||
append_(
|
||
CFG.SHEETS.PAYROLL,
|
||
{
|
||
|
||
payroll_id:
|
||
uuid_(
|
||
'PAY'
|
||
),
|
||
|
||
employee_id:
|
||
closure.employee_id,
|
||
|
||
period:
|
||
closure.period,
|
||
|
||
approved_minutes:
|
||
summary.worked_minutes,
|
||
|
||
base_amount:
|
||
baseAmount,
|
||
|
||
bonus_amount:
|
||
bonusAmount,
|
||
|
||
tips_amount:
|
||
tipsAmount,
|
||
|
||
other_amount:
|
||
otherAmount,
|
||
|
||
final_amount:
|
||
finalAmount,
|
||
|
||
status:
|
||
'READY_FOR_ACCOUNTANT',
|
||
|
||
payment_date:
|
||
'',
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.MONTH_CLOSURES,
|
||
'closure_id',
|
||
closureId,
|
||
{
|
||
|
||
locked_at:
|
||
now_(),
|
||
|
||
status:
|
||
'LOCKED'
|
||
|
||
}
|
||
);
|
||
|
||
|
||
setMonthShiftStatus_(
|
||
closure.employee_id,
|
||
closure.period,
|
||
'LOCKED'
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'MONTH_LOCKED_AND_SENT_TO_ACCOUNTANT',
|
||
'MONTH_CLOSURE',
|
||
closureId,
|
||
'MANAGER_APPROVED',
|
||
'LOCKED'
|
||
);
|
||
|
||
|
||
notifyEmployee_(
|
||
closure.employee_id,
|
||
'Docházka uzavřena',
|
||
'Docházka za ' +
|
||
closure.period +
|
||
' byla uzavřena a předána ke mzdovému zpracování.'
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
ok:
|
||
true,
|
||
|
||
final_amount:
|
||
finalAmount
|
||
|
||
};
|
||
|
||
}
|
||
|
||
finally {
|
||
|
||
lock.releaseLock();
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ACCOUNTANT / ADMIN – MZDOVÝ DASHBOARD
|
||
============================================================ */
|
||
|
||
function getPayrollDashboard(
|
||
token,
|
||
period
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'ACCOUNTANT'
|
||
]
|
||
);
|
||
|
||
|
||
period =
|
||
String(
|
||
period || ''
|
||
).trim();
|
||
|
||
|
||
const employees =
|
||
displayRows_(
|
||
CFG.SHEETS.EMPLOYEES
|
||
);
|
||
|
||
|
||
const employeeMap =
|
||
{};
|
||
|
||
|
||
employees.forEach(
|
||
function(employee) {
|
||
|
||
employeeMap[
|
||
String(
|
||
employee.employee_id
|
||
)
|
||
] =
|
||
employee;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
let payrolls =
|
||
displayRows_(
|
||
CFG.SHEETS.PAYROLL
|
||
);
|
||
|
||
|
||
if (
|
||
period
|
||
) {
|
||
|
||
payrolls =
|
||
payrolls.filter(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.period
|
||
) ===
|
||
period
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
payrolls =
|
||
payrolls.filter(
|
||
function(row) {
|
||
|
||
return [
|
||
'READY_FOR_ACCOUNTANT',
|
||
'PROCESSING',
|
||
'PROCESSED',
|
||
'PAID'
|
||
].includes(
|
||
String(
|
||
row.status
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const rows =
|
||
payrolls
|
||
.map(
|
||
function(payroll) {
|
||
|
||
const employee =
|
||
employeeMap[
|
||
String(
|
||
payroll.employee_id
|
||
)
|
||
];
|
||
|
||
|
||
const baseAmount =
|
||
numberFromSheet_(
|
||
payroll.base_amount
|
||
);
|
||
|
||
|
||
const tipsAmount =
|
||
numberFromSheet_(
|
||
payroll.tips_amount
|
||
);
|
||
|
||
|
||
const bonusAmount =
|
||
numberFromSheet_(
|
||
payroll.bonus_amount
|
||
);
|
||
|
||
|
||
const otherAmount =
|
||
numberFromSheet_(
|
||
payroll.other_amount
|
||
);
|
||
|
||
|
||
const finalAmount =
|
||
baseAmount +
|
||
tipsAmount +
|
||
bonusAmount +
|
||
otherAmount;
|
||
|
||
|
||
return {
|
||
|
||
payroll_id:
|
||
String(
|
||
payroll.payroll_id ||
|
||
''
|
||
),
|
||
|
||
employee_id:
|
||
String(
|
||
payroll.employee_id ||
|
||
''
|
||
),
|
||
|
||
employee_name:
|
||
employee
|
||
? (
|
||
String(
|
||
employee.first_name ||
|
||
''
|
||
) +
|
||
' ' +
|
||
String(
|
||
employee.last_name ||
|
||
''
|
||
)
|
||
).trim()
|
||
: String(
|
||
payroll.employee_id ||
|
||
''
|
||
),
|
||
|
||
employment_type:
|
||
employee
|
||
? String(
|
||
employee.employment_type ||
|
||
''
|
||
)
|
||
: '',
|
||
|
||
period:
|
||
String(
|
||
payroll.period ||
|
||
''
|
||
),
|
||
|
||
approved_minutes:
|
||
numberFromSheet_(
|
||
payroll.approved_minutes
|
||
),
|
||
|
||
base_amount:
|
||
baseAmount,
|
||
|
||
tips_amount:
|
||
tipsAmount,
|
||
|
||
bonus_amount:
|
||
bonusAmount,
|
||
|
||
other_amount:
|
||
otherAmount,
|
||
|
||
final_amount:
|
||
finalAmount,
|
||
|
||
status:
|
||
String(
|
||
payroll.status ||
|
||
''
|
||
),
|
||
|
||
payment_date:
|
||
String(
|
||
payroll.payment_date ||
|
||
''
|
||
)
|
||
|
||
};
|
||
|
||
}
|
||
)
|
||
.sort(
|
||
function(a,b) {
|
||
|
||
if (
|
||
a.period !==
|
||
b.period
|
||
) {
|
||
|
||
return b.period.localeCompare(
|
||
a.period
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return a.employee_name.localeCompare(
|
||
b.employee_name,
|
||
'cs'
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const stats = {
|
||
|
||
count:
|
||
rows.length,
|
||
|
||
total_base:
|
||
0,
|
||
|
||
total_tips:
|
||
0,
|
||
|
||
total_bonus:
|
||
0,
|
||
|
||
total_other:
|
||
0,
|
||
|
||
total_final:
|
||
0,
|
||
|
||
ready:
|
||
0,
|
||
|
||
processing:
|
||
0,
|
||
|
||
processed:
|
||
0,
|
||
|
||
paid:
|
||
0
|
||
|
||
};
|
||
|
||
|
||
rows.forEach(
|
||
function(row) {
|
||
|
||
stats.total_base +=
|
||
row.base_amount;
|
||
|
||
|
||
stats.total_tips +=
|
||
row.tips_amount;
|
||
|
||
|
||
stats.total_bonus +=
|
||
row.bonus_amount;
|
||
|
||
|
||
stats.total_other +=
|
||
row.other_amount;
|
||
|
||
|
||
stats.total_final +=
|
||
row.final_amount;
|
||
|
||
|
||
if (
|
||
row.status ===
|
||
'READY_FOR_ACCOUNTANT'
|
||
) {
|
||
|
||
stats.ready++;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
row.status ===
|
||
'PROCESSING'
|
||
) {
|
||
|
||
stats.processing++;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
row.status ===
|
||
'PROCESSED'
|
||
) {
|
||
|
||
stats.processed++;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
row.status ===
|
||
'PAID'
|
||
) {
|
||
|
||
stats.paid++;
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
role:
|
||
String(
|
||
user.role ||
|
||
''
|
||
),
|
||
|
||
period:
|
||
period,
|
||
|
||
stats:
|
||
stats,
|
||
|
||
rows:
|
||
rows
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ACCOUNTANT / ADMIN – ZMĚNA STAVU
|
||
============================================================ */
|
||
|
||
function setPayrollStatus(
|
||
token,
|
||
payrollId,
|
||
newStatus
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'ACCOUNTANT'
|
||
]
|
||
);
|
||
|
||
|
||
newStatus =
|
||
String(
|
||
newStatus || ''
|
||
).trim();
|
||
|
||
|
||
const allowed =
|
||
[
|
||
'READY_FOR_ACCOUNTANT',
|
||
'PROCESSING',
|
||
'PROCESSED',
|
||
'PAID'
|
||
];
|
||
|
||
|
||
if (
|
||
!allowed.includes(
|
||
newStatus
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Neplatný stav mzdy.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const payrolls =
|
||
displayRows_(
|
||
CFG.SHEETS.PAYROLL
|
||
);
|
||
|
||
|
||
const payroll =
|
||
payrolls.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.payroll_id
|
||
) ===
|
||
String(
|
||
payrollId
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!payroll
|
||
) {
|
||
|
||
throw new Error(
|
||
'Mzdový podklad nebyl nalezen.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const currentStatus =
|
||
String(
|
||
payroll.status ||
|
||
''
|
||
);
|
||
|
||
|
||
const order = {
|
||
|
||
READY_FOR_ACCOUNTANT:
|
||
1,
|
||
|
||
PROCESSING:
|
||
2,
|
||
|
||
PROCESSED:
|
||
3,
|
||
|
||
PAID:
|
||
4
|
||
|
||
};
|
||
|
||
|
||
if (
|
||
order[
|
||
newStatus
|
||
] <
|
||
order[
|
||
currentStatus
|
||
]
|
||
) {
|
||
|
||
throw new Error(
|
||
'Stav mzdy nelze vracet zpět.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const update = {
|
||
|
||
status:
|
||
newStatus,
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
};
|
||
|
||
|
||
if (
|
||
newStatus ===
|
||
'PAID'
|
||
) {
|
||
|
||
update.payment_date =
|
||
Utilities.formatDate(
|
||
new Date(),
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.PAYROLL,
|
||
'payroll_id',
|
||
payrollId,
|
||
update
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'PAYROLL_STATUS_CHANGED',
|
||
'PAYROLL',
|
||
payrollId,
|
||
currentStatus,
|
||
newStatus
|
||
);
|
||
|
||
|
||
if (
|
||
newStatus ===
|
||
'PROCESSED'
|
||
) {
|
||
|
||
notifyEmployee_(
|
||
payroll.employee_id,
|
||
'Mzda byla zpracována',
|
||
'Mzda za ' +
|
||
payroll.period +
|
||
' byla zpracována účetní.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
newStatus ===
|
||
'PAID'
|
||
) {
|
||
|
||
notifyEmployee_(
|
||
payroll.employee_id,
|
||
'Mzda byla označena jako vyplacená',
|
||
'Mzda za ' +
|
||
payroll.period +
|
||
' byla označena jako vyplacená.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return {
|
||
|
||
ok:
|
||
true
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ACCOUNTANT / ADMIN – CSV EXPORT
|
||
============================================================ */
|
||
|
||
function exportPayrollCsv(
|
||
token,
|
||
period
|
||
) {
|
||
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'ACCOUNTANT'
|
||
]
|
||
);
|
||
|
||
|
||
const data =
|
||
getPayrollDashboard(
|
||
token,
|
||
period
|
||
);
|
||
|
||
|
||
const rows =
|
||
[
|
||
[
|
||
'Zaměstnanec',
|
||
'Typ vztahu',
|
||
'Období',
|
||
'Schválené hodiny',
|
||
'Základ',
|
||
'Spropitné',
|
||
'Bonusy',
|
||
'Ostatní',
|
||
'Celkem',
|
||
'Stav',
|
||
'Datum výplaty'
|
||
]
|
||
];
|
||
|
||
|
||
data.rows.forEach(
|
||
function(row) {
|
||
|
||
rows.push(
|
||
[
|
||
row.employee_name,
|
||
row.employment_type,
|
||
row.period,
|
||
(
|
||
row.approved_minutes /
|
||
60
|
||
).toFixed(
|
||
2
|
||
),
|
||
row.base_amount,
|
||
row.tips_amount,
|
||
row.bonus_amount,
|
||
row.other_amount,
|
||
row.final_amount,
|
||
row.status,
|
||
row.payment_date
|
||
]
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const csv =
|
||
rows
|
||
.map(
|
||
function(row) {
|
||
|
||
return row
|
||
.map(
|
||
function(value) {
|
||
|
||
const text =
|
||
String(
|
||
value == null
|
||
? ''
|
||
: value
|
||
);
|
||
|
||
|
||
return (
|
||
'"' +
|
||
text.replace(
|
||
/"/g,
|
||
'""'
|
||
) +
|
||
'"'
|
||
);
|
||
|
||
}
|
||
)
|
||
.join(
|
||
';'
|
||
);
|
||
|
||
}
|
||
)
|
||
.join(
|
||
'\r\n'
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
filename:
|
||
'EatMe_Portal_mzdy_' +
|
||
(
|
||
period ||
|
||
'vse'
|
||
) +
|
||
'.csv',
|
||
|
||
content:
|
||
'\uFEFF' +
|
||
csv
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
POMOCNÁ FUNKCE – ČÍSELNÁ HODNOTA
|
||
============================================================ */
|
||
|
||
function payrollNumber_(
|
||
value
|
||
) {
|
||
|
||
const number =
|
||
Number(
|
||
String(
|
||
value == null
|
||
? 0
|
||
: value
|
||
)
|
||
.replace(
|
||
',',
|
||
'.'
|
||
)
|
||
);
|
||
|
||
|
||
if (
|
||
!isFinite(
|
||
number
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Částka musí být číslo.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return Math.round(
|
||
number *
|
||
100
|
||
) /
|
||
100;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
DIAGNOSTIKA
|
||
============================================================ */
|
||
|
||
function testPayrollDatabase() {
|
||
|
||
Logger.log(
|
||
'PAYROLL: ' +
|
||
JSON.stringify(
|
||
displayRows_(
|
||
CFG.SHEETS.PAYROLL
|
||
)
|
||
)
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'PayrollService OK'
|
||
);
|
||
|
||
} |