mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 15:07:24 +00:00
1108 lines
56 KiB
PHP
1108 lines
56 KiB
PHP
<?php
|
|
|
|
/*
|
|
* ITFlow - trips: the driving an MSP does, for clients and for itself.
|
|
*
|
|
* A trip is a date, where from and where to, the miles (stored as the whole
|
|
* trip - one way doubled for a round trip), what it was for and who drove.
|
|
* For a client it can name the client's site it went to (trip_location_id)
|
|
* and the ticket it was for (trip_ticket_id), so a ticket and a site show
|
|
* their trips. Odometer readings are optional; when both are given they
|
|
* make the miles.
|
|
*
|
|
* Who sees what: agents with Financial access see every trip in their
|
|
* client scope; anyone else sees the trips they drove or logged. Anyone can
|
|
* log a trip for themselves and change or delete their own; logging one for
|
|
* someone else, or changing theirs, needs Financial write access, and
|
|
* deleting theirs full access.
|
|
*
|
|
* A trip is money two ways (Mileage money, below): driven in the agent's own
|
|
* vehicle it is paid back at a rate for the distance, and for a client it
|
|
* can be billed to them. The distance is in miles or kilometres, as set in
|
|
* Admin > Settings > Invoice - trip_miles holds whichever it is.
|
|
*/
|
|
|
|
/** The distance a trip is measured in - "miles" ("mi" short) or "kilometers" ("km"). */
|
|
function tripUnit($short = false) {
|
|
if (tripRules()['unit'] === 'km') {
|
|
return $short ? 'km' : 'kilometers';
|
|
}
|
|
return $short ? 'mi' : 'miles';
|
|
}
|
|
|
|
/** One of them, for a rate - "mile" or "km". */
|
|
function tripUnitOne() {
|
|
return tripRules()['unit'] === 'km' ? 'km' : 'mile';
|
|
}
|
|
|
|
/** "12.4 mi" */
|
|
function tripMilesText($miles) {
|
|
return number_format(floatval($miles), 1) . ' ' . tripUnit(true);
|
|
}
|
|
|
|
/** May this agent see every trip (in their client scope), not just their own? */
|
|
function tripCanSeeAll() {
|
|
return lookupUserPermission('module_financial') >= 1;
|
|
}
|
|
|
|
/** May this agent log trips for others and change theirs? */
|
|
function tripCanManage() {
|
|
return lookupUserPermission('module_financial') >= 2;
|
|
}
|
|
|
|
/** Is it this agent's own trip - they drove it or logged it? */
|
|
function tripIsMine(array $row) {
|
|
global $session_user_id;
|
|
return intval($row['trip_user_id']) === intval($session_user_id) || intval($row['trip_created_by'] ?? 0) === intval($session_user_id);
|
|
}
|
|
|
|
/** May this agent change this trip? */
|
|
function tripCanEdit(array $row) {
|
|
return tripIsMine($row) || tripCanManage();
|
|
}
|
|
|
|
/** May this agent delete this trip? */
|
|
function tripCanDelete(array $row) {
|
|
return tripIsMine($row) || lookupUserPermission('module_financial') >= 3;
|
|
}
|
|
|
|
/** The trips this agent may see, as SQL - their client scope, and their own unless they see all. */
|
|
function tripScopeSql() {
|
|
global $session_user_id;
|
|
$sql = clientScopeSql('trips.trip_client_id');
|
|
if (!tripCanSeeAll()) {
|
|
$me = intval($session_user_id);
|
|
$sql .= " AND (trips.trip_user_id = $me OR trips.trip_created_by = $me)";
|
|
}
|
|
return $sql;
|
|
}
|
|
|
|
/** The joins the list, export and quick look read through. */
|
|
function tripJoinsSql() {
|
|
return "LEFT JOIN clients ON client_id = trips.trip_client_id
|
|
LEFT JOIN users AS driver ON driver.user_id = trips.trip_user_id
|
|
LEFT JOIN locations ON location_id = trips.trip_location_id
|
|
LEFT JOIN tickets ON ticket_id = trips.trip_ticket_id";
|
|
}
|
|
|
|
/** One trip with its client, driver, site, ticket and who logged it - raw, or null. Scoped to what this agent may see. */
|
|
function tripRow($trip_id, $scoped = true) {
|
|
global $mysqli;
|
|
$trip_id = intval($trip_id);
|
|
if (!$trip_id) {
|
|
return null;
|
|
}
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT trips.*, client_name, client_abbreviation, driver.user_name AS driver_name, driver.user_avatar AS driver_avatar,
|
|
creator.user_name AS created_by_name, location_name, location_address, location_city, location_state, location_zip, location_archived_at,
|
|
ticket_prefix, ticket_number, ticket_subject, ticket_client_id, ticket_resolved_at, ticket_closed_at, ticket_onsite, ticket_billable,
|
|
claim.expense_id AS claim_expense_id, claim.expense_date AS claim_date, claim.expense_amount AS claim_amount, claim.expense_currency_code AS claim_currency,
|
|
reimbursement_date, invoice_prefix, invoice_number, invoice_status, item_subtotal AS billed_subtotal
|
|
FROM trips " . tripJoinsSql() . "
|
|
LEFT JOIN users AS creator ON creator.user_id = trips.trip_created_by
|
|
LEFT JOIN expenses AS claim ON trips.trip_expense_id > 0 AND claim.expense_id = trips.trip_expense_id
|
|
LEFT JOIN reimbursements ON reimbursement_id = claim.expense_reimbursement_id
|
|
LEFT JOIN invoices ON trips.trip_invoice_id > 0 AND invoice_id = trips.trip_invoice_id
|
|
LEFT JOIN invoice_items ON trips.trip_invoice_item_id > 0 AND item_id = trips.trip_invoice_item_id
|
|
WHERE trips.trip_id = $trip_id AND trips.trip_archived_at IS NULL " . ($scoped ? tripScopeSql() : '') . "
|
|
LIMIT 1"));
|
|
return $row ?: null;
|
|
}
|
|
|
|
/** The miles one way - the whole trip halved for a round trip. */
|
|
function tripOneWay(array $row) {
|
|
$miles = floatval($row['trip_miles']);
|
|
return intval($row['round_trip']) ? round($miles / 2, 1) : $miles;
|
|
}
|
|
|
|
/** A trip in a sentence - "Office to Ravenwood Dental on 2026-10-14 (24.6 mi)". Raw. */
|
|
function tripLabel(array $row) {
|
|
return $row['trip_source'] . ' to ' . $row['trip_destination'] . ' on ' . $row['trip_date'] . ' (' . tripMilesText($row['trip_miles']) . ')';
|
|
}
|
|
|
|
/** A directions link for the trip - to the address, from where it started. */
|
|
function tripMapUrl(array $row) {
|
|
return 'https://www.google.com/maps/dir/?api=1&origin=' . rawurlencode((string) $row['trip_source']) . '&destination=' . rawurlencode((string) $row['trip_destination']);
|
|
}
|
|
|
|
/** Where we start from - the company's address, as one line, or ''. */
|
|
function tripOfficeAddress() {
|
|
global $mysqli;
|
|
static $address = null;
|
|
if ($address === null) {
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT company_address, company_city, company_state, company_zip FROM companies WHERE company_id = 1")) ?: [];
|
|
$address = formatAddress($row['company_address'] ?? '', $row['company_city'] ?? '', $row['company_state'] ?? '', $row['company_zip'] ?? '', '', ' ');
|
|
}
|
|
return $address;
|
|
}
|
|
|
|
/** A client site's address as one line - its name when it has no address. */
|
|
function tripLocationAddress(array $row) {
|
|
$address = formatAddress($row['location_address'] ?? '', $row['location_city'] ?? '', $row['location_state'] ?? '', $row['location_zip'] ?? '', '', ' ');
|
|
return $address !== '' ? $address : (string) ($row['location_name'] ?? '');
|
|
}
|
|
|
|
/** The ways the list can be cut - ?show= */
|
|
function tripShows() {
|
|
global $session_user_id;
|
|
$me = intval($session_user_id);
|
|
return [
|
|
'' => ['label' => 'All', 'where' => ''],
|
|
'mine' => ['label' => 'Mine', 'icon' => 'fa-user', 'where' => "trips.trip_user_id = $me", 'title' => 'Trips you drove'],
|
|
'client' => ['label' => 'For a client', 'icon' => 'fa-building', 'where' => 'trips.trip_client_id > 0', 'title' => 'Driving for a client'],
|
|
'ticket' => ['label' => 'For a ticket', 'icon' => 'fa-life-ring', 'where' => 'trips.trip_ticket_id > 0', 'title' => 'Onsite work on a ticket'],
|
|
'own' => ['label' => 'Our own', 'icon' => 'fa-store', 'where' => 'trips.trip_client_id = 0', 'title' => "Errands for the business - the bank, the post office, a supplier"],
|
|
] + (tripPayBackOn() ? [
|
|
'to_pay' => ['label' => 'To pay back', 'icon' => 'fa-hand-holding-usd', 'where' => tripClaimSql(), 'title' => 'In their own vehicle, not paid back for yet'],
|
|
] : []) + (tripBillingOn() ? [
|
|
'to_bill' => ['label' => 'To bill', 'icon' => 'fa-file-invoice-dollar', 'where' => tripUnbilledSql(), 'title' => 'Billable to the client, on no invoice yet'],
|
|
] : []);
|
|
}
|
|
|
|
/**
|
|
* The WHERE for a set of filters, as the list and the export take them: q,
|
|
* driver, client, location_id, ticket_id, show, dtf and dtt. Starts with what
|
|
* this agent may see. $summary gets what each is, by name, for the export.
|
|
*/
|
|
function tripFiltersSql(array $src, &$summary = []) {
|
|
$summary = [];
|
|
$sql = "trips.trip_archived_at IS NULL " . tripScopeSql();
|
|
$q = trim((string) ($src['q'] ?? ''));
|
|
if ($q !== '') {
|
|
$like = escapeSql($q);
|
|
$sql .= " AND (trips.trip_purpose LIKE '%$like%' OR trips.trip_source LIKE '%$like%' OR trips.trip_destination LIKE '%$like%' OR client_name LIKE '%$like%'
|
|
OR driver.user_name LIKE '%$like%' OR location_name LIKE '%$like%' OR CONCAT(ticket_prefix, ticket_number) LIKE '%$like%')";
|
|
$summary['Search'] = $q;
|
|
}
|
|
foreach (['driver' => ['trips.trip_user_id', 'users', 'user_name', 'Driver'], 'client' => ['trips.trip_client_id', 'clients', 'client_name', 'Client'],
|
|
'location_id' => ['trips.trip_location_id', 'locations', 'location_name', 'Site'], 'ticket_id' => ['trips.trip_ticket_id', 'tickets', 'ticket_subject', 'Ticket'],
|
|
'expense_id' => ['trips.trip_expense_id', 'expenses', 'expense_date', 'Mileage paid on'], 'invoice_id' => ['trips.trip_invoice_id', 'invoices', 'invoice_number', 'Billed on']] as $key => $f) {
|
|
$id = intval($src[$key] ?? 0);
|
|
if ($id) {
|
|
$sql .= " AND {$f[0]} = $id";
|
|
$summary[$f[3]] = (string) getFieldById($f[1], $id, $f[2]);
|
|
}
|
|
}
|
|
$shows = tripShows();
|
|
$show = (string) ($src['show'] ?? '');
|
|
if (isset($shows[$show]) && $shows[$show]['where']) {
|
|
$sql .= " AND {$shows[$show]['where']}";
|
|
$summary['Showing'] = $shows[$show]['label'];
|
|
}
|
|
$dtf = (string) ($src['dtf'] ?? '');
|
|
$dtt = (string) ($src['dtt'] ?? '');
|
|
$dtf = preg_match('/^\d{4}-\d{2}-\d{2}$/', $dtf) ? $dtf : '1970-01-01';
|
|
$dtt = preg_match('/^\d{4}-\d{2}-\d{2}$/', $dtt) ? $dtt : '2099-12-31';
|
|
if ($dtf !== '1970-01-01' || $dtt !== '2099-12-31') {
|
|
$sql .= " AND trips.trip_date BETWEEN '$dtf' AND '$dtt'";
|
|
$range = formatExportDateRange($dtf, $dtt);
|
|
if ($range) {
|
|
$summary['Dated'] = $range;
|
|
}
|
|
}
|
|
return $sql;
|
|
}
|
|
|
|
/**
|
|
* The driving along the top of the list, in what this agent may see: miles
|
|
* this month against the same days of last month, the year so far, and the
|
|
* agent's own this month.
|
|
*/
|
|
function tripStats() {
|
|
global $mysqli, $session_user_id;
|
|
$scope = "trips.trip_archived_at IS NULL " . tripScopeSql();
|
|
$month_start = date('Y-m-01');
|
|
$today = date('Y-m-d');
|
|
$last_start = date('Y-m-01', strtotime('first day of last month'));
|
|
$last_to_date = date('Y-m-d', min(strtotime(date('Y-m-t', strtotime($last_start))), strtotime($last_start . ' +' . (intval(date('j')) - 1) . ' days')));
|
|
$year_start = date('Y-01-01');
|
|
$me = intval($session_user_id);
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT
|
|
COALESCE(SUM(CASE WHEN trip_date BETWEEN '$month_start' AND '$today' THEN trip_miles END), 0) AS month_miles,
|
|
COALESCE(SUM(trip_date BETWEEN '$month_start' AND '$today'), 0) AS month_trips,
|
|
COALESCE(SUM(CASE WHEN trip_date BETWEEN '$last_start' AND '$last_to_date' THEN trip_miles END), 0) AS last_to_date,
|
|
COALESCE(SUM(CASE WHEN trip_date BETWEEN '$year_start' AND '$today' THEN trip_miles END), 0) AS year_miles,
|
|
COALESCE(SUM(trip_date BETWEEN '$year_start' AND '$today'), 0) AS year_trips,
|
|
COALESCE(SUM(CASE WHEN trip_date BETWEEN '$year_start' AND '$today' AND trip_client_id > 0 THEN trip_miles END), 0) AS year_client_miles,
|
|
COALESCE(SUM(CASE WHEN trip_date BETWEEN '$month_start' AND '$today' AND trip_user_id = $me THEN trip_miles END), 0) AS my_month_miles,
|
|
COALESCE(SUM(trip_date BETWEEN '$month_start' AND '$today' AND trip_user_id = $me), 0) AS my_month_trips
|
|
FROM trips LEFT JOIN clients ON client_id = trip_client_id WHERE $scope"));
|
|
$row = array_map('floatval', $row ?: []);
|
|
$row['month_start'] = $month_start;
|
|
$row['last_start'] = $last_start;
|
|
$row['year_start'] = $year_start;
|
|
$row['change'] = $row['last_to_date'] > 0 ? round(($row['month_miles'] - $row['last_to_date']) / $row['last_to_date'] * 100) : null;
|
|
return $row;
|
|
}
|
|
|
|
/** Who can drive - active agents, and $keep_id - id => [name, avatar]. */
|
|
function tripDrivers($keep_id = 0) {
|
|
global $mysqli;
|
|
$keep_id = intval($keep_id);
|
|
$drivers = [];
|
|
$sql = mysqli_query($mysqli, "SELECT user_id, user_name, user_avatar FROM users WHERE (user_type = 1 AND user_archived_at IS NULL) OR user_id = $keep_id ORDER BY user_name ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$drivers[intval($row['user_id'])] = $row;
|
|
}
|
|
return $drivers;
|
|
}
|
|
|
|
/** The sites of the clients this agent may reach - client_id => [[id, name, address], ...], archived ones left out but $keep_id. */
|
|
function tripClientSites($client_id = 0, $keep_id = 0) {
|
|
global $mysqli;
|
|
$client_id = intval($client_id);
|
|
$keep_id = intval($keep_id);
|
|
$sites = [];
|
|
$sql = mysqli_query($mysqli, "SELECT location_id, location_client_id, location_name, location_address, location_city, location_state, location_zip, location_primary
|
|
FROM locations JOIN clients ON client_id = location_client_id
|
|
WHERE (location_archived_at IS NULL OR location_id = $keep_id) AND client_archived_at IS NULL " . ($client_id ? "AND location_client_id = $client_id " : '') . clientScopeSql('location_client_id') . "
|
|
ORDER BY location_primary DESC, location_name ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$sites[intval($row['location_client_id'])][] = [intval($row['location_id']), $row['location_name'], tripLocationAddress($row)];
|
|
}
|
|
return $sites;
|
|
}
|
|
|
|
/**
|
|
* The tickets a trip can be for - each client's open ones and those done in
|
|
* the last 60 days, newest first, up to 25 a client, plus $keep_id - client_id
|
|
* => [[id, "TCK-12 - Subject", location_id, open, billable], ...]. Only for
|
|
* agents who can see tickets.
|
|
*/
|
|
function tripClientTickets($client_id = 0, $keep_id = 0) {
|
|
global $mysqli;
|
|
if (lookupUserPermission('module_support') < 1) {
|
|
return [];
|
|
}
|
|
$client_id = intval($client_id);
|
|
$keep_id = intval($keep_id);
|
|
$tickets = [];
|
|
$sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_client_id, ticket_location_id, ticket_resolved_at, ticket_closed_at, ticket_billable
|
|
FROM tickets
|
|
WHERE ticket_client_id > 0 AND ticket_archived_at IS NULL " . ($client_id ? "AND ticket_client_id = $client_id " : '') . "
|
|
AND (" . TICKET_OPEN_SQL . " OR COALESCE(ticket_resolved_at, ticket_closed_at) >= CURDATE() - INTERVAL 60 DAY OR ticket_id = $keep_id) " . clientScopeSql('ticket_client_id') . "
|
|
ORDER BY ticket_client_id, (ticket_resolved_at IS NULL AND ticket_closed_at IS NULL) DESC, ticket_id DESC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$cid = intval($row['ticket_client_id']);
|
|
if (count($tickets[$cid] ?? []) >= 25 && intval($row['ticket_id']) !== $keep_id) {
|
|
continue;
|
|
}
|
|
$tickets[$cid][] = [intval($row['ticket_id']), ticketReference($row) . ' - ' . mb_substr((string) $row['ticket_subject'], 0, 80), intval($row['ticket_location_id']), ticketIsOpen($row) ? 1 : 0, intval($row['ticket_billable'])];
|
|
}
|
|
return $tickets;
|
|
}
|
|
|
|
/**
|
|
* Routes driven before - "from|to" (lowercase) => miles one way, the last
|
|
* time each was driven, from the trips this agent may see. Both directions,
|
|
* so the way back is known too. For the form to fill in.
|
|
*/
|
|
function tripKnownRoutes($limit = 400) {
|
|
global $mysqli;
|
|
$routes = [];
|
|
$sql = mysqli_query($mysqli, "SELECT trip_source, trip_destination, trip_miles, round_trip FROM trips LEFT JOIN clients ON client_id = trip_client_id
|
|
WHERE trips.trip_archived_at IS NULL AND trip_miles > 0 " . tripScopeSql() . "
|
|
ORDER BY trip_date DESC, trip_id DESC LIMIT " . intval($limit * 3));
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$from = mb_strtolower(trim((string) $row['trip_source']));
|
|
$to = mb_strtolower(trim((string) $row['trip_destination']));
|
|
if ($from === '' || $to === '') {
|
|
continue;
|
|
}
|
|
$one_way = tripOneWay($row);
|
|
$routes += ["$from|$to" => $one_way, "$to|$from" => $one_way];
|
|
if (count($routes) >= $limit * 2) {
|
|
break;
|
|
}
|
|
}
|
|
return $routes;
|
|
}
|
|
|
|
/** Where this agent has started from lately, most used first - for the From box to offer, with the office first. */
|
|
function tripStartPlaces($limit = 6) {
|
|
global $mysqli, $session_user_id;
|
|
$places = [];
|
|
$office = tripOfficeAddress();
|
|
if ($office !== '') {
|
|
$places[] = $office;
|
|
}
|
|
$sql = mysqli_query($mysqli, "SELECT trip_source, COUNT(*) AS n FROM trips WHERE trip_user_id = " . intval($session_user_id) . " AND trip_archived_at IS NULL
|
|
GROUP BY trip_source ORDER BY n DESC LIMIT " . intval($limit));
|
|
while ($row = mysqli_fetch_row($sql)) {
|
|
if (!in_array($row[0], $places, true) && trim((string) $row[0]) !== '') {
|
|
$places[] = $row[0];
|
|
}
|
|
}
|
|
return $places;
|
|
}
|
|
|
|
/** Where this agent's last trip started - for a new one to start from. */
|
|
function tripLastStart() {
|
|
global $mysqli, $session_user_id;
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT trip_source FROM trips WHERE trip_user_id = " . intval($session_user_id) . " AND trip_archived_at IS NULL ORDER BY trip_date DESC, trip_id DESC LIMIT 1"));
|
|
return $row ? (string) $row[0] : '';
|
|
}
|
|
|
|
/**
|
|
* The one path that writes a trip from a form - New, Edit, Copy and Return.
|
|
* $in: date, source, destination, miles (one way), round_trip, odometer_start,
|
|
* odometer_end, purpose, driver, client_id, location_id, ticket_id,
|
|
* own_vehicle and billable. A ticket brings its client when none is picked;
|
|
* a site and ticket must be the client's. Once paid back for (on a mileage
|
|
* expense) the distance, driver and vehicle stay as they were paid; once
|
|
* billed, the distance, client and billable stay as they were billed - the
|
|
* form shows them locked. Returns the trip id, or 0 with $error.
|
|
*/
|
|
function tripSave(array $in, $trip_id = 0, &$error = '') {
|
|
global $mysqli, $session_user_id;
|
|
$error = '';
|
|
$trip_id = intval($trip_id);
|
|
$existing = $trip_id ? tripRow($trip_id) : null;
|
|
if ($trip_id && !$existing) {
|
|
$error = 'That trip no longer exists, or is not one you can see';
|
|
return 0;
|
|
}
|
|
if ($existing && !tripCanEdit($existing)) {
|
|
$error = "That is someone else's trip - changing it needs write access to Financial";
|
|
return 0;
|
|
}
|
|
|
|
$date = trim((string) ($in['date'] ?? ''));
|
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !checkdate(intval(substr($date, 5, 2)), intval(substr($date, 8, 2)), intval(substr($date, 0, 4)))) {
|
|
$error = 'Enter the date of the trip';
|
|
return 0;
|
|
}
|
|
$source = mb_substr(trim(preg_replace('/\s+/u', ' ', (string) ($in['source'] ?? ''))), 0, 200);
|
|
$destination = mb_substr(trim(preg_replace('/\s+/u', ' ', (string) ($in['destination'] ?? ''))), 0, 200);
|
|
if ($source === '' || $destination === '') {
|
|
$error = 'Enter where the trip started and where it went';
|
|
return 0;
|
|
}
|
|
$purpose = mb_substr(trim((string) ($in['purpose'] ?? '')), 0, 200);
|
|
if ($purpose === '') {
|
|
$error = 'Enter what the trip was for';
|
|
return 0;
|
|
}
|
|
|
|
// Paid back for, or billed - those keep what they were paid or billed on
|
|
$claimed = $existing && intval($existing['trip_expense_id']) > 0;
|
|
$billed = $existing && intval($existing['trip_invoice_id']) > 0;
|
|
|
|
// The miles: from the odometer when both readings are there, else one way, doubled for a round trip
|
|
$round = !empty($in['round_trip']) ? 1 : 0;
|
|
if ($claimed || $billed) {
|
|
$round = intval($existing['round_trip']);
|
|
$in['odometer_start'] = $existing['trip_start_odometer'] ?? '';
|
|
$in['odometer_end'] = $existing['trip_end_odometer'] ?? '';
|
|
}
|
|
$odo_start_text = trim(str_replace(',', '', (string) ($in['odometer_start'] ?? '')));
|
|
$odo_end_text = trim(str_replace(',', '', (string) ($in['odometer_end'] ?? '')));
|
|
if (($odo_start_text !== '' && !ctype_digit($odo_start_text)) || ($odo_end_text !== '' && !ctype_digit($odo_end_text))) {
|
|
$error = 'The odometer readings are whole numbers';
|
|
return 0;
|
|
}
|
|
$odo_start = $odo_start_text === '' ? null : intval($odo_start_text);
|
|
$odo_end = $odo_end_text === '' ? null : intval($odo_end_text);
|
|
if ($claimed || $billed) {
|
|
$miles = round(floatval($existing['trip_miles']), 1);
|
|
} elseif ($odo_start !== null && $odo_end !== null) {
|
|
if ($odo_end < $odo_start) {
|
|
$error = 'The odometer at the end is lower than at the start';
|
|
return 0;
|
|
}
|
|
$miles = round($odo_end - $odo_start, 1);
|
|
} else {
|
|
$miles_text = trim(str_replace([',', ' '], '', (string) ($in['miles'] ?? '')));
|
|
if (!is_numeric($miles_text) || floatval($miles_text) < 0 || floatval($miles_text) >= 100000) {
|
|
$error = 'Enter the ' . tripUnit() . ' one way';
|
|
return 0;
|
|
}
|
|
$miles = round(round(floatval($miles_text), 1) * ($round ? 2 : 1), 1);
|
|
}
|
|
|
|
// The client, the ticket it was for and the site it went to
|
|
$client_id = $billed ? intval($existing['trip_client_id']) : intval($in['client_id'] ?? 0);
|
|
$ticket_id = intval($in['ticket_id'] ?? 0);
|
|
if ($ticket_id) {
|
|
$ticket = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_id, ticket_client_id FROM tickets WHERE ticket_id = $ticket_id AND ticket_client_id > 0 " . clientScopeSql('ticket_client_id')));
|
|
if (!$ticket || ($client_id && intval($ticket['ticket_client_id']) !== $client_id) || (lookupUserPermission('module_support') < 1 && (!$existing || intval($existing['trip_ticket_id']) !== $ticket_id))) {
|
|
$error = "That ticket isn't one of the client's";
|
|
return 0;
|
|
}
|
|
$client_id = intval($ticket['ticket_client_id']);
|
|
}
|
|
if ($client_id && !mysqli_num_rows(mysqli_query($mysqli, "SELECT client_id FROM clients WHERE client_id = $client_id " . clientScopeSql('client_id')))) {
|
|
$error = "That client isn't one you can log trips for";
|
|
return 0;
|
|
}
|
|
$location_id = $client_id ? intval($in['location_id'] ?? 0) : 0;
|
|
if ($location_id && !mysqli_num_rows(mysqli_query($mysqli, "SELECT location_id FROM locations WHERE location_id = $location_id AND location_client_id = $client_id"))) {
|
|
$error = "That site isn't one of the client's";
|
|
return 0;
|
|
}
|
|
|
|
// Who drove - only themselves, unless they may log trips for others
|
|
$driver = $claimed ? intval($existing['trip_user_id']) : intval($in['driver'] ?? 0);
|
|
$drivers = tripDrivers($existing ? $existing['trip_user_id'] : 0);
|
|
if (!isset($drivers[$driver])) {
|
|
$error = 'Pick who drove';
|
|
return 0;
|
|
}
|
|
if (!tripCanManage() && $driver !== intval($session_user_id) && !($existing && $driver === intval($existing['trip_user_id']))) {
|
|
$error = 'You can log your own trips - logging one for someone else needs write access to Financial';
|
|
return 0;
|
|
}
|
|
|
|
// Their own vehicle - paid back for the distance; billable to the client
|
|
$own_vehicle = $claimed ? 1 : (!empty($in['own_vehicle']) ? 1 : 0);
|
|
$billable = $billed ? 1 : ($client_id && !empty($in['billable']) ? 1 : 0);
|
|
|
|
$set = "trip_date = '$date', trip_source = '" . escapeSql($source) . "', trip_destination = '" . escapeSql($destination) . "', trip_miles = $miles, round_trip = $round,
|
|
trip_start_odometer = " . ($odo_start === null ? 'NULL' : $odo_start) . ", trip_end_odometer = " . ($odo_end === null ? 'NULL' : $odo_end) . ",
|
|
trip_purpose = '" . escapeSql($purpose) . "', trip_user_id = $driver, trip_client_id = $client_id, trip_location_id = $location_id, trip_ticket_id = $ticket_id,
|
|
trip_own_vehicle = $own_vehicle, trip_billable = $billable";
|
|
if ($existing) {
|
|
mysqli_query($mysqli, "UPDATE trips SET $set WHERE trip_id = $trip_id");
|
|
return $trip_id;
|
|
}
|
|
mysqli_query($mysqli, "INSERT INTO trips SET $set, trip_created_by = " . intval($session_user_id));
|
|
return mysqli_insert_id($mysqli);
|
|
}
|
|
|
|
/** Delete a trip this agent may delete - the row it deleted, or null with $error. */
|
|
function tripDelete($trip_id, &$error = '') {
|
|
global $mysqli;
|
|
$error = '';
|
|
$row = tripRow($trip_id);
|
|
if (!$row) {
|
|
$error = 'That trip no longer exists, or is not one you can see';
|
|
return null;
|
|
}
|
|
if (!tripCanDelete($row)) {
|
|
$error = "That is someone else's trip - deleting it needs full access to Financial";
|
|
return null;
|
|
}
|
|
if ($row['claim_expense_id'] !== null) {
|
|
$error = 'That trip is paid back for on a mileage expense - delete that expense first';
|
|
return null;
|
|
}
|
|
if ($row['invoice_number'] !== null) {
|
|
$error = 'That trip is billed on ' . $row['invoice_prefix'] . $row['invoice_number'] . ' - take it off the invoice first';
|
|
return null;
|
|
}
|
|
mysqli_query($mysqli, "DELETE FROM trips WHERE trip_id = " . intval($row['trip_id']));
|
|
return $row;
|
|
}
|
|
|
|
/** The trips of a ticket, a site or a client - newest first, scoped. For the cards that list them. */
|
|
function tripsFor($column, $id, $limit = 10) {
|
|
global $mysqli;
|
|
if (!in_array($column, ['trip_ticket_id', 'trip_location_id', 'trip_client_id', 'trip_expense_id', 'trip_invoice_id'], true)) {
|
|
return [];
|
|
}
|
|
$id = intval($id);
|
|
$rows = [];
|
|
$sql = mysqli_query($mysqli, "SELECT trips.*, driver.user_name AS driver_name, driver.user_avatar AS driver_avatar, client_name, location_name, ticket_prefix, ticket_number, ticket_subject
|
|
FROM trips " . tripJoinsSql() . "
|
|
WHERE trips.$column = $id AND trips.trip_archived_at IS NULL " . tripScopeSql() . "
|
|
ORDER BY trip_date DESC, trip_id DESC LIMIT " . intval($limit));
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$rows[] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/** How many trips and miles a ticket, site or client has - [count, miles], scoped. */
|
|
function tripTotalsFor($column, $id) {
|
|
global $mysqli;
|
|
if (!in_array($column, ['trip_ticket_id', 'trip_location_id', 'trip_client_id', 'trip_expense_id', 'trip_invoice_id'], true)) {
|
|
return [0, 0.0];
|
|
}
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*), COALESCE(SUM(trip_miles), 0) FROM trips LEFT JOIN clients ON client_id = trip_client_id
|
|
WHERE trips.$column = " . intval($id) . " AND trips.trip_archived_at IS NULL " . tripScopeSql()));
|
|
return [intval($row[0]), floatval($row[1])];
|
|
}
|
|
|
|
/** A small table of trips - from tripsFor() - each opening its quick look. For a site's Trips tab. */
|
|
function tripTableHtml(array $rows) {
|
|
$html = '<div class="table-responsive"><table class="table table-hover align-middle mb-0 itflow-trip-table">'
|
|
. '<thead class="text-nowrap table-light"><tr><th>Date</th><th>Trip</th><th>Driver</th><th class="text-end">' . ucfirst(tripUnit()) . '</th></tr></thead><tbody>';
|
|
foreach ($rows as $row) {
|
|
$open = 'href="#" class="ajax-modal" data-modal-size="lg" data-modal-url="modals/trip/trip.php?id=' . intval($row['trip_id']) . '"';
|
|
$ticket = $row['ticket_number'] !== null && intval($row['trip_ticket_id']) ? '<span class="font-monospace text-body-secondary me-1">' . escapeHtml(ticketReference($row)) . '</span>' : '';
|
|
$html .= '<tr>'
|
|
. '<td class="text-nowrap"><a ' . $open . '>' . escapeHtml(date('M j, Y', strtotime($row['trip_date']))) . '</a></td>'
|
|
. '<td class="itflow-trip-route"><div class="text-truncate">' . escapeHtml($row['trip_source']) . ' → ' . escapeHtml($row['trip_destination']) . '</div>'
|
|
. '<div class="small text-body-secondary text-truncate">' . $ticket . escapeHtml($row['trip_purpose']) . '</div></td>'
|
|
. '<td class="text-nowrap">' . formatAssignee($row['trip_user_id'], $row['driver_name'] ?? '', $row['driver_avatar'] ?? '', false, 'No driver') . '</td>'
|
|
. '<td class="text-end text-nowrap">' . escapeHtml(tripMilesText($row['trip_miles']))
|
|
. (intval($row['round_trip']) ? '<i class="fas fa-sync-alt text-body-secondary ms-1" title="Round trip"></i>' : '') . '</td>'
|
|
. '</tr>';
|
|
}
|
|
return $html . '</tbody></table></div>';
|
|
}
|
|
|
|
/* --------------------------------------------------------- Mileage money */
|
|
|
|
/*
|
|
* Two ways a trip is money, both set in Admin > Settings > Invoice.
|
|
*
|
|
* Paying back: driven in the agent's own vehicle (trip_own_vehicle), the
|
|
* distance is paid back at the mileage rate. Pay Mileage turns a driver's
|
|
* trips into mileage expenses paid personally by them - one for each client
|
|
* and one for our own errands, so each client's costs include the driving -
|
|
* under the mileage category and vendor. The trips point at it
|
|
* (trip_expense_id). The expenses are then paid back with anything else the
|
|
* agent paid for (functions/expenses.php, Paid personally), there and then
|
|
* or later from Reimburse.
|
|
*
|
|
* Billing: a trip for a client can be billable (trip_billable). It waits in
|
|
* Unbilled Time beside the client's tickets and expenses, priced at the
|
|
* charge for a trip plus the rate for the distance beyond what the charge
|
|
* covers, and goes on an invoice as a line of its own (trip_invoice_id,
|
|
* trip_invoice_item_id) - taken off again the way a billed expense is.
|
|
*/
|
|
|
|
/**
|
|
* The mileage settings - unit (mi or km), rate (paid back a unit in their
|
|
* own vehicle), category_id and vendor_id (of the mileage expenses), and
|
|
* what a trip bills: charge (a trip), bill_rate (a unit), included (the
|
|
* distance the charge covers) and item_name (the invoice line).
|
|
*/
|
|
function tripRules() {
|
|
global $mysqli;
|
|
static $rules = null;
|
|
if ($rules !== null) {
|
|
return $rules;
|
|
}
|
|
try {
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_mileage_unit, config_mileage_rate, config_mileage_category_id, config_mileage_vendor_id,
|
|
config_trip_charge, config_trip_bill_rate, config_trip_included, config_trip_item_name FROM settings WHERE company_id = 1")) ?: [];
|
|
} catch (mysqli_sql_exception $e) {
|
|
$row = [];
|
|
}
|
|
$rules = [
|
|
'unit' => ($row['config_mileage_unit'] ?? 'mi') === 'km' ? 'km' : 'mi',
|
|
'rate' => max(0, floatval($row['config_mileage_rate'] ?? 0)),
|
|
'category_id' => intval($row['config_mileage_category_id'] ?? 0),
|
|
'vendor_id' => intval($row['config_mileage_vendor_id'] ?? 0),
|
|
'charge' => max(0, floatval($row['config_trip_charge'] ?? 0)),
|
|
'bill_rate' => max(0, floatval($row['config_trip_bill_rate'] ?? 0)),
|
|
'included' => max(0, floatval($row['config_trip_included'] ?? 0)),
|
|
'item_name' => trim((string) ($row['config_trip_item_name'] ?? '')) ?: 'Travel',
|
|
];
|
|
return $rules;
|
|
}
|
|
|
|
/** Are agents paid back for their own vehicle - is there a mileage rate? */
|
|
function tripPayBackOn() {
|
|
return tripRules()['rate'] > 0;
|
|
}
|
|
|
|
/** Do trips bill clients - is there a charge or a rate? */
|
|
function tripBillingOn() {
|
|
$rules = tripRules();
|
|
return $rules['charge'] > 0 || $rules['bill_rate'] > 0;
|
|
}
|
|
|
|
/** A rate typed in - up to four decimals, as 0.655 - or null when it isn't a number of zero or more. */
|
|
function tripRateInput($value) {
|
|
$value = trim(str_replace([',', ' ', '$', '€', '£'], '', (string) $value));
|
|
if ($value === '') {
|
|
return 0.0;
|
|
}
|
|
return is_numeric($value) && floatval($value) >= 0 && floatval($value) < 1000 ? round(floatval($value), 4) : null;
|
|
}
|
|
|
|
/** A rate as money - $0.70, or $0.655 when it has more than cents. */
|
|
function tripRateMoney($rate) {
|
|
$rate = round(floatval($rate), 4);
|
|
if (abs($rate - round($rate, 2)) < 0.00001) {
|
|
return billingMoney($rate);
|
|
}
|
|
$format = numfmt_create((string) (billingCompany()['company_locale'] ?? '') ?: 'en_US', NumberFormatter::CURRENCY);
|
|
numfmt_set_attribute($format, NumberFormatter::MIN_FRACTION_DIGITS, 2);
|
|
numfmt_set_attribute($format, NumberFormatter::MAX_FRACTION_DIGITS, 4);
|
|
global $session_company_currency;
|
|
return numfmt_format_currency($format, $rate, (string) (($session_company_currency ?? '') ?: (billingCompany()['company_currency'] ?? '') ?: 'USD'));
|
|
}
|
|
|
|
/** "$0.70 a mile" */
|
|
function tripRateText($rate) {
|
|
return tripRateMoney($rate) . ' a ' . tripUnitOne();
|
|
}
|
|
|
|
/** A distance without a trailing .0 - "20 mi", "12.5 km". */
|
|
function tripDistanceText($distance) {
|
|
return rtrim(rtrim(number_format(floatval($distance), 1, '.', ''), '0'), '.') . ' ' . tripUnit(true);
|
|
}
|
|
|
|
/** What a trip bills its client - the charge, and the rate for the distance beyond what the charge covers. */
|
|
function tripBillPrice(array $row) {
|
|
$rules = tripRules();
|
|
return round($rules['charge'] + $rules['bill_rate'] * max(0, floatval($row['trip_miles']) - $rules['included']), 2);
|
|
}
|
|
|
|
/** How a trip is billed, in words - "$45.00 a trip, covering the first 20 mi, then $0.85 a mile". '' when trips don't bill. */
|
|
function tripBillRuleText() {
|
|
$rules = tripRules();
|
|
$charge = $rules['charge'] > 0 ? billingMoney($rules['charge']) . ' a trip' : '';
|
|
$rate = $rules['bill_rate'] > 0 ? tripRateText($rules['bill_rate']) : '';
|
|
if ($charge !== '' && $rate !== '') {
|
|
return $rules['included'] > 0 ? "$charge, covering the first " . tripDistanceText($rules['included']) . ", then $rate" : "$charge plus $rate";
|
|
}
|
|
if ($rate !== '') {
|
|
return $rules['included'] > 0 ? "$rate beyond the first " . tripDistanceText($rules['included']) : $rate;
|
|
}
|
|
return $charge;
|
|
}
|
|
|
|
/** The expense category mileage would go under - the one set, else Mileage or Travel - or 0 when there is none yet. */
|
|
function tripMileageCategoryShown() {
|
|
global $mysqli;
|
|
$id = tripRules()['category_id'];
|
|
if ($id && mysqli_num_rows(mysqli_query($mysqli, "SELECT category_id FROM categories WHERE category_id = $id AND category_type = 'Expense' AND category_archived_at IS NULL"))) {
|
|
return $id;
|
|
}
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT category_id FROM categories WHERE category_type = 'Expense' AND category_archived_at IS NULL AND category_name IN ('Mileage', 'Travel')
|
|
ORDER BY category_name = 'Mileage' DESC, category_id ASC LIMIT 1"));
|
|
return $row ? intval($row[0]) : 0;
|
|
}
|
|
|
|
/** The expense category mileage goes under - as shown, else a new Mileage. Kept in the settings. */
|
|
function tripMileageCategory() {
|
|
global $mysqli;
|
|
$id = tripMileageCategoryShown();
|
|
if ($id === tripRules()['category_id'] && $id) {
|
|
return $id;
|
|
}
|
|
if (!$id) {
|
|
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Mileage', category_type = 'Expense', category_color = '#6f42c1'");
|
|
$id = mysqli_insert_id($mysqli);
|
|
}
|
|
mysqli_query($mysqli, "UPDATE settings SET config_mileage_category_id = $id WHERE company_id = 1");
|
|
return $id;
|
|
}
|
|
|
|
/** The vendor mileage is paid to - the one set, else one of ours named Mileage, else a new one. Kept in the settings. */
|
|
function tripMileageVendor() {
|
|
global $mysqli;
|
|
$id = tripRules()['vendor_id'];
|
|
if ($id && mysqli_num_rows(mysqli_query($mysqli, "SELECT vendor_id FROM vendors WHERE vendor_id = $id AND vendor_client_id = 0"))) {
|
|
return $id;
|
|
}
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT vendor_id FROM vendors WHERE vendor_client_id = 0 AND vendor_name = 'Mileage' ORDER BY vendor_archived_at IS NULL DESC, vendor_id ASC LIMIT 1"));
|
|
if ($row) {
|
|
$id = intval($row[0]);
|
|
} else {
|
|
mysqli_query($mysqli, "INSERT INTO vendors SET " . vendorFormSet(['name' => 'Mileage', 'description' => 'Agents paid back for driving their own vehicles']) . ", vendor_client_id = 0");
|
|
$id = mysqli_insert_id($mysqli);
|
|
}
|
|
mysqli_query($mysqli, "UPDATE settings SET config_mileage_vendor_id = $id WHERE company_id = 1");
|
|
return $id;
|
|
}
|
|
|
|
/* Paying back */
|
|
|
|
/** The SQL that makes a trip to pay back for: their own vehicle, a driver and a distance, on no mileage expense yet. */
|
|
function tripClaimSql() {
|
|
return 'trips.trip_own_vehicle = 1 AND trips.trip_expense_id = 0 AND trips.trip_miles > 0 AND trips.trip_user_id > 0 AND trips.trip_archived_at IS NULL';
|
|
}
|
|
|
|
/**
|
|
* Trips to pay back for - one driver's ($user_id) or everyone's the agent may
|
|
* see, optionally only $ids - by driver: user_id => [user_id, user_name,
|
|
* trips (id => row), count, miles, value (at the mileage rate), oldest (days)].
|
|
*/
|
|
function tripsToClaim($user_id = 0, array $ids = []) {
|
|
global $mysqli;
|
|
$user_id = intval($user_id);
|
|
$ids = array_filter(array_map('intval', $ids));
|
|
$rate = tripRules()['rate'];
|
|
$sql = mysqli_query($mysqli, "SELECT trips.*, client_name, location_name, ticket_prefix, ticket_number, ticket_subject, driver.user_name AS driver_name,
|
|
DATEDIFF(CURDATE(), trips.trip_date) AS waiting
|
|
FROM trips " . tripJoinsSql() . "
|
|
WHERE " . tripClaimSql() . ($user_id ? " AND trips.trip_user_id = $user_id" : '') . ($ids ? ' AND trips.trip_id IN (' . implode(',', $ids) . ')' : '') . ' ' . tripScopeSql() . "
|
|
ORDER BY driver.user_name ASC, trips.trip_date ASC, trips.trip_id ASC");
|
|
$drivers = [];
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$uid = intval($row['trip_user_id']);
|
|
if (!isset($drivers[$uid])) {
|
|
$drivers[$uid] = ['user_id' => $uid, 'user_name' => (string) $row['driver_name'], 'trips' => [], 'count' => 0, 'miles' => 0.0, 'value' => 0.0, 'oldest' => 0];
|
|
}
|
|
$drivers[$uid]['trips'][intval($row['trip_id'])] = $row;
|
|
$drivers[$uid]['count']++;
|
|
$drivers[$uid]['miles'] = round($drivers[$uid]['miles'] + floatval($row['trip_miles']), 1);
|
|
$drivers[$uid]['oldest'] = max($drivers[$uid]['oldest'], intval($row['waiting']));
|
|
}
|
|
foreach ($drivers as &$driver) {
|
|
$driver['value'] = round($driver['miles'] * $rate, 2);
|
|
}
|
|
unset($driver);
|
|
return $drivers;
|
|
}
|
|
|
|
/** How many trips are waiting to be paid back for, their distance and drivers, and what that comes to at the rate - for the list and the dashboard. */
|
|
function tripToClaimCount() {
|
|
global $mysqli;
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*), COALESCE(SUM(trip_miles), 0), COUNT(DISTINCT trip_user_id)
|
|
FROM trips LEFT JOIN clients ON client_id = trips.trip_client_id WHERE " . tripClaimSql() . ' ' . tripScopeSql()));
|
|
$miles = round(floatval($row[1]), 1);
|
|
return ['count' => intval($row[0]), 'miles' => $miles, 'agents' => intval($row[2]), 'value' => round($miles * tripRules()['rate'], 2)];
|
|
}
|
|
|
|
/**
|
|
* Pay a driver for their ticked trips in their own vehicle: a mileage
|
|
* expense paid personally by them for each client (and one for our own
|
|
* errands), at $opts['rate'] a unit, dated $opts['date']. The trips are
|
|
* claimed as each expense is made, so two agents can't pay one twice.
|
|
* $opts['by'] is who, for the log. Returns [expense_ids, count, miles,
|
|
* amount, rate, user_name, currency], or null with $error.
|
|
*/
|
|
function tripClaim($user_id, array $trip_ids, array $opts, &$error = '') {
|
|
global $mysqli, $session_user_id, $session_company_currency;
|
|
$error = '';
|
|
$user_id = intval($user_id);
|
|
$driver = $trip_ids ? (tripsToClaim($user_id, $trip_ids)[$user_id] ?? null) : null;
|
|
if (!$user_id || !$driver) {
|
|
$error = 'Nothing to pay - those trips are paid back for already, or not in their own vehicle.';
|
|
return null;
|
|
}
|
|
$rate = tripRateInput($opts['rate'] ?? '');
|
|
if ($rate === null || $rate <= 0) {
|
|
$error = 'Enter the rate a ' . tripUnitOne() . ', more than nothing';
|
|
return null;
|
|
}
|
|
$date = (string) ($opts['date'] ?? '');
|
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !checkdate(intval(substr($date, 5, 2)), intval(substr($date, 8, 2)), intval(substr($date, 0, 4)))) {
|
|
$error = 'Enter the date of the mileage expense';
|
|
return null;
|
|
}
|
|
$by = (string) ($opts['by'] ?? 'ITFlow');
|
|
$currency = (string) (($session_company_currency ?? '') ?: (billingCompany()['company_currency'] ?? '') ?: 'USD');
|
|
$category_id = tripMileageCategory();
|
|
$vendor_id = tripMileageVendor();
|
|
|
|
// A mileage expense for each client - so their costs include the driving
|
|
$groups = [];
|
|
foreach ($driver['trips'] as $trip_id => $trip) {
|
|
$groups[intval($trip['trip_client_id'])][] = $trip_id;
|
|
}
|
|
$expense_ids = [];
|
|
$count = 0;
|
|
$miles = 0.0;
|
|
$amount = 0.0;
|
|
foreach ($groups as $client_id => $ids) {
|
|
mysqli_query($mysqli, "INSERT INTO expenses SET expense_date = '$date', expense_amount = 0, expense_currency_code = '" . escapeSql($currency) . "',
|
|
expense_vendor_id = $vendor_id, expense_category_id = $category_id, expense_account_id = 0, expense_paid_by = $user_id, expense_client_id = $client_id,
|
|
expense_description = 'Mileage', expense_created_by = " . intval($session_user_id));
|
|
$expense_id = mysqli_insert_id($mysqli);
|
|
mysqli_query($mysqli, "UPDATE trips SET trip_expense_id = $expense_id WHERE trip_id IN (" . implode(',', $ids) . ") AND trip_user_id = $user_id AND " . tripClaimSql());
|
|
$got = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*), COALESCE(SUM(trip_miles), 0), MIN(trip_date), MAX(trip_date) FROM trips WHERE trip_expense_id = $expense_id"));
|
|
if (!intval($got[0])) {
|
|
mysqli_query($mysqli, "DELETE FROM expenses WHERE expense_id = $expense_id");
|
|
continue;
|
|
}
|
|
$group_count = intval($got[0]);
|
|
$group_miles = round(floatval($got[1]), 1);
|
|
$group_amount = round($group_miles * $rate, 2);
|
|
$dates = $got[2] === $got[3] ? date('M j, Y', strtotime($got[2])) : date('M j', strtotime($got[2])) . ' to ' . date('M j, Y', strtotime($got[3]));
|
|
$description = 'Mileage - ' . ($group_count === 1 ? '1 trip' : "$group_count trips") . ', ' . tripDistanceText($group_miles) . "\n" . 'At ' . tripRateText($rate) . ", $dates";
|
|
mysqli_query($mysqli, "UPDATE expenses SET expense_amount = $group_amount, expense_description = '" . escapeSql($description) . "' WHERE expense_id = $expense_id");
|
|
logAudit('Expense', 'Create', escapeSql("$by paid {$driver['user_name']} mileage for " . ($group_count === 1 ? '1 trip' : "$group_count trips") . ', ' . tripDistanceText($group_miles) . ' at ' . tripRateText($rate)
|
|
. ' - ' . billingMoney($group_amount, $currency) . ' to pay back'), $client_id, $expense_id);
|
|
$expense_ids[] = $expense_id;
|
|
$count += $group_count;
|
|
$miles += $group_miles;
|
|
$amount += $group_amount;
|
|
}
|
|
if (!$expense_ids) {
|
|
$error = 'Nothing to pay - someone else paid for those trips just now.';
|
|
return null;
|
|
}
|
|
return ['expense_ids' => $expense_ids, 'count' => $count, 'miles' => round($miles, 1), 'amount' => round($amount, 2), 'rate' => $rate,
|
|
'user_name' => $driver['user_name'], 'currency' => $currency];
|
|
}
|
|
|
|
/* Billing */
|
|
|
|
/** The SQL that makes a trip to bill: billable, for a client, on no invoice yet. */
|
|
function tripUnbilledSql() {
|
|
return 'trips.trip_billable = 1 AND trips.trip_invoice_id = 0 AND trips.trip_client_id > 0 AND trips.trip_archived_at IS NULL';
|
|
}
|
|
|
|
/**
|
|
* Trips to bill - one client's ($client_id) or every client the agent may
|
|
* see, optionally only $ids - oldest first, each with its client, site,
|
|
* ticket and driver, the price it bills at and the days since it was driven.
|
|
*/
|
|
function tripUnbilled($client_id = 0, array $ids = []) {
|
|
global $mysqli;
|
|
$client_id = intval($client_id);
|
|
$ids = array_filter(array_map('intval', $ids));
|
|
$rows = [];
|
|
$sql = mysqli_query($mysqli, "SELECT trips.*, client_name, client_abbreviation, location_name, ticket_prefix, ticket_number, ticket_subject,
|
|
driver.user_name AS driver_name, DATEDIFF(CURDATE(), trips.trip_date) AS waiting
|
|
FROM trips " . tripJoinsSql() . "
|
|
WHERE " . tripUnbilledSql() . ($client_id ? " AND trips.trip_client_id = $client_id" : '') . ($ids ? ' AND trips.trip_id IN (' . implode(',', $ids) . ')' : '') . ' ' . tripScopeSql() . "
|
|
ORDER BY client_name ASC, trips.trip_date ASC, trips.trip_id ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$row['price'] = tripBillPrice($row);
|
|
$row['waiting'] = max(0, intval($row['waiting']));
|
|
$rows[intval($row['trip_id'])] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/** How many trips are waiting to be billed, and what they come to - every client the agent can see, or one - for the menu, the dashboard and the client's overview. */
|
|
function tripUnbilledCount($client_id = 0) {
|
|
global $mysqli;
|
|
$client_id = intval($client_id);
|
|
$rules = tripRules();
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*), COALESCE(SUM(ROUND({$rules['charge']} + {$rules['bill_rate']} * GREATEST(0, trip_miles - {$rules['included']}), 2)), 0)
|
|
FROM trips LEFT JOIN clients ON client_id = trips.trip_client_id
|
|
WHERE " . tripUnbilledSql() . ($client_id ? " AND trips.trip_client_id = $client_id" : '') . ' ' . tripScopeSql()));
|
|
return ['count' => intval($row[0]), 'value' => round(floatval($row[1]), 2)];
|
|
}
|
|
|
|
/**
|
|
* Unbilled Time's clients (after expenseUnbilledMerge()) with their trips to
|
|
* bill as well: each gets trips, trip_value and trip_miles, a client with
|
|
* only trips waiting is added, and the oldest counts the trips too. Sorted
|
|
* by what it all comes to.
|
|
*/
|
|
function tripUnbilledMerge(array $clients, $client_id = 0) {
|
|
global $mysqli;
|
|
foreach ($clients as $id => $c) {
|
|
$clients[$id] += ['expenses' => [], 'expense_cost' => 0.0, 'expense_value' => 0.0, 'trips' => [], 'trip_value' => 0.0, 'trip_miles' => 0.0];
|
|
}
|
|
$added = [];
|
|
foreach (tripUnbilled($client_id) as $trip_id => $trip) {
|
|
$id = intval($trip['trip_client_id']);
|
|
if (!isset($clients[$id])) {
|
|
$client_rate = getFieldById('clients', $id, 'client_rate');
|
|
$clients[$id] = ['client_id' => $id, 'client_name' => $trip['client_name'], 'client_abbreviation' => $trip['client_abbreviation'],
|
|
'rate' => billingTimeRate($client_rate), 'default_rate' => floatval($client_rate) <= 0, 'tickets' => [], 'seconds' => 0, 'hours' => 0.0,
|
|
'value' => 0.0, 'oldest' => 0, 'no_time' => 0, 'drafts' => [], 'expenses' => [], 'expense_cost' => 0.0, 'expense_value' => 0.0,
|
|
'trips' => [], 'trip_value' => 0.0, 'trip_miles' => 0.0];
|
|
$added[] = $id;
|
|
}
|
|
$clients[$id]['trips'][$trip_id] = $trip;
|
|
$clients[$id]['trip_value'] = round($clients[$id]['trip_value'] + $trip['price'], 2);
|
|
$clients[$id]['trip_miles'] = round($clients[$id]['trip_miles'] + floatval($trip['trip_miles']), 1);
|
|
$clients[$id]['oldest'] = max($clients[$id]['oldest'], $trip['waiting']);
|
|
}
|
|
if ($added) {
|
|
$sql = mysqli_query($mysqli, "SELECT invoice_id, invoice_prefix, invoice_number, invoice_scope, invoice_date, invoice_amount, invoice_client_id FROM invoices
|
|
WHERE invoice_status = 'Draft' AND invoice_archived_at IS NULL AND invoice_client_id IN (" . implode(',', array_unique($added)) . ")
|
|
ORDER BY invoice_date DESC, invoice_id DESC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$clients[intval($row['invoice_client_id'])]['drafts'][intval($row['invoice_id'])] = $row;
|
|
}
|
|
}
|
|
uasort($clients, function ($a, $b) {
|
|
return ($b['value'] + $b['expense_value'] + $b['trip_value']) <=> ($a['value'] + $a['expense_value'] + $a['trip_value'])
|
|
?: (count($b['tickets']) + count($b['expenses']) + count($b['trips'])) <=> (count($a['tickets']) + count($a['expenses']) + count($a['trips']));
|
|
});
|
|
return $clients;
|
|
}
|
|
|
|
/** The invoice line a trip goes on as - [name, description]. Raw. */
|
|
function tripBillLine(array $trip) {
|
|
$description = trim((string) $trip['trip_purpose']);
|
|
$detail = implode(' - ', array_filter([
|
|
$trip['trip_source'] . ' to ' . $trip['trip_destination'],
|
|
date('m/d/Y', strtotime((string) $trip['trip_date'])),
|
|
tripDistanceText($trip['trip_miles']) . (intval($trip['round_trip']) ? ' round trip' : ''),
|
|
intval($trip['trip_ticket_id']) && ($trip['ticket_number'] ?? null) !== null ? ticketReference($trip) : '',
|
|
]));
|
|
return [mb_substr(tripRules()['item_name'], 0, 200), $description !== '' ? "$description\n$detail" : $detail];
|
|
}
|
|
|
|
/**
|
|
* Bill trips on an invoice, a line each. $prices is trip id => price (null:
|
|
* what tripBillPrice() makes it). Only the client's trips still to bill are
|
|
* taken, each claimed first. $opts as expenseBillInvoice() takes them:
|
|
* invoice_id (or 0 for a new draft with date, category_id and scope), tax_id
|
|
* and by. Returns [invoice_id, number, new, trips (count), amount, miles],
|
|
* or null with $error.
|
|
*/
|
|
function tripBillInvoice($client_id, array $prices, array $opts, &$error = '') {
|
|
global $mysqli;
|
|
$error = '';
|
|
$client_id = intval($client_id);
|
|
$by = (string) ($opts['by'] ?? 'ITFlow');
|
|
$trips = $prices ? tripUnbilled($client_id, array_keys($prices)) : [];
|
|
if (!$client_id || !$trips) {
|
|
$error = 'Nothing to bill - those trips are already on an invoice, or no longer billable.';
|
|
return null;
|
|
}
|
|
|
|
$bill = [];
|
|
foreach ($trips as $id => $trip) {
|
|
$value = $prices[$id] ?? null;
|
|
$price = ($value === null || trim((string) $value) === '') ? $trip['price'] : billingNumberInput($value);
|
|
if ($price === null || $price < 0 || $price >= 1e9) {
|
|
$error = 'The price for the trip to ' . $trip['trip_destination'] . ' on ' . $trip['trip_date'] . ' has to be a number, zero or more.';
|
|
return null;
|
|
}
|
|
$bill[$id] = round($price, 2);
|
|
}
|
|
$tax_id = intval($opts['tax_id'] ?? 0);
|
|
if ($tax_id && !mysqli_num_rows(mysqli_query($mysqli, "SELECT tax_id FROM taxes WHERE tax_id = $tax_id"))) {
|
|
$error = 'That tax no longer exists.';
|
|
return null;
|
|
}
|
|
|
|
$invoice_id = intval($opts['invoice_id'] ?? 0);
|
|
$new = false;
|
|
if ($invoice_id) {
|
|
$invoice = billingRow('invoice', $invoice_id);
|
|
if (!$invoice || intval($invoice['client_id']) !== $client_id || !billingCanEditItems('invoice', $invoice)) {
|
|
$error = "That invoice isn't one of the client's you can still add to.";
|
|
return null;
|
|
}
|
|
} else {
|
|
$invoice_id = billingInvoiceCreate($client_id, $opts['date'] ?? '', $opts['category_id'] ?? 0, $opts['scope'] ?? '', $by);
|
|
if (!$invoice_id) {
|
|
$error = "The invoice couldn't be made.";
|
|
return null;
|
|
}
|
|
$new = true;
|
|
}
|
|
$number = billingNumber('invoice', billingRow('invoice', $invoice_id, false));
|
|
|
|
// Claim them - only those still to bill go on
|
|
$in = implode(',', array_keys($bill));
|
|
mysqli_query($mysqli, "UPDATE trips SET trip_invoice_id = $invoice_id WHERE trip_id IN ($in) AND trip_client_id = $client_id AND " . tripUnbilledSql());
|
|
$claimed = [];
|
|
$sql = mysqli_query($mysqli, "SELECT trip_id FROM trips WHERE trip_invoice_id = $invoice_id AND trip_id IN ($in) AND trip_invoice_item_id = 0");
|
|
while ($row = mysqli_fetch_row($sql)) {
|
|
$claimed[intval($row[0])] = true;
|
|
}
|
|
if (!$claimed) {
|
|
if ($new) {
|
|
mysqli_query($mysqli, "DELETE FROM history WHERE history_invoice_id = $invoice_id");
|
|
mysqli_query($mysqli, "DELETE FROM invoices WHERE invoice_id = $invoice_id");
|
|
}
|
|
$error = 'Nothing to bill - someone else billed those trips just now.';
|
|
return null;
|
|
}
|
|
|
|
$lines = 0;
|
|
$amount = 0.0;
|
|
$miles = 0.0;
|
|
$invoice = billingRow('invoice', $invoice_id, false);
|
|
$currency = (string) $invoice['invoice_currency_code'];
|
|
foreach ($bill as $id => $price) {
|
|
if (!isset($claimed[$id])) {
|
|
continue;
|
|
}
|
|
$trip = $trips[$id];
|
|
[$name, $description] = tripBillLine($trip);
|
|
$item_id = billingItemSave('invoice', $invoice_id, ['name' => $name, 'description' => $description, 'qty' => 1, 'price' => $price, 'tax_id' => $tax_id], 0, $line_error);
|
|
if (!$item_id) {
|
|
mysqli_query($mysqli, "UPDATE trips SET trip_invoice_id = 0 WHERE trip_id = $id AND trip_invoice_id = $invoice_id");
|
|
continue;
|
|
}
|
|
mysqli_query($mysqli, "UPDATE trips SET trip_invoice_item_id = " . intval($item_id) . " WHERE trip_id = $id");
|
|
$lines++;
|
|
$amount += $price;
|
|
$miles += floatval($trip['trip_miles']);
|
|
logAudit('Trip', 'Bill', escapeSql("$by billed the trip " . tripLabel($trip) . " on invoice $number at " . billingMoney($price, $currency)), $client_id, $id);
|
|
}
|
|
billingRecalc('invoice', $invoice_id);
|
|
|
|
$invoice = billingRow('invoice', $invoice_id, false);
|
|
$what = $lines === 1 ? '1 trip' : "$lines trips";
|
|
billingLog('invoice', $invoice_id, $invoice['invoice_status'], ucfirst($what) . " billed by $by - " . billingMoney($amount, $currency) . ' for ' . tripDistanceText($miles));
|
|
logAudit('Invoice', $new ? 'Create' : 'Edit', escapeSql("$by billed $what on invoice $number - " . billingMoney($amount, $currency)), $client_id, $invoice_id);
|
|
if ($new) {
|
|
triggerCustomAction('invoice_create', $invoice_id);
|
|
}
|
|
|
|
return ['invoice_id' => $invoice_id, 'number' => $number, 'new' => $new, 'trips' => $lines, 'amount' => round($amount, 2), 'miles' => round($miles, 1)];
|
|
}
|
|
|
|
/** May this agent take a trip off an invoice in this status - with write access to Financial and Sales, while its lines can change or once it is cancelled. */
|
|
function tripCanUnbill($invoice_status) {
|
|
return tripCanManage() && expenseCanUnbill($invoice_status);
|
|
}
|
|
|
|
/**
|
|
* Take a trip off the invoice it was billed on - its line comes off, and it
|
|
* is to bill again. On a cancelled invoice the line stays and only the link
|
|
* goes. The invoice number, or '' with $error.
|
|
*/
|
|
function tripUnbill($trip_id, &$error = '') {
|
|
global $mysqli;
|
|
$error = '';
|
|
$row = tripRow($trip_id);
|
|
if (!$row || !intval($row['trip_invoice_id'])) {
|
|
$error = "That trip isn't on an invoice";
|
|
return '';
|
|
}
|
|
$id = intval($row['trip_id']);
|
|
$invoice = billingRow('invoice', intval($row['trip_invoice_id']), false);
|
|
if (!$invoice) {
|
|
mysqli_query($mysqli, "UPDATE trips SET trip_invoice_id = 0, trip_invoice_item_id = 0 WHERE trip_id = $id");
|
|
return 'an invoice since deleted';
|
|
}
|
|
$number = billingNumber('invoice', $invoice);
|
|
if (!tripCanUnbill($invoice['invoice_status'])) {
|
|
$error = "$number is " . strtolower($invoice['invoice_status']) . " - its lines can't change any more";
|
|
return '';
|
|
}
|
|
if (intval($row['trip_invoice_item_id']) && $invoice['invoice_status'] !== 'Cancelled') {
|
|
billingItemDelete('invoice', intval($invoice['invoice_id']), intval($row['trip_invoice_item_id']));
|
|
}
|
|
mysqli_query($mysqli, "UPDATE trips SET trip_invoice_id = 0, trip_invoice_item_id = 0 WHERE trip_id = $id");
|
|
return $number;
|
|
}
|
|
|
|
/** The trips billed on an invoice - each with what its line bills before tax. */
|
|
function tripsOnInvoice($invoice_id) {
|
|
global $mysqli;
|
|
$invoice_id = intval($invoice_id);
|
|
$rows = [];
|
|
$sql = mysqli_query($mysqli, "SELECT trips.*, driver.user_name AS driver_name, ticket_prefix, ticket_number, ticket_subject, item_subtotal
|
|
FROM trips
|
|
LEFT JOIN users AS driver ON driver.user_id = trips.trip_user_id
|
|
LEFT JOIN tickets ON ticket_id = trips.trip_ticket_id
|
|
LEFT JOIN invoice_items ON trips.trip_invoice_item_id > 0 AND item_id = trips.trip_invoice_item_id
|
|
WHERE trips.trip_invoice_id = $invoice_id
|
|
ORDER BY trips.trip_date ASC, trips.trip_id ASC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$row['billed'] = $row['item_subtotal'] !== null ? floatval($row['item_subtotal']) : null;
|
|
$rows[] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/** The client's ticked trips to bill aren't billable after all - their labels. */
|
|
function tripWriteOff($client_id, array $trip_ids, $by) {
|
|
global $mysqli;
|
|
$trips = $trip_ids ? tripUnbilled($client_id, $trip_ids) : [];
|
|
$done = [];
|
|
foreach ($trips as $trip_id => $trip) {
|
|
mysqli_query($mysqli, "UPDATE trips SET trip_billable = 0 WHERE trip_id = $trip_id AND trip_invoice_id = 0 AND trip_billable = 1");
|
|
if (mysqli_affected_rows($mysqli) === 1) {
|
|
logAudit('Trip', 'Edit', escapeSql("$by marked the trip " . tripLabel($trip) . ' not billable'), intval($client_id), $trip_id);
|
|
$done[] = tripLabel($trip);
|
|
}
|
|
}
|
|
return $done;
|
|
}
|
|
|
|
/** Each driver's vehicle on their last trip - user_id => 1 (their own) or 0 - for New Trip to start with. */
|
|
function tripDriverVehicles() {
|
|
global $mysqli;
|
|
$vehicles = [];
|
|
$sql = mysqli_query($mysqli, "SELECT t.trip_user_id, t.trip_own_vehicle FROM trips AS t
|
|
WHERE t.trip_archived_at IS NULL AND t.trip_user_id > 0 AND t.trip_id = (SELECT last.trip_id FROM trips AS last
|
|
WHERE last.trip_user_id = t.trip_user_id AND last.trip_archived_at IS NULL ORDER BY last.trip_date DESC, last.trip_id DESC LIMIT 1)");
|
|
while ($row = mysqli_fetch_row($sql)) {
|
|
$vehicles[intval($row[0])] = intval($row[1]);
|
|
}
|
|
return $vehicles;
|
|
}
|