mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 23:17:23 +00:00
627 lines
31 KiB
PHP
627 lines
31 KiB
PHP
<?php
|
|
|
|
/*
|
|
* Prepaid hours on contracts - block hours bought up front, and hours a
|
|
* contract includes each month - drawn down by the time on the client's
|
|
* tickets.
|
|
*
|
|
* A block (contract_blocks, from database 3.4.4) is hours bought on a day,
|
|
* maybe for a price and on an invoice, maybe running out on a day; a month's
|
|
* included hours are a block made for that month, running out at its end.
|
|
* Time is drawn the way it is billed - by the ticket, once it is billable and
|
|
* resolved or closed, rounded by the rules in Admin > Settings > Invoice -
|
|
* from the client's active contracts' blocks, the one running out first
|
|
* first (contract_block_uses: a row per block a ticket drew from). A ticket
|
|
* whose time is all covered leaves Unbilled Time; one that is partly covered
|
|
* stays there with the rest, the overage, to bill as usual. A block covers
|
|
* work done before it runs out.
|
|
*
|
|
* The nightly job adds each month's included hours and draws the tickets
|
|
* done since; Draw now does it straight away, from the contract or from
|
|
* Unbilled Time. A ticket can be put back until it is invoiced: its hours
|
|
* return to the blocks and it is billed as usual, kept out of later draws
|
|
* (a use of no hours marks it) until an agent draws it again. The owner is
|
|
* told when hours run low, once, until more are added.
|
|
*
|
|
* agent/contract.php shows them, with Add hours (agent/modals/contract/
|
|
* contract_hours_add.php) and the handlers in agent/post/contract.php;
|
|
* functions/billing.php leaves out what is covered (billingUnbilledSql(),
|
|
* billingUnbilledTickets()).
|
|
*/
|
|
|
|
/** Whether the 3.4.4 update has run - contract_blocks is there. */
|
|
function contractHoursReady() {
|
|
global $mysqli;
|
|
static $ready = null;
|
|
if ($ready === null) {
|
|
if (defined('CURRENT_DATABASE_VERSION')) {
|
|
$version = CURRENT_DATABASE_VERSION;
|
|
} else {
|
|
$result = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings WHERE company_id = 1");
|
|
$version = $result && ($row = mysqli_fetch_row($result)) ? (string) $row[0] : '0';
|
|
}
|
|
$ready = version_compare($version, '3.4.4', '>=');
|
|
}
|
|
return $ready;
|
|
}
|
|
|
|
/** Hours as "12.5 h", "1 h". */
|
|
function contractHoursText($hours) {
|
|
return rtrim(rtrim(number_format(round(floatval($hours), 2), 2, '.', ''), '0'), '.') . ' h';
|
|
}
|
|
|
|
/** Hours in words - "12.5 hours", "1 hour". */
|
|
function contractHoursWords($hours) {
|
|
$hours = round(floatval($hours), 2);
|
|
return rtrim(rtrim(number_format($hours, 2, '.', ''), '0'), '.') . ($hours == 1 ? ' hour' : ' hours');
|
|
}
|
|
|
|
/*
|
|
* A contract's blocks, the one running out first first - each with what was
|
|
* drawn from it, what is left, and whether it has run out ($today).
|
|
*/
|
|
function contractBlocks($contract_id, $today = null) {
|
|
global $mysqli;
|
|
$today = $today ?? date('Y-m-d');
|
|
$blocks = [];
|
|
if (!contractHoursReady()) {
|
|
return $blocks;
|
|
}
|
|
$sql = mysqli_query($mysqli, "SELECT contract_blocks.*, COALESCE(SUM(contract_block_use_hours), 0) AS used, COUNT(NULLIF(contract_block_use_hours, 0)) AS uses,
|
|
invoice_prefix, invoice_number, invoice_status
|
|
FROM contract_blocks
|
|
LEFT JOIN contract_block_uses ON contract_block_use_block_id = contract_block_id
|
|
LEFT JOIN invoices ON contract_block_invoice_id = invoice_id AND contract_block_invoice_id > 0
|
|
WHERE contract_block_contract_id = " . intval($contract_id) . "
|
|
GROUP BY contract_block_id
|
|
ORDER BY contract_block_expires IS NULL, contract_block_expires ASC, contract_block_bought ASC, contract_block_id ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$row['used'] = round(floatval($row['used']), 2);
|
|
$row['left'] = max(0.0, round(floatval($row['contract_block_hours']) - $row['used'], 2));
|
|
$row['expired'] = !empty($row['contract_block_expires']) && $row['contract_block_expires'] < $today;
|
|
$blocks[intval($row['contract_block_id'])] = $row;
|
|
}
|
|
return $blocks;
|
|
}
|
|
|
|
/*
|
|
* Where a contract's prepaid hours stand - bought (in blocks still good),
|
|
* used of those, left, left unused in blocks that ran out, the next day a
|
|
* block with hours left runs out, and how many blocks. $blocks from
|
|
* contractBlocks().
|
|
*/
|
|
function contractHoursSummary(array $blocks) {
|
|
$s = ['bought' => 0.0, 'used' => 0.0, 'left' => 0.0, 'lapsed' => 0.0, 'next_expiry' => '', 'next_expiry_hours' => 0.0, 'blocks' => count($blocks)];
|
|
foreach ($blocks as $block) {
|
|
if ($block['expired']) {
|
|
$s['lapsed'] += $block['left'];
|
|
continue;
|
|
}
|
|
$s['bought'] += floatval($block['contract_block_hours']);
|
|
$s['used'] += $block['used'];
|
|
$s['left'] += $block['left'];
|
|
if ($block['left'] > 0 && !empty($block['contract_block_expires']) && ($s['next_expiry'] === '' || $block['contract_block_expires'] < $s['next_expiry'])) {
|
|
$s['next_expiry'] = (string) $block['contract_block_expires'];
|
|
$s['next_expiry_hours'] = $block['left'];
|
|
}
|
|
}
|
|
foreach (['bought', 'used', 'left', 'lapsed', 'next_expiry_hours'] as $key) {
|
|
$s[$key] = round($s[$key], 2);
|
|
}
|
|
return $s;
|
|
}
|
|
|
|
/** Hours left (and prepaid) in blocks still good, for some contracts - id => [left, bought], for the ones with blocks. For lists. */
|
|
function contractHoursLeftFor(array $contract_ids, $today = null) {
|
|
global $mysqli;
|
|
$ids = array_filter(array_map('intval', $contract_ids));
|
|
$left = [];
|
|
if (!$ids || !contractHoursReady()) {
|
|
return $left;
|
|
}
|
|
$today = escapeSql($today ?? date('Y-m-d'));
|
|
$sql = mysqli_query($mysqli, "SELECT contract_block_contract_id, SUM(GREATEST(contract_block_hours - used, 0)) AS hours_left, SUM(contract_block_hours) AS bought
|
|
FROM (SELECT contract_block_contract_id, contract_block_hours,
|
|
COALESCE((SELECT SUM(contract_block_use_hours) FROM contract_block_uses WHERE contract_block_use_block_id = contract_block_id), 0) AS used
|
|
FROM contract_blocks WHERE contract_block_contract_id IN (" . implode(',', $ids) . ") AND (contract_block_expires IS NULL OR contract_block_expires >= '$today')) AS blocks
|
|
GROUP BY contract_block_contract_id");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$left[intval($row['contract_block_contract_id'])] = ['left' => round(floatval($row['hours_left']), 2), 'bought' => round(floatval($row['bought']), 2)];
|
|
}
|
|
return $left;
|
|
}
|
|
|
|
/*
|
|
* Whether time is drawn from a contract's prepaid hours - while it is active,
|
|
* and once cancelled, until its last day (it is in force till then).
|
|
*/
|
|
function contractHoursLive(array $row, $today = null) {
|
|
$today = $today ?? date('Y-m-d');
|
|
$status = (string) ($row['contract_status'] ?? '');
|
|
return $status === 'Active' || ($status === 'Cancelled' && (string) ($row['contract_end_date'] ?? '') >= $today);
|
|
}
|
|
|
|
/** SQL for contractHoursLive(). */
|
|
function contractHoursLiveSql($today = null) {
|
|
$today = $today !== null ? "'" . escapeSql($today) . "'" : 'CURDATE()';
|
|
return "(contract_status = 'Active' OR (contract_status = 'Cancelled' AND contract_end_date >= $today))";
|
|
}
|
|
|
|
/** Whether a contract has prepaid hours to show - blocks, hours each month, or it is a block hours contract. */
|
|
function contractHasHours(array $row, array $blocks) {
|
|
return $blocks || floatval($row['contract_monthly_hours'] ?? 0) > 0 || ($row['contract_type'] ?? '') === 'block';
|
|
}
|
|
|
|
/** The client's contracts that have prepaid hours to draw from (contractHoursLive()) - id => row with its blocks and summary. */
|
|
function contractClientHours($client_id) {
|
|
global $mysqli;
|
|
$contracts = [];
|
|
if (!contractHoursReady()) {
|
|
return $contracts;
|
|
}
|
|
$sql = mysqli_query($mysqli, "SELECT * FROM contracts WHERE contract_client_id = " . intval($client_id) . " AND " . contractHoursLiveSql() . " AND contract_archived_at IS NULL
|
|
AND (contract_monthly_hours > 0 OR EXISTS (SELECT 1 FROM contract_blocks WHERE contract_block_contract_id = contract_id))
|
|
ORDER BY contract_id ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$row['blocks'] = contractBlocks(intval($row['contract_id']));
|
|
$row['hours'] = contractHoursSummary($row['blocks']);
|
|
$contracts[intval($row['contract_id'])] = $row;
|
|
}
|
|
return $contracts;
|
|
}
|
|
|
|
/*
|
|
* This month's included hours, when a contract has them and is in force -
|
|
* a block from the first of the month (or the day it starts) to the end of
|
|
* it (or its last day), made once. Whether one was made.
|
|
*/
|
|
function contractHoursEnsureMonthly(array $row, $today = null) {
|
|
global $mysqli;
|
|
$today = $today ?? date('Y-m-d');
|
|
$hours = round(floatval($row['contract_monthly_hours'] ?? 0), 2);
|
|
if (!contractHoursReady() || $hours <= 0 || !contractHoursLive($row, $today)) {
|
|
return false;
|
|
}
|
|
$start = (string) ($row['contract_start_date'] ?? '');
|
|
$end = (string) ($row['contract_end_date'] ?? '');
|
|
if (($start !== '' && $start > $today) || ($end !== '' && $end < $today)) {
|
|
return false;
|
|
}
|
|
$month = substr($today, 0, 7);
|
|
$from = max($month . '-01', $start !== '' ? $start : $month . '-01');
|
|
$to = date('Y-m-t', strtotime($today));
|
|
$to = $end !== '' && $end < $to ? $end : $to;
|
|
$note = 'Included in ' . date('F Y', strtotime($today));
|
|
mysqli_query($mysqli, "INSERT IGNORE INTO contract_blocks SET contract_block_contract_id = " . intval($row['contract_id']) . ", contract_block_hours = " . sprintf('%.2f', $hours) . ",
|
|
contract_block_bought = '$from', contract_block_expires = '$to', contract_block_month = '$month', contract_block_note = '" . escapeSql($note) . "'");
|
|
if (mysqli_affected_rows($mysqli) === 1) {
|
|
contractHoursAlertReset(intval($row['contract_id']));
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/*
|
|
* After a contract is saved or made active: this month's included hours
|
|
* follow a change to the hours each month (never under what was used), and
|
|
* are made when it is in force and has none yet.
|
|
*/
|
|
function contractHoursSync($contract_id) {
|
|
global $mysqli;
|
|
$row = contractRow($contract_id);
|
|
if (!$row || !contractHoursReady()) {
|
|
return;
|
|
}
|
|
$hours = round(floatval($row['contract_monthly_hours']), 2);
|
|
if ($hours > 0) {
|
|
mysqli_query($mysqli, "UPDATE contract_blocks SET contract_block_hours = GREATEST(" . sprintf('%.2f', $hours) . ",
|
|
(SELECT COALESCE(SUM(contract_block_use_hours), 0) FROM contract_block_uses WHERE contract_block_use_block_id = contract_block_id))
|
|
WHERE contract_block_contract_id = " . intval($contract_id) . " AND contract_block_month = '" . date('Y-m') . "'");
|
|
}
|
|
contractHoursEnsureMonthly($row);
|
|
contractHoursAlertReset(intval($contract_id));
|
|
}
|
|
|
|
/** The prepaid hours a ticket drew - each with its block and contract. $kept_out: whether it was put back and is kept out of draws. */
|
|
function contractHoursTicketUses($ticket_id, &$kept_out = false) {
|
|
global $mysqli;
|
|
$uses = [];
|
|
$kept_out = false;
|
|
if (!contractHoursReady()) {
|
|
return $uses;
|
|
}
|
|
$sql = mysqli_query($mysqli, "SELECT contract_block_uses.*, contract_block_note, contract_block_month, contract_block_bought, contract_block_expires, contract_id, contract_name, contract_client_id
|
|
FROM contract_block_uses
|
|
JOIN contract_blocks ON contract_block_use_block_id = contract_block_id
|
|
JOIN contracts ON contract_block_contract_id = contract_id
|
|
WHERE contract_block_use_ticket_id = " . intval($ticket_id) . " ORDER BY contract_block_use_id ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
if (floatval($row['contract_block_use_hours']) > 0) {
|
|
$uses[] = $row;
|
|
} else {
|
|
$kept_out = true;
|
|
}
|
|
}
|
|
return $uses;
|
|
}
|
|
|
|
/** The tickets a contract's blocks covered, newest first - each with the hours drawn. $limit 0 for all. */
|
|
function contractHoursUses($contract_id, $limit = 0) {
|
|
global $mysqli;
|
|
$rows = [];
|
|
if (!contractHoursReady()) {
|
|
return $rows;
|
|
}
|
|
$sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_resolved_at, ticket_closed_at, ticket_invoice_id, ticket_client_id, ticket_contact_id,
|
|
SUM(contract_block_use_hours) AS hours, MAX(contract_block_use_full) AS covered, MAX(contract_block_use_created_at) AS drawn_at
|
|
FROM contract_block_uses
|
|
JOIN contract_blocks ON contract_block_use_block_id = contract_block_id
|
|
JOIN tickets ON contract_block_use_ticket_id = ticket_id
|
|
WHERE contract_block_contract_id = " . intval($contract_id) . " AND contract_block_use_hours > 0
|
|
GROUP BY ticket_id
|
|
ORDER BY drawn_at DESC, ticket_id DESC" . ($limit ? ' LIMIT ' . intval($limit) : ''));
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$row['hours'] = round(floatval($row['hours']), 2);
|
|
$rows[] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/*
|
|
* Draw a client's done tickets from their prepaid hours - $ticket_ids, or
|
|
* every one waiting in Unbilled Time. Oldest first, each from the blocks
|
|
* that cover the day it was done (from the day bought to the day they run
|
|
* out), the one running out first first. What a ticket needs beyond them
|
|
* stays in Unbilled Time to bill. $by for the history. [drawn (references
|
|
* => hours), hours drawn, overage (references => hours left to bill, for
|
|
* tickets the hours ran out on), hours left, contracts].
|
|
*/
|
|
function contractHoursDraw($client_id, array $ticket_ids = [], $by = 'ITFlow', $user_id = 0) {
|
|
global $mysqli;
|
|
$client_id = intval($client_id);
|
|
$result = ['drawn' => [], 'hours' => 0.0, 'overage' => [], 'left' => 0.0, 'contracts' => []];
|
|
if (!contractHoursReady() || !$client_id) {
|
|
return $result;
|
|
}
|
|
// One draw for a client at a time - the nightly job and an agent could start together
|
|
$lock = "itflow_contract_hours_$client_id";
|
|
if (!intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT GET_LOCK('$lock', 10)"))[0] ?? 0)) {
|
|
return $result;
|
|
}
|
|
try {
|
|
$contracts = contractClientHours($client_id);
|
|
$made = false;
|
|
foreach ($contracts as $contract) {
|
|
$made = contractHoursEnsureMonthly($contract) || $made;
|
|
}
|
|
if ($made) {
|
|
$contracts = contractClientHours($client_id);
|
|
}
|
|
contractHoursRecheck($client_id);
|
|
|
|
// The blocks with hours left, the one running out first first, across the client's active contracts
|
|
$blocks = [];
|
|
foreach ($contracts as $contract) {
|
|
foreach ($contract['blocks'] as $id => $block) {
|
|
if ($block['left'] > 0) {
|
|
$blocks[$id] = $block + ['contract_name' => $contract['contract_name']];
|
|
}
|
|
}
|
|
}
|
|
uasort($blocks, function ($a, $b) {
|
|
return [$a['contract_block_expires'] === null ? 1 : 0, (string) $a['contract_block_expires'], $a['contract_block_bought'], $a['contract_block_id']]
|
|
<=> [$b['contract_block_expires'] === null ? 1 : 0, (string) $b['contract_block_expires'], $b['contract_block_bought'], $b['contract_block_id']];
|
|
});
|
|
$result['contracts'] = $contracts;
|
|
if (!$blocks) {
|
|
return $result;
|
|
}
|
|
|
|
// Tickets put back stay out - unless an agent draws them by name
|
|
if ($ticket_ids) {
|
|
mysqli_query($mysqli, "DELETE FROM contract_block_uses WHERE contract_block_use_hours = 0 AND contract_block_use_ticket_id IN (" . implode(',', array_map('intval', $ticket_ids)) . ")");
|
|
}
|
|
$kept_out = [];
|
|
$sql = mysqli_query($mysqli, "SELECT DISTINCT contract_block_use_ticket_id FROM contract_block_uses JOIN tickets ON contract_block_use_ticket_id = ticket_id
|
|
WHERE ticket_client_id = $client_id AND contract_block_use_hours = 0");
|
|
while ($row = mysqli_fetch_row($sql)) {
|
|
$kept_out[intval($row[0])] = true;
|
|
}
|
|
$tickets = array_diff_key(billingUnbilledTickets($client_id, $ticket_ids), $kept_out);
|
|
uasort($tickets, function ($a, $b) {
|
|
return [contractHoursDoneOn($a), $a['ticket_id']] <=> [contractHoursDoneOn($b), $b['ticket_id']];
|
|
});
|
|
$touched = [];
|
|
foreach ($tickets as $ticket_id => $ticket) {
|
|
$need = round(floatval($ticket['hours']), 2);
|
|
if ($need <= 0) {
|
|
continue;
|
|
}
|
|
$done = contractHoursDoneOn($ticket);
|
|
$taken = [];
|
|
foreach ($blocks as $id => $block) {
|
|
if ($need <= 0) {
|
|
break;
|
|
}
|
|
if ($blocks[$id]['left'] <= 0 || $done < $block['contract_block_bought'] || (!empty($block['contract_block_expires']) && $done > $block['contract_block_expires'])) {
|
|
continue;
|
|
}
|
|
$take = round(min($need, $blocks[$id]['left']), 2);
|
|
mysqli_query($mysqli, "INSERT INTO contract_block_uses SET contract_block_use_block_id = " . intval($id) . ", contract_block_use_ticket_id = " . intval($ticket_id) . ",
|
|
contract_block_use_hours = " . sprintf('%.2f', $take) . ", contract_block_use_created_by = " . intval($user_id));
|
|
$taken[] = [intval(mysqli_insert_id($mysqli)), $block['contract_name'], $take];
|
|
$blocks[$id]['left'] = round($blocks[$id]['left'] - $take, 2);
|
|
$need = round($need - $take, 2);
|
|
$touched[intval($block['contract_block_contract_id'])] = true;
|
|
}
|
|
if (!$taken) {
|
|
// No hours that cover the day it was done - it is billed as usual
|
|
continue;
|
|
}
|
|
$drawn = round(array_sum(array_column($taken, 2)), 2);
|
|
$reference = $ticket['ticket_prefix'] . $ticket['ticket_number'];
|
|
if ($need <= 0) {
|
|
// All of it covered - it leaves Unbilled Time
|
|
mysqli_query($mysqli, "UPDATE contract_block_uses SET contract_block_use_full = 1 WHERE contract_block_use_id = " . intval(end($taken)[0]));
|
|
} else {
|
|
$result['overage'][$reference] = $need;
|
|
}
|
|
$result['drawn'][$reference] = $drawn;
|
|
$result['hours'] += $drawn;
|
|
$names = implode(', ', array_unique(array_column($taken, 1)));
|
|
logTicketHistory($ticket_id, escapeSql(contractHoursText($drawn) . " drawn from prepaid hours ($names) by $by" . ($need > 0 ? ' - ' . contractHoursText($need) . ' over, left to bill' : '')));
|
|
}
|
|
$result['hours'] = round($result['hours'], 2);
|
|
$today = date('Y-m-d');
|
|
foreach ($blocks as $block) {
|
|
$result['left'] += empty($block['contract_block_expires']) || $block['contract_block_expires'] >= $today ? $block['left'] : 0;
|
|
}
|
|
$result['left'] = round($result['left'], 2);
|
|
|
|
// Running low, or out - the owner hears once
|
|
foreach (array_keys($touched) as $contract_id) {
|
|
contractHoursAlertCheck($contract_id);
|
|
}
|
|
// The hours ran out part way through a ticket - the rest is to bill
|
|
if ($result['overage']) {
|
|
$count = count($result['overage']);
|
|
appNotify("Contract", escapeSql(getFieldById('clients', $client_id, 'client_name') . " ran out of prepaid hours - " . contractHoursText(array_sum($result['overage']))
|
|
. " on $count ticket" . ($count === 1 ? '' : 's') . " left to bill"), "/agent/unbilled_time.php?client_id=$client_id", $client_id);
|
|
}
|
|
return $result;
|
|
} finally {
|
|
mysqli_query($mysqli, "SELECT RELEASE_LOCK('$lock')");
|
|
}
|
|
}
|
|
|
|
/** The day a ticket's work was done - resolved, else closed, else opened. */
|
|
function contractHoursDoneOn(array $ticket) {
|
|
return substr((string) ($ticket['ticket_resolved_at'] ?: ($ticket['ticket_closed_at'] ?: $ticket['ticket_created_at'])), 0, 10);
|
|
}
|
|
|
|
/*
|
|
* A ticket that was all covered and has had time added since (reopened and
|
|
* worked on, say) needs the rest billed: it goes back to Unbilled Time,
|
|
* keeping what it drew, for the next draw to take the rest. How many.
|
|
*/
|
|
function contractHoursRecheck($client_id) {
|
|
global $mysqli;
|
|
$rules = billingTimeRules();
|
|
$back = 0;
|
|
$sql = mysqli_query($mysqli, "SELECT ticket_id, SUM(contract_block_use_hours) AS drawn,
|
|
COALESCE((SELECT SUM(TIME_TO_SEC(ticket_reply_time_worked)) FROM ticket_replies WHERE ticket_reply_ticket_id = ticket_id AND ticket_reply_archived_at IS NULL), 0) AS seconds
|
|
FROM contract_block_uses
|
|
JOIN tickets ON contract_block_use_ticket_id = ticket_id
|
|
WHERE ticket_client_id = " . intval($client_id) . " AND ticket_invoice_id = 0 AND contract_block_use_hours > 0
|
|
GROUP BY ticket_id
|
|
HAVING MAX(contract_block_use_full) = 1");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
if (billingTimeHours(intval($row['seconds']), $rules) > round(floatval($row['drawn']), 2)) {
|
|
mysqli_query($mysqli, "UPDATE contract_block_uses SET contract_block_use_full = 0 WHERE contract_block_use_ticket_id = " . intval($row['ticket_id']));
|
|
$back++;
|
|
}
|
|
}
|
|
return $back;
|
|
}
|
|
|
|
/*
|
|
* Put a ticket's hours back - its draws undone, so it is in Unbilled Time
|
|
* again with all its time, to bill as usual, and kept out of later draws
|
|
* until it is drawn by name. Not once it is on an invoice. Hours put back,
|
|
* or null.
|
|
*/
|
|
function contractHoursPutBack($ticket_id, $by, $user_id = 0) {
|
|
global $mysqli;
|
|
$ticket_id = intval($ticket_id);
|
|
$uses = contractHoursTicketUses($ticket_id);
|
|
if (!$uses || intval(getFieldById('tickets', $ticket_id, 'ticket_invoice_id'))) {
|
|
return null;
|
|
}
|
|
$hours = round(array_sum(array_column($uses, 'contract_block_use_hours')), 2);
|
|
mysqli_query($mysqli, "DELETE FROM contract_block_uses WHERE contract_block_use_ticket_id = $ticket_id");
|
|
// A use of no hours keeps it out of the nightly draw
|
|
mysqli_query($mysqli, "INSERT INTO contract_block_uses SET contract_block_use_block_id = " . intval($uses[0]['contract_block_use_block_id']) . ", contract_block_use_ticket_id = $ticket_id,
|
|
contract_block_use_hours = 0, contract_block_use_created_by = " . intval($user_id));
|
|
logTicketHistory($ticket_id, escapeSql(contractHoursText($hours) . " put back on the prepaid hours by $by - the ticket is billed as usual, in Unbilled Time"));
|
|
foreach (array_unique(array_column($uses, 'contract_id')) as $contract_id) {
|
|
contractHoursAlertReset(intval($contract_id));
|
|
}
|
|
return $hours;
|
|
}
|
|
|
|
/* After hours are added or put back: told again next time they run low, once they are no longer low. */
|
|
function contractHoursAlertReset($contract_id) {
|
|
global $mysqli;
|
|
$row = contractRow($contract_id);
|
|
if (!$row || empty($row['contract_hours_alerted_at'])) {
|
|
return;
|
|
}
|
|
$left = contractHoursSummary(contractBlocks($contract_id))['left'];
|
|
if ($left > 0 && $left >= floatval($row['contract_hours_alert'])) {
|
|
mysqli_query($mysqli, "UPDATE contracts SET contract_hours_alerted_at = NULL WHERE contract_id = " . intval($contract_id));
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Running low: once a contract's hours left fall under its alert level, its
|
|
* owner is emailed and the agents told - once, until more are added. Whether
|
|
* they were told now.
|
|
*/
|
|
function contractHoursAlertCheck($contract_id) {
|
|
global $mysqli;
|
|
$row = contractRow($contract_id);
|
|
$alert = $row ? floatval($row['contract_hours_alert']) : 0;
|
|
if (!$row || $alert <= 0 || !empty($row['contract_hours_alerted_at'])) {
|
|
return false;
|
|
}
|
|
$hours = contractHoursSummary(contractBlocks($contract_id));
|
|
if ($hours['left'] >= $alert) {
|
|
return false;
|
|
}
|
|
mysqli_query($mysqli, "UPDATE contracts SET contract_hours_alerted_at = NOW() WHERE contract_id = " . intval($contract_id) . " AND contract_hours_alerted_at IS NULL");
|
|
if (mysqli_affected_rows($mysqli) !== 1) {
|
|
return false;
|
|
}
|
|
contractHoursAlertSend($row, $hours);
|
|
return true;
|
|
}
|
|
|
|
/** Tell the owner - and the agents, in ITFlow - that a contract's prepaid hours are running low. */
|
|
function contractHoursAlertSend(array $row, array $hours) {
|
|
global $config_base_url;
|
|
$client_id = intval($row['contract_client_id']);
|
|
$left = $hours['left'] > 0 ? contractHoursWords($hours['left']) . ' left' : 'no hours left';
|
|
appNotify("Contract", escapeSql("{$row['client_name']} has $left on {$row['contract_name']}"), contractAgentPath($row), $client_id, intval($row['contract_id']));
|
|
if (empty($row['owner_email'])) {
|
|
return;
|
|
}
|
|
emailQueue([['email' => $row['owner_email'], 'name' => $row['owner_name'], 'footnote' => 'You look after this contract in ITFlow.']],
|
|
"Prepaid hours running low: {$row['contract_name']} - {$row['client_name']}", [
|
|
'kind' => 'Contract', 'tone' => 'warning',
|
|
'title' => "{$row['client_name']} has $left",
|
|
'intro' => [$hours['left'] > 0
|
|
? "The prepaid hours on {$row['contract_name']} are down to " . contractHoursWords($hours['left']) . ", under the " . contractHoursWords($row['contract_hours_alert']) . " you set to be told at. Time beyond them goes to Unbilled Time to bill as usual - a good time to offer the client more."
|
|
: "The prepaid hours on {$row['contract_name']} are used up. Time beyond them goes to Unbilled Time to bill as usual - a good time to offer the client more."],
|
|
'summary' => array_filter([
|
|
'Client' => (string) $row['client_name'],
|
|
'Prepaid' => contractHoursWords($hours['bought']),
|
|
'Used' => contractHoursWords($hours['used']),
|
|
'Left' => contractHoursWords($hours['left']),
|
|
'Next to run out' => $hours['next_expiry'] !== '' ? contractHoursWords($hours['next_expiry_hours']) . ' on ' . date('M j, Y', strtotime($hours['next_expiry'])) : '',
|
|
]),
|
|
'button' => ['label' => 'Open the contract', 'url' => "https://$config_base_url" . contractAgentPath($row)],
|
|
]);
|
|
}
|
|
|
|
/*
|
|
* A block of hours from the Add hours window - [fields, error]: hours, the
|
|
* price (null: none), the day bought, the day it runs out ('' never), a
|
|
* note, and whether to invoice it (in which category).
|
|
*/
|
|
function contractHoursFromInput(array $in) {
|
|
$date = function ($key) use ($in) {
|
|
$value = trim((string) ($in[$key] ?? ''));
|
|
$parsed = DateTime::createFromFormat('!Y-m-d', $value);
|
|
return $value === '' ? '' : ($parsed && $parsed->format('Y-m-d') === $value ? $value : false);
|
|
};
|
|
$hours = billingNumberInput(trim((string) ($in['hours'] ?? '')));
|
|
$price = trim((string) ($in['price'] ?? '')) === '' ? null : billingNumberInput($in['price']);
|
|
$f = [
|
|
'hours' => $hours === null ? 0 : round($hours, 2),
|
|
'price' => $price === null ? null : round($price, 2),
|
|
'bought' => $date('bought'),
|
|
'expires' => $date('expires'),
|
|
'note' => mb_substr(trim(strip_tags((string) ($in['note'] ?? ''))), 0, 255),
|
|
'invoice' => !empty($in['invoice']),
|
|
'category_id' => max(0, intval($in['category'] ?? 0)),
|
|
];
|
|
if ($hours === null || $f['hours'] <= 0 || $f['hours'] >= 10000) {
|
|
return [$f, 'Give the hours - more than none, under 10,000.'];
|
|
}
|
|
if (trim((string) ($in['price'] ?? '')) !== '' && ($price === null || $price < 0 || $price >= 1e9)) {
|
|
return [$f, 'The price is an amount, such as 2,400.00 - or leave it empty.'];
|
|
}
|
|
if ($f['bought'] === false || $f['bought'] === '') {
|
|
return [$f, 'Give the day they were bought.'];
|
|
}
|
|
if ($f['expires'] === false || ($f['expires'] !== '' && $f['expires'] < $f['bought'])) {
|
|
return [$f, 'The day they run out is a day after they were bought - or leave it empty for never.'];
|
|
}
|
|
if ($f['invoice'] && !$f['price']) {
|
|
return [$f, 'Give a price to invoice them.'];
|
|
}
|
|
return [$f, ''];
|
|
}
|
|
|
|
/*
|
|
* Add a block of hours to a contract - and, with $f['invoice'], a draft
|
|
* invoice for it, one line. [block id, invoice id (0 for none), invoice
|
|
* number].
|
|
*/
|
|
function contractHoursAdd(array $row, array $f, $user_id, $by) {
|
|
global $mysqli;
|
|
$contract_id = intval($row['contract_id']);
|
|
$invoice_id = 0;
|
|
$number = '';
|
|
if ($f['invoice']) {
|
|
$invoice_id = billingInvoiceCreate(intval($row['contract_client_id']), date('Y-m-d'), $f['category_id'] ?? 0, 'Prepaid hours - ' . $row['contract_name'], $by);
|
|
if ($invoice_id) {
|
|
billingItemSave('invoice', $invoice_id, ['name' => 'Prepaid support hours', 'description' => contractHoursWords($f['hours']) . ' for ' . $row['contract_name']
|
|
. ($f['expires'] !== '' ? ', good until ' . date('M j, Y', strtotime($f['expires'])) : ''), 'qty' => 1, 'price' => $f['price'], 'tax_id' => taxChoice(TAX_USUAL, intval($row['contract_client_id']), 'time')]);
|
|
billingRecalc('invoice', $invoice_id);
|
|
$number = billingNumber('invoice', billingRow('invoice', $invoice_id, false));
|
|
}
|
|
}
|
|
mysqli_query($mysqli, "INSERT INTO contract_blocks SET contract_block_contract_id = $contract_id, contract_block_hours = " . sprintf('%.2f', $f['hours']) . ",
|
|
contract_block_price = " . ($f['price'] === null ? 'NULL' : sprintf('%.2f', $f['price'])) . ", contract_block_bought = '" . escapeSql($f['bought']) . "',
|
|
contract_block_expires = " . ($f['expires'] !== '' ? "'" . escapeSql($f['expires']) . "'" : 'NULL') . ", contract_block_invoice_id = $invoice_id,
|
|
contract_block_note = " . ($f['note'] !== '' ? "'" . escapeSql($f['note']) . "'" : 'NULL') . ", contract_block_created_by = " . intval($user_id));
|
|
$block_id = intval(mysqli_insert_id($mysqli));
|
|
contractHoursAlertReset($contract_id);
|
|
return [$block_id, $invoice_id, $number];
|
|
}
|
|
|
|
/** Delete a block nothing was drawn from. Whether it went. */
|
|
function contractHoursBlockDelete($block_id) {
|
|
global $mysqli;
|
|
$block_id = intval($block_id);
|
|
mysqli_query($mysqli, "DELETE FROM contract_blocks WHERE contract_block_id = $block_id
|
|
AND NOT EXISTS (SELECT 1 FROM contract_block_uses WHERE contract_block_use_block_id = $block_id AND contract_block_use_hours > 0)");
|
|
return mysqli_affected_rows($mysqli) === 1;
|
|
}
|
|
|
|
/** A block with its contract - or null. */
|
|
function contractBlockRow($block_id) {
|
|
global $mysqli;
|
|
if (!contractHoursReady()) {
|
|
return null;
|
|
}
|
|
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contract_blocks.*, contract_id, contract_name, contract_client_id,
|
|
(SELECT COUNT(*) FROM contract_block_uses WHERE contract_block_use_block_id = contract_block_id AND contract_block_use_hours > 0) AS uses
|
|
FROM contract_blocks JOIN contracts ON contract_block_contract_id = contract_id WHERE contract_block_id = " . intval($block_id))) ?: null;
|
|
}
|
|
|
|
/*
|
|
* The night's prepaid hours, from contractsNightly(): each month's included
|
|
* hours made, and every client with hours left has its done tickets drawn.
|
|
* How many tickets were drawn.
|
|
*/
|
|
function contractHoursNightly($today = null) {
|
|
global $mysqli;
|
|
if (!contractHoursReady()) {
|
|
return 0;
|
|
}
|
|
$sql = mysqli_query($mysqli, "SELECT * FROM contracts WHERE " . contractHoursLiveSql($today) . " AND contract_archived_at IS NULL AND contract_monthly_hours > 0");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
contractHoursEnsureMonthly($row, $today);
|
|
}
|
|
$drawn = 0;
|
|
$sql = mysqli_query($mysqli, "SELECT DISTINCT contract_client_id FROM contracts
|
|
JOIN contract_blocks ON contract_block_contract_id = contract_id
|
|
WHERE " . contractHoursLiveSql($today) . " AND contract_archived_at IS NULL");
|
|
while ($row = mysqli_fetch_row($sql)) {
|
|
$drawn += count(contractHoursDraw(intval($row[0]))['drawn']);
|
|
}
|
|
return $drawn;
|
|
}
|