Files
itflow/functions/products.php
johnnyq afaaa5fc32 Taxes stage 1: Admin > Taxes rebuilt (renamed from Tax Rates) listing each rate with its whole percent, parts, description, registration number and usage (invoices, quotes, recurring invoices, products, clients), Current and Archived tabs showing what replaced a rate, Restore, Make default, and Delete only for a rate never used; rates made of parts (state and county, GST and QST) with parts charged on top of the ones above, the whole rate shown as typed, four decimal places and a registration number per rate or part printed with the company details on invoices, quotes and PDFs; changing the percent or parts of a rate on a sent invoice or quote makes a new rate of the same name and archives the old one, so billed lines keep the old rate while products, client rates, the default, recurring invoices and optionally drafts move to the new one, and archiving offers to move whatever still uses it; How lines are taxed settings for the default rate, taxing billed time, trips and expenses, and discounts taken off before tax (on by default, shared across lines by amount, paid, cancelled and answered documents never recalculated); clients get their own tax rate and tax exempt with certificate number and expiry on the Billing tab, new lines defaulting to the client's rate then the product's then the default and to no tax for exempt clients, exempt badge on the client header and overview, a warning with Take the tax off on open documents that still carry taxed lines for an exempt client, and Tax exempt with the certificate under the client on untaxed documents; totals, PDFs, guest and portal views show tax by part; Unbilled Time can bill at each client's usual rate and the ticket, Add Tickets, Add Expenses and Add Trips windows start on it; API client fields for rate and exemption and tax_id default on invoice item create; Tax Collected shows exact percents; demo taxes get parts and registration numbers, demo clients their rates and two nonprofits exemptions; database 3.1.1 makes tax_percent decimal(9,4) and adds tax description, registration and replaced-by, tax_components, client rate and exemption columns and the tax settings; new functions/tax.php and js/tax_form.js; changelog updated
2026-10-17 13:00:54 -04:00

1164 lines
56 KiB
PHP

<?php
/*
* Products and services - the catalog lines are picked from on invoices,
* quotes and recurring invoices.
*
* Each one has a price and what it costs us (so its margin), the vendor we
* buy it from with their part number, internal notes, and how it is billed by
* default: once, or each month, quarter or year, counted per user or per
* device. Picking it on a quote fills in Billed, on a recurring invoice
* Counted. A product can track its stock: tracked ones move stock as they
* are invoiced and can't be sold past what is on the shelf, the rest are
* ordered as needed. Hardware can become a client asset when it is sold -
* the invoice offers to add each unit to the client's assets.
*/
/** Product types => label, plural and icon. */
function productTypes() {
return [
'service' => ['label' => 'Service', 'plural' => 'Services', 'icon' => 'fa-wrench'],
'product' => ['label' => 'Product', 'plural' => 'Products', 'icon' => 'fa-box-open'],
];
}
/** How a recurring product is counted by default => label. Empty is as typed. */
function productSeatSources() {
return ['' => 'As typed', 'contacts' => 'Per user', 'assets' => 'Per device'];
}
/** A product's default billing in words - "One time", "Monthly, per user". */
function productBilledText($frequency, $seat_source = '') {
$frequency = quoteFrequency($frequency);
if ($frequency === '') {
return 'One time';
}
$text = recurringInvoiceFrequencies()[$frequency];
$seat = (string) $seat_source;
if ($seat !== '' && isset(productSeatSources()[$seat])) {
$text .= ', ' . strtolower(productSeatSources()[$seat]);
}
return $text;
}
/**
* The margin on a price at a cost - ['amount', 'percent'] (percent of the
* price), or null when the cost isn't set or there is no price to take it from.
*/
function productMargin($price, $cost) {
$price = floatval($price);
$cost = floatval($cost);
if ($cost <= 0 || $price <= 0) {
return null;
}
return ['amount' => round($price - $cost, 2), 'percent' => round(($price - $cost) / $price * 100, 1)];
}
/** A margin as a pill - green from 30%, amber above 0, red at a loss. '' when there is none. */
function productMarginPill($margin) {
if (!$margin) {
return '';
}
$percent = $margin['percent'];
$tone = $percent >= 30 ? 'success' : ($percent > 0 ? 'warning' : 'danger');
return '<span class="badge rounded-pill bg-' . $tone . '-subtle text-' . $tone . '-emphasis fw-normal" title="Margin on the price">' . escapeHtml(rtrim(rtrim(number_format($percent, 1), '0'), '.')) . '%</span>';
}
/** What is on the shelf, in SQL - use with products in the FROM. */
function productStockSql($column = 'products.product_id') {
return "(SELECT COALESCE(SUM(stock_qty), 0) FROM product_stock WHERE stock_product_id = $column)";
}
/** A product's tile - its type's icon. */
function productTile($type, $size = '') {
$type = isset(productTypes()[$type]) ? $type : 'service';
return '<span class="itflow-asset-icon itflow-product-tile itflow-product-tile-' . $type . ($size ? " itflow-asset-icon-$size" : '') . '" aria-hidden="true"><i class="fas fa-fw ' . productTypes()[$type]['icon'] . '"></i></span>';
}
/** One product with its category, tax, vendor and stock - or null. */
function productRow($product_id) {
global $mysqli;
$product_id = intval($product_id);
if (!$product_id) {
return null;
}
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT products.*, category_name, category_color, category_icon, tax_name, tax_percent, tax_archived_at, vendor_name,
" . productStockSql() . " AS product_stock
FROM products
LEFT JOIN categories ON category_id = product_category_id
LEFT JOIN taxes ON tax_id = product_tax_id
LEFT JOIN vendors ON vendor_id = product_vendor_id
WHERE product_id = $product_id")) ?: null;
}
/** Our vendors (not a client's), id => name, for picking who a product comes from. */
function productVendors() {
global $mysqli;
$vendors = [];
$sql = mysqli_query($mysqli, "SELECT vendor_id, vendor_name FROM vendors WHERE vendor_client_id = 0 AND vendor_archived_at IS NULL ORDER BY vendor_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$vendors[intval($row['vendor_id'])] = $row['vendor_name'];
}
return $vendors;
}
/**
* Add a product ($product_id 0) or change one. $in holds raw values - only the
* keys given are changed on an existing product:
* name, type (service or product), category (an Income category id), price,
* cost, tax (a tax id), frequency ('' once, month, quarter, year),
* seat_source ('' as typed, contacts, assets - only when billed recurring),
* code, vendor (one of our vendors), vendor_part, track_stock (products
* only), reorder_level and reorder_qty (when to buy more, how many),
* asset_type (products only - one of the asset types, '' for none), make,
* model, warranty_months, location, description, notes.
* A change to an existing product's price or cost goes in its price history,
* from $source - edit, import or receive.
* Returns the product id, or 0 with $error.
*/
function productSave(array $in, $product_id = 0, &$error = '', $source = 'edit') {
global $mysqli, $session_company_currency;
$error = '';
$product_id = intval($product_id);
$old = $product_id ? productRow($product_id) : null;
if ($product_id && !$old) {
$error = 'That product no longer exists.';
return 0;
}
$has = function ($key) use ($in, $old) {
return !$old || array_key_exists($key, $in);
};
$set = [];
if ($has('name')) {
$name = trim((string) ($in['name'] ?? ''));
if ($name === '') {
$error = 'A product needs a name.';
return 0;
}
$set[] = "product_name = '" . escapeSql(mb_substr($name, 0, 200)) . "'";
}
$type = $has('type') ? (string) ($in['type'] ?? 'service') : $old['product_type'];
if (!isset(productTypes()[$type])) {
$error = 'A product is a service or a product.';
return 0;
}
if ($has('type')) {
$set[] = "product_type = '$type'";
}
foreach (['price' => 'product_price', 'cost' => 'product_cost'] as $key => $column) {
if (!$has($key)) {
continue;
}
$raw = trim(str_replace([',', '$', ' '], '', (string) ($in[$key] ?? '')));
if ($raw === '') {
$raw = '0';
}
if (!is_numeric($raw) || abs(floatval($raw)) >= 1e13) {
$error = ucfirst($key) . ' has to be a number.';
return 0;
}
if ($key === 'cost' && floatval($raw) < 0) {
$error = 'Cost can\'t be below zero.';
return 0;
}
$set[] = "$column = " . round(floatval($raw), 2);
}
if ($has('category')) {
$category_id = intval($in['category'] ?? 0);
if ($category_id && !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM categories WHERE category_id = $category_id AND category_type = 'Income'"))) {
$error = 'That category is not an income category.';
return 0;
}
$set[] = "product_category_id = $category_id";
}
if ($has('tax')) {
$tax_id = intval($in['tax'] ?? 0);
if ($tax_id && !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM taxes WHERE tax_id = $tax_id"))) {
$tax_id = 0;
}
$set[] = "product_tax_id = $tax_id";
}
if ($has('vendor')) {
$vendor_id = intval($in['vendor'] ?? 0);
if ($vendor_id && !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM vendors WHERE vendor_id = $vendor_id AND vendor_client_id = 0"))) {
$error = 'That vendor is not one of yours.';
return 0;
}
$set[] = "product_vendor_id = $vendor_id";
}
// How it is billed - counted per user or device only when it recurs
$frequency = $has('frequency') ? quoteFrequency($in['frequency'] ?? '') : quoteFrequency($old['product_frequency'] ?? '');
if ($has('frequency')) {
$set[] = 'product_frequency = ' . ($frequency === '' ? 'NULL' : "'$frequency'");
}
if ($has('seat_source') || $has('frequency')) {
$seat = $has('seat_source') ? (string) ($in['seat_source'] ?? '') : (string) ($old['product_seat_source'] ?? '');
if ($frequency === '' || !isset(productSeatSources()[$seat])) {
$seat = '';
}
$set[] = 'product_seat_source = ' . ($seat === '' ? 'NULL' : "'$seat'");
}
// Stock is tracked for products only
if ($has('track_stock') || $has('type')) {
$track = $has('track_stock') ? !empty($in['track_stock']) : intval($old['product_track_stock']) === 1;
$set[] = 'product_track_stock = ' . ($type === 'product' && $track ? 1 : 0);
}
// When to buy more, and how many
foreach (['reorder_level' => 'product_reorder_level', 'reorder_qty' => 'product_reorder_qty'] as $key => $column) {
if (!$has($key)) {
continue;
}
$raw = trim(str_replace([',', ' '], '', (string) ($in[$key] ?? '')));
if ($raw === '') {
$raw = '0';
}
if (!is_numeric($raw) || floatval($raw) < 0 || floatval($raw) >= 1e9) {
$error = ($key === 'reorder_level' ? 'Reorder at' : 'Order') . ' has to be a number, zero or more.';
return 0;
}
$set[] = "$column = " . round(floatval($raw), 2);
}
// Hardware that becomes a client asset when sold - products only
if ($has('asset_type') || $has('type')) {
$asset_type = $has('asset_type') ? trim((string) ($in['asset_type'] ?? '')) : (string) ($old['product_asset_type'] ?? '');
if (in_array(strtolower($asset_type), ['', 'none', 'no', '-'], true)) {
$asset_type = '';
} else {
$asset_type = productAssetType($asset_type);
if ($asset_type === '') {
$error = "\"" . trim((string) ($in['asset_type'] ?? '')) . "\" isn't an asset type - use one of " . implode(', ', array_keys(productAssetTypes())) . '.';
return 0;
}
}
if ($type !== 'product') {
$asset_type = '';
}
$set[] = 'product_asset_type = ' . ($asset_type === '' ? 'NULL' : "'" . escapeSql($asset_type) . "'");
}
if ($has('warranty_months')) {
$raw = trim((string) ($in['warranty_months'] ?? ''));
if ($raw === '') {
$raw = '0';
}
if (!ctype_digit($raw) || intval($raw) > 600) {
$error = 'Warranty is a number of months, up to 600.';
return 0;
}
$set[] = 'product_warranty_months = ' . intval($raw);
}
foreach (['code' => ['product_code', 200], 'vendor_part' => ['product_vendor_part', 200], 'location' => ['product_location', 250], 'make' => ['product_make', 200], 'model' => ['product_model', 200]] as $key => [$column, $length]) {
if ($has($key)) {
$value = trim((string) ($in[$key] ?? ''));
$set[] = "$column = " . ($value === '' ? 'NULL' : "'" . escapeSql(mb_substr($value, 0, $length)) . "'");
}
}
foreach (['description' => 'product_description', 'notes' => 'product_notes'] as $key => $column) {
if ($has($key)) {
$value = trim((string) ($in[$key] ?? ''));
$set[] = "$column = " . ($value === '' ? 'NULL' : "'" . escapeSql($value) . "'");
}
}
if (!$set) {
return $product_id;
}
if ($product_id) {
mysqli_query($mysqli, "UPDATE products SET " . implode(', ', $set) . " WHERE product_id = $product_id");
productPriceRecord($product_id, $old, $source);
return $product_id;
}
$currency = escapeSql($session_company_currency ?? '');
mysqli_query($mysqli, "INSERT INTO products SET " . implode(', ', $set) . ", product_currency_code = '$currency'");
return intval(mysqli_insert_id($mysqli));
}
/** Where a price change came from => label. */
function productPriceSources() {
return ['edit' => 'Edited', 'import' => 'Imported', 'receive' => 'Stock received'];
}
/** Put a change to a product's price or cost in its history - $old is the product before. */
function productPriceRecord($product_id, array $old, $source = 'edit') {
global $mysqli, $session_user_id;
$product_id = intval($product_id);
$now = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT product_price, product_cost FROM products WHERE product_id = $product_id"));
if (!$now || (round(floatval($now['product_price']), 2) == round(floatval($old['product_price']), 2) && round(floatval($now['product_cost']), 2) == round(floatval($old['product_cost']), 2))) {
return;
}
$source = isset(productPriceSources()[$source]) ? $source : 'edit';
mysqli_query($mysqli, "INSERT INTO product_prices SET price_product_id = $product_id,
price_old = " . round(floatval($old['product_price']), 2) . ", price_new = " . round(floatval($now['product_price']), 2) . ",
price_cost_old = " . round(floatval($old['product_cost']), 2) . ", price_cost_new = " . round(floatval($now['product_cost']), 2) . ",
price_source = '$source', price_user_id = " . intval($session_user_id ?? 0));
}
/** A product's price changes, newest first, with who made them. */
function productPrices($product_id, $limit = 100) {
global $mysqli;
$product_id = intval($product_id);
$limit = max(1, intval($limit));
$rows = [];
$sql = mysqli_query($mysqli, "SELECT product_prices.*, user_name FROM product_prices
LEFT JOIN users ON user_id = price_user_id
WHERE price_product_id = $product_id
ORDER BY price_created_at DESC, price_id DESC LIMIT $limit");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/** Copy a product - its new id. "Name (copy)", with no stock. */
function productCopy($product_id) {
global $mysqli;
$product = productRow($product_id);
if (!$product) {
return 0;
}
$error = '';
return productSave([
'name' => mb_substr($product['product_name'] . ' (copy)', 0, 200), 'type' => $product['product_type'], 'category' => $product['product_category_id'],
'price' => $product['product_price'], 'cost' => $product['product_cost'], 'tax' => $product['product_tax_id'],
'frequency' => $product['product_frequency'], 'seat_source' => $product['product_seat_source'], 'code' => '',
'vendor' => $product['product_vendor_id'], 'vendor_part' => $product['product_vendor_part'], 'track_stock' => $product['product_track_stock'],
'reorder_level' => $product['product_reorder_level'], 'reorder_qty' => $product['product_reorder_qty'],
'asset_type' => $product['product_asset_type'], 'make' => $product['product_make'], 'model' => $product['product_model'], 'warranty_months' => $product['product_warranty_months'],
'location' => $product['product_location'], 'description' => $product['product_description'], 'notes' => $product['product_notes'],
], 0, $error);
}
/** Delete a product, its stock and its price history. Lines it is on keep their text and amounts. */
function productDelete($product_id) {
global $mysqli;
$product_id = intval($product_id);
mysqli_query($mysqli, "DELETE FROM product_stock WHERE stock_product_id = $product_id");
mysqli_query($mysqli, "DELETE FROM product_prices WHERE price_product_id = $product_id");
mysqli_query($mysqli, "DELETE FROM products WHERE product_id = $product_id");
}
/**
* Where a product is used and what it sold - for its page. Scoped to the
* agent's clients.
* recurring the recurring invoices it is on (not archived), with qty, amount and a month's worth
* quotes open quotes it is on (draft, sent, viewed or accepted)
* invoices the last 50 invoice lines it is on
* sold over the last 12 months of invoices sent (not drafts, cancelled or non-billable): amount, quantity, invoices, clients
* monthly what the recurring lines bring in a month, active ones only, and on how many clients
*/
function productUsage($product_id) {
global $mysqli;
$product_id = intval($product_id);
$scope = clientScopeSql('client_id');
$usage = ['recurring' => [], 'quotes' => [], 'invoices' => [], 'monthly' => 0.0, 'monthly_clients' => 0];
$clients = [];
$sql = mysqli_query($mysqli, "SELECT recurring_invoice_id, recurring_invoice_prefix, recurring_invoice_number, recurring_invoice_frequency, recurring_invoice_status,
recurring_invoice_next_date, recurring_invoice_currency_code, client_id, client_name, SUM(item_quantity) AS qty, SUM(item_subtotal) AS amount
FROM recurring_invoice_items
JOIN recurring_invoices ON recurring_invoice_id = item_recurring_invoice_id
JOIN clients ON client_id = recurring_invoice_client_id
WHERE item_product_id = $product_id AND recurring_invoice_archived_at IS NULL $scope
GROUP BY recurring_invoice_id
ORDER BY recurring_invoice_status DESC, client_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$row['monthly'] = recurringInvoiceMonthly($row['amount'], $row['recurring_invoice_frequency']);
if (intval($row['recurring_invoice_status']) === 1) {
$usage['monthly'] += $row['monthly'];
$clients[intval($row['client_id'])] = true;
}
$usage['recurring'][] = $row;
}
$usage['monthly_clients'] = count($clients);
$sql = mysqli_query($mysqli, "SELECT quote_id, quote_prefix, quote_number, quote_status, quote_date, quote_expire, quote_currency_code, client_id, client_name,
SUM(item_quantity) AS qty, SUM(item_subtotal) AS amount, MAX(item_frequency) AS frequency
FROM quote_items
JOIN quotes ON quote_id = item_quote_id
JOIN clients ON client_id = quote_client_id
WHERE item_product_id = $product_id AND quote_archived_at IS NULL AND quote_status IN ('Draft', 'Sent', 'Viewed', 'Accepted') $scope
GROUP BY quote_id
ORDER BY quote_date DESC, quote_id DESC");
while ($row = mysqli_fetch_assoc($sql)) {
$usage['quotes'][] = $row;
}
$sql = mysqli_query($mysqli, "SELECT invoice_id, invoice_prefix, invoice_number, invoice_status, invoice_date, invoice_due, invoice_amount, invoice_currency_code,
invoice_archived_at, client_id, client_name, item_quantity, item_price, item_subtotal
FROM invoice_items
JOIN invoices ON invoice_id = item_invoice_id
JOIN clients ON client_id = invoice_client_id
WHERE item_product_id = $product_id $scope
ORDER BY invoice_date DESC, item_id DESC
LIMIT 50");
while ($row = mysqli_fetch_assoc($sql)) {
$usage['invoices'][] = $row;
}
$usage['sold'] = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(item_subtotal), 0) AS amount, COALESCE(SUM(item_quantity), 0) AS qty,
COUNT(DISTINCT invoice_id) AS invoices, COUNT(DISTINCT client_id) AS clients
FROM invoice_items
JOIN invoices ON invoice_id = item_invoice_id
JOIN clients ON client_id = invoice_client_id
WHERE item_product_id = $product_id AND invoice_status NOT IN ('Draft', 'Cancelled', 'Non-Billable')
AND invoice_date >= CURDATE() - INTERVAL 12 MONTH $scope"));
return $usage;
}
/** A product's history from the audit log, newest first. */
function productHistory($product_id, $limit = 100) {
global $mysqli;
$product_id = intval($product_id);
$limit = max(1, intval($limit));
$rows = [];
$sql = mysqli_query($mysqli, "SELECT log_action, log_description, log_created_at, user_name FROM logs
LEFT JOIN users ON user_id = log_user_id
WHERE log_type = 'Product' AND log_entity_id = $product_id
ORDER BY log_id DESC LIMIT $limit");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/* ------------------------------------------------------------- Stock ledger */
/** Kinds of stock move => label, icon and tone. */
function productStockTypes() {
return [
'received' => ['label' => 'Received', 'icon' => 'fa-dolly', 'tone' => 'success'],
'sold' => ['label' => 'Sold', 'icon' => 'fa-file-invoice-dollar', 'tone' => 'primary'],
'returned' => ['label' => 'Returned', 'icon' => 'fa-undo', 'tone' => 'info'],
'adjusted' => ['label' => 'Counted', 'icon' => 'fa-clipboard-check', 'tone' => 'secondary'],
'written_off' => ['label' => 'Written off', 'icon' => 'fa-minus-circle', 'tone' => 'danger'],
];
}
/** Why stock is written off. */
function productWriteOffReasons() {
return ['Damaged', 'Lost or stolen', 'Used by us', 'Sent back to the vendor', 'Other'];
}
/** What is on the shelf now. */
function productOnHand($product_id) {
global $mysqli;
return round(floatval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(stock_qty), 0) FROM product_stock WHERE stock_product_id = " . intval($product_id)))[0]), 2);
}
/** Tracked products at or under their reorder level, or out - in SQL, with products in the FROM. */
function productReorderSql() {
return "(products.product_track_stock = 1 AND products.product_archived_at IS NULL AND " . productStockSql() . " <= GREATEST(products.product_reorder_level, 0))";
}
/** How many tracked products need ordering. */
function productReorderCount() {
global $mysqli;
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM products WHERE " . productReorderSql()))[0]);
}
/**
* Record a stock move. $qty is signed - in above zero, out below. $type is one
* of productStockTypes(). $opts: note (raw), unit_cost (anything but a
* delivery takes the product's cost when not given), invoice_id, item_id,
* expense_id. Tells
* agents when it takes the product to its reorder level or out. The move's id,
* or 0 when there is nothing to record.
*/
function productStockMove($product_id, $qty, $type, array $opts = []) {
global $mysqli, $session_user_id;
$product_id = intval($product_id);
$qty = round(floatval($qty), 2);
if (!$product_id || $qty == 0 || !isset(productStockTypes()[$type])) {
return 0;
}
$before = productOnHand($product_id);
$unit_cost = $opts['unit_cost'] ?? null;
// What it cost - given for a delivery; anything else is at the product's cost now
if (($unit_cost === null || $unit_cost === '') && $type !== 'received') {
$unit_cost = getFieldById('products', $product_id, 'product_cost');
}
$unit_cost = $unit_cost === null || $unit_cost === '' ? 'NULL' : round(floatval($unit_cost), 2);
$note = escapeSql(mb_substr(trim((string) ($opts['note'] ?? '')), 0, 1000));
$invoice_id = intval($opts['invoice_id'] ?? 0);
$item_id = intval($opts['item_id'] ?? 0);
$expense_id = intval($opts['expense_id'] ?? 0);
$user_id = intval($session_user_id ?? 0);
mysqli_query($mysqli, "INSERT INTO product_stock SET stock_qty = $qty, stock_type = '$type', stock_unit_cost = $unit_cost, stock_note = '$note',
stock_expense_id = " . ($expense_id ?: 'NULL') . ", stock_invoice_id = $invoice_id, stock_item_id = " . ($item_id ?: 'NULL') . ",
stock_user_id = $user_id, stock_product_id = $product_id");
$stock_id = intval(mysqli_insert_id($mysqli));
productReorderCheck($product_id, $before, round($before + $qty, 2));
return $stock_id;
}
/** Tell agents when stock going out reaches the reorder level, or runs out. */
function productReorderCheck($product_id, $before, $after) {
if ($after >= $before) {
return;
}
$product = productRow($product_id);
if (!$product || !intval($product['product_track_stock'])) {
return;
}
$level = floatval($product['product_reorder_level']);
$name = $product['product_name'];
if ($after <= 0 && $before > 0) {
$text = "$name is out of stock";
} elseif ($level > 0 && $before > $level && $after <= $level) {
$text = "$name is down to " . billingQuantity($after) . " - reorder at " . billingQuantity($level);
} else {
return;
}
if (floatval($product['product_reorder_qty']) > 0) {
$text .= ', order ' . billingQuantity($product['product_reorder_qty']);
}
if ($product['vendor_name']) {
$text .= ' from ' . $product['vendor_name'];
}
appNotify('Low Stock', escapeSql($text), 'product.php?product_id=' . intval($product['product_id']), 0, intval($product['product_id']));
}
/**
* Put back what an invoice took off the shelf and still has - when it is
* cancelled or deleted. $why is the note, raw. The number of products returned.
*/
function productStockReturnInvoice($invoice_id, $why) {
global $mysqli;
$invoice_id = intval($invoice_id);
$returned = 0;
$sql = mysqli_query($mysqli, "SELECT stock_product_id, SUM(stock_qty) AS net FROM product_stock WHERE stock_invoice_id = $invoice_id GROUP BY stock_product_id HAVING net < 0");
while ($row = mysqli_fetch_assoc($sql)) {
if (productStockMove($row['stock_product_id'], -floatval($row['net']), 'returned', ['invoice_id' => $invoice_id, 'note' => $why])) {
$returned++;
}
}
return $returned;
}
/**
* Lines about to go on an invoice that take tracked products past what is on
* the shelf - the first one as a message, or '' when there is enough of all.
*/
function productStockShortfall(array $items) {
global $mysqli;
$needed = [];
foreach ($items as $item) {
$id = intval($item['item_product_id'] ?? 0);
if ($id && floatval($item['item_quantity']) > 0) {
$needed[$id] = ($needed[$id] ?? 0) + floatval($item['item_quantity']);
}
}
if (!$needed) {
return '';
}
$sql = mysqli_query($mysqli, "SELECT product_id, product_name, " . productStockSql() . " AS on_hand FROM products
WHERE product_track_stock = 1 AND product_id IN (" . implode(',', array_keys($needed)) . ")");
while ($row = mysqli_fetch_assoc($sql)) {
$want = round($needed[intval($row['product_id'])], 2);
if (floatval($row['on_hand']) < $want) {
return 'Not enough ' . $row['product_name'] . ' in stock - ' . billingQuantity($row['on_hand']) . ' left, ' . billingQuantity($want) . ' needed.';
}
}
return '';
}
/**
* A product's stock moves, newest first, with the balance after each, the
* invoice or expense behind it and who made it. Invoices of clients the agent
* can't open have no link ('invoice_open' 0).
*/
function productStockMoves($product_id, $limit = 25) {
global $mysqli;
$product_id = intval($product_id);
$limit = max(1, intval($limit));
$balance = productOnHand($product_id);
$rows = [];
$sql = mysqli_query($mysqli, "SELECT stock_id, stock_qty, stock_type, stock_unit_cost, stock_note, stock_created_at, stock_expense_id, stock_invoice_id,
expense_description, expense_date, expense_amount, expense_currency_code, invoice_prefix, invoice_number, invoice_client_id, user_name
FROM product_stock
LEFT JOIN expenses ON expense_id = stock_expense_id
LEFT JOIN invoices ON invoice_id = stock_invoice_id AND stock_invoice_id > 0
LEFT JOIN users ON user_id = stock_user_id AND stock_user_id > 0
WHERE stock_product_id = $product_id
ORDER BY stock_id DESC LIMIT $limit");
while ($row = mysqli_fetch_assoc($sql)) {
$row['balance'] = $balance;
$balance = round($balance - floatval($row['stock_qty']), 2);
$row['invoice_open'] = $row['invoice_number'] !== null && hasClientAccess(intval($row['invoice_client_id'])) ? 1 : 0;
$rows[] = $row;
}
return $rows;
}
/** A move's quantity with its sign - "+4", "-2", "+1.5". */
function productStockQty($qty) {
$qty = floatval($qty);
return ($qty > 0 ? '+' : '') . billingQuantity($qty);
}
/*
* Sold hardware becomes client assets. A product with an asset type is
* hardware: when it is sold on an invoice, each unit can go on the client's
* assets - as a new asset, or one already on file linked to the sale - with
* its serial and who has it, the make and model, bought on the invoice date
* and under warranty for the product's months from it. The asset keeps the
* product, the invoice and the line, so each line knows how many of its
* units are recorded.
*/
/** Asset types => icon class (fa-laptop) - the list in load_global_settings.php. */
function productAssetTypes() {
global $asset_types_array;
return is_array($asset_types_array ?? null) ? $asset_types_array : [];
}
/** An asset type as the list spells it, matched without case - '' when it isn't one. */
function productAssetType($value) {
$value = trim((string) $value);
foreach (array_keys(productAssetTypes()) as $asset_type) {
if (strcasecmp($asset_type, $value) === 0) {
return $asset_type;
}
}
return '';
}
/** An asset type's round icon, as on the assets list - $size '' or sm. */
function productAssetIcon($asset_type, $size = '') {
return '<span class="itflow-asset-icon' . ($size ? " itflow-asset-icon-$size" : '') . ' itflow-asset-tone-' . assetTypeTone($asset_type) . '" aria-hidden="true">'
. '<i class="fas fa-fw fa-' . escapeHtml(getAssetIcon($asset_type)) . '"></i></span>';
}
/** Hardware, in SQL - with products in the FROM. */
function productHardwareSql() {
return "products.product_type = 'product' AND products.product_asset_type IS NOT NULL AND products.product_asset_type <> ''";
}
/** How many units a line stands for - its quantity rounded up, none for a credit. */
function productHardwareUnits($qty) {
$qty = round(floatval($qty), 2);
return $qty > 0 ? (int) ceil($qty) : 0;
}
/** When a warranty of $months from $date (Y-m-d) runs out - the 31st becomes the month's last day. Null for none. */
function productWarrantyUntil($date, $months) {
$months = intval($months);
if ($months <= 0 || !preg_match('/^(\d{4})-(\d{2})-(\d{2})/', (string) $date, $m)) {
return null;
}
$month = intval($m[2]) - 1 + $months;
$year = intval($m[1]) + intdiv($month, 12);
$month = $month % 12 + 1;
$day = min(intval($m[3]), intval(date('t', mktime(0, 0, 0, $month, 1, $year))));
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* The invoices whose hardware counts as sold and waiting to be recorded, in
* SQL with invoices in the FROM: not drafts or cancelled, and not made by a
* recurring invoice (hardware billed each month is rented, not sold).
*/
function productHardwareInvoiceSql() {
return "invoices.invoice_status NOT IN ('Draft', 'Cancelled') AND invoices.invoice_recurring_invoice_id = 0";
}
/** Lines with units still to add to assets - a condition on invoice_items joined to products. */
function productHardwareShortSql() {
return productHardwareSql() . " AND invoice_items.item_quantity > 0
AND CEIL(ROUND(invoice_items.item_quantity, 2)) > (SELECT COUNT(*) FROM assets
WHERE assets.asset_invoice_id = invoice_items.item_invoice_id AND assets.asset_invoice_item_id = invoice_items.item_id)";
}
/**
* Hardware sold in the last $days days, up to today, that isn't on the
* client's assets yet: ['units', 'invoices']. Scoped to the agent's clients.
*/
function productHardwarePending($days = 90) {
global $mysqli;
$days = max(1, intval($days));
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(units - made), 0) AS units, COUNT(DISTINCT invoice_id) AS invoices FROM (
SELECT invoice_id, CEIL(ROUND(item_quantity, 2)) AS units,
(SELECT COUNT(*) FROM assets WHERE asset_invoice_id = invoice_id AND asset_invoice_item_id = item_id) AS made
FROM invoice_items
JOIN invoices ON invoice_id = item_invoice_id
JOIN products ON product_id = item_product_id
WHERE " . productHardwareSql() . " AND item_quantity > 0 AND " . productHardwareInvoiceSql() . "
AND invoice_date BETWEEN CURDATE() - INTERVAL $days DAY AND CURDATE() " . clientScopeSql('invoice_client_id') . "
) AS hardware WHERE units > made"));
return ['units' => intval($row['units'] ?? 0), 'invoices' => intval($row['invoices'] ?? 0)];
}
/**
* An invoice's hardware lines and the assets made from each - for its
* Hardware card and the Add to Assets window. Each line: item_id, item_name,
* item_description, item_quantity, units, product_id, product_name,
* product_asset_type, product_make, product_model, product_warranty_months,
* assets (asset_id, asset_name, asset_serial, asset_type, asset_status,
* asset_client_id, asset_archived_at) and to_add.
*/
function productInvoiceHardware($invoice_id) {
global $mysqli;
$invoice_id = intval($invoice_id);
$lines = [];
$sql = mysqli_query($mysqli, "SELECT item_id, item_name, item_description, item_quantity, product_id, product_name,
product_asset_type, product_make, product_model, product_warranty_months
FROM invoice_items
JOIN products ON product_id = item_product_id
WHERE item_invoice_id = $invoice_id AND " . productHardwareSql() . "
ORDER BY item_order ASC, item_id ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$row['units'] = productHardwareUnits($row['item_quantity']);
$row['assets'] = [];
$lines[intval($row['item_id'])] = $row;
}
if ($lines) {
$sql = mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_serial, asset_type, asset_status, asset_client_id, asset_archived_at, asset_invoice_item_id
FROM assets
WHERE asset_invoice_id = $invoice_id AND asset_invoice_item_id IN (" . implode(', ', array_keys($lines)) . ")
ORDER BY asset_name ASC, asset_id ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$lines[intval($row['asset_invoice_item_id'])]['assets'][] = $row;
}
}
foreach ($lines as &$line) {
$line['to_add'] = max(0, $line['units'] - count($line['assets']));
}
unset($line);
return array_values($lines);
}
/** Assets made from a product, newest first - its installed base. Scoped to the agent's clients. */
function productAssets($product_id, $limit = 200) {
global $mysqli;
$product_id = intval($product_id);
$limit = max(1, intval($limit));
$assets = [];
$sql = mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_serial, asset_type, asset_status, asset_warranty_expire, asset_purchase_date, asset_archived_at,
client_id, client_name, invoice_id, invoice_prefix, invoice_number, invoice_date
FROM assets
JOIN clients ON client_id = asset_client_id
LEFT JOIN invoices ON invoice_id = asset_invoice_id AND asset_invoice_id > 0
WHERE asset_product_id = $product_id " . clientScopeSql('asset_client_id') . "
ORDER BY asset_archived_at IS NOT NULL, asset_purchase_date DESC, asset_id DESC
LIMIT $limit");
while ($row = mysqli_fetch_assoc($sql)) {
$assets[] = $row;
}
return $assets;
}
/** The client's assets that could be the hardware sold - not archived, not from a sale already. Same type first. */
function productAssetCandidates($client_id, $asset_type = '') {
global $mysqli;
$client_id = intval($client_id);
$asset_type = escapeSql($asset_type);
$assets = [];
$sql = mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_type, asset_serial, asset_make, asset_model
FROM assets
WHERE asset_client_id = $client_id AND asset_archived_at IS NULL AND asset_invoice_item_id = 0
ORDER BY asset_type = '$asset_type' DESC, asset_type ASC, asset_name ASC
LIMIT 300");
while ($row = mysqli_fetch_assoc($sql)) {
$assets[] = $row;
}
return $assets;
}
/**
* Put an invoice's hardware on the client's assets. Raw input:
* $lines item_id => ['make', 'model', 'warranty' (Y-m-d, '' for none)]
* $units item_id => list of ['name', 'serial', 'contact', 'existing']
* - existing is one of the client's assets to link instead of adding one
* $shared ['status', 'location'] for every one added
* Only hardware lines still short are used, and no more units than each is
* short. Returns ['added' => n, 'linked' => n], or null with $error.
*/
function productSaleAssets($invoice_id, array $lines, array $units, array $shared, &$error = '') {
global $mysqli, $session_name;
$error = '';
$invoice = billingRow('invoice', $invoice_id);
if (!$invoice) {
$error = 'That invoice no longer exists.';
return null;
}
$invoice_id = intval($invoice['invoice_id']);
$client_id = intval($invoice['client_id']);
$number = billingNumber('invoice', $invoice);
if ($invoice['invoice_status'] === 'Cancelled') {
$error = "$number is cancelled - nothing on it was sold.";
return null;
}
$date = substr((string) $invoice['invoice_date'], 0, 10);
$status = trim((string) ($shared['status'] ?? ''));
if ($status !== '' && !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM categories WHERE category_type = 'asset_status' AND category_name = '" . escapeSql($status) . "'"))) {
$status = '';
}
$location_id = assetClientOwnedId('location', $shared['location'] ?? 0, $client_id);
// Check it all before anything is written
$plan = [];
$picked = [];
foreach (productInvoiceHardware($invoice_id) as $line) {
$item_id = intval($line['item_id']);
$rows = array_values(array_filter((array) ($units[$item_id] ?? []), 'is_array'));
if (!$rows || !$line['to_add']) {
continue;
}
$rows = array_slice($rows, 0, $line['to_add']);
$line_in = (array) ($lines[$item_id] ?? []);
$make = trim((string) ($line_in['make'] ?? $line['product_make']));
$model = trim((string) ($line_in['model'] ?? $line['product_model']));
$warranty = trim((string) ($line_in['warranty'] ?? ''));
if ($warranty !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $warranty) || !strtotime($warranty))) {
$error = "The warranty date for {$line['item_name']} isn't a date.";
return null;
}
foreach ($rows as $n => $row) {
$existing = intval($row['existing'] ?? 0);
if ($existing) {
$asset = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_invoice_item_id FROM assets WHERE asset_id = $existing AND asset_client_id = $client_id"));
if (!$asset || intval($asset['asset_invoice_item_id']) || isset($picked[$existing])) {
$error = isset($picked[$existing]) ? "{$asset['asset_name']} is picked twice." : "An asset picked for {$line['item_name']} isn't one of {$invoice['client_name']}'s, or came from another sale.";
return null;
}
$picked[$existing] = true;
$plan[] = ['line' => $line, 'existing' => $existing, 'name' => $asset['asset_name'], 'make' => $make, 'model' => $model, 'warranty' => $warranty];
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name === '') {
$error = "Each new asset needs a name - " . ($n + 1) . " of {$line['item_name']} has none.";
return null;
}
$plan[] = ['line' => $line, 'existing' => 0, 'name' => mb_substr($name, 0, 200), 'serial' => mb_substr(trim((string) ($row['serial'] ?? '')), 0, 200),
'contact' => assetClientOwnedId('contact', $row['contact'] ?? 0, $client_id), 'make' => $make, 'model' => $model, 'warranty' => $warranty];
}
}
if (!$plan) {
$error = "Nothing to add - every unit of hardware on $number is on {$invoice['client_name']}'s assets.";
return null;
}
$by = stripslashes((string) $session_name);
$ref = escapeSql(mb_substr($number, 0, 200));
$number_sql = escapeSql($number);
$done = ['added' => 0, 'linked' => 0];
mysqli_begin_transaction($mysqli);
try {
foreach ($plan as $unit) {
$line = $unit['line'];
$item_id = intval($line['item_id']);
$product_id = intval($line['product_id']);
$name = escapeSql($unit['name']);
$make = escapeSql(mb_substr($unit['make'], 0, 200));
$model = $unit['model'] === '' ? 'NULL' : "'" . escapeSql(mb_substr($unit['model'], 0, 200)) . "'";
$warranty = $unit['warranty'] === '' ? 'NULL' : "'" . escapeSql($unit['warranty']) . "'";
$status_sql = escapeSql($status);
if ($unit['existing']) {
$asset_id = $unit['existing'];
mysqli_query($mysqli, "UPDATE assets SET asset_product_id = $product_id, asset_invoice_id = $invoice_id, asset_invoice_item_id = $item_id,
asset_purchase_date = COALESCE(asset_purchase_date, '$date'),
asset_warranty_expire = COALESCE(asset_warranty_expire, $warranty),
asset_purchase_reference = IF(COALESCE(asset_purchase_reference, '') = '', '$ref', asset_purchase_reference),
asset_make = IF(asset_make = '', '$make', asset_make),
asset_model = IF(COALESCE(asset_model, '') = '', $model, asset_model)
WHERE asset_id = $asset_id");
$history = escapeSql(mb_substr("$by linked {$unit['name']} to $number, where it was sold", 0, 255));
mysqli_query($mysqli, "INSERT INTO asset_history SET asset_history_status = COALESCE((SELECT asset_status FROM assets WHERE asset_id = $asset_id), ''), asset_history_description = '$history', asset_history_asset_id = $asset_id");
logAudit("Asset", "Edit", "$session_name linked asset $name to invoice $number_sql", $client_id, $asset_id);
$done['linked']++;
continue;
}
$type = escapeSql($line['product_asset_type']);
$serial = $unit['serial'] === '' ? 'NULL' : "'" . escapeSql($unit['serial']) . "'";
$description = trim((string) $line['item_description']);
$description = $description === '' ? 'NULL' : "'" . escapeSql(mb_substr($description, 0, 255)) . "'";
$contact_id = intval($unit['contact']);
mysqli_query($mysqli, "INSERT INTO assets SET asset_type = '$type', asset_name = '$name', asset_description = $description, asset_make = '$make', asset_model = $model,
asset_serial = $serial, asset_status = '$status_sql', asset_purchase_reference = '$ref', asset_purchase_date = '$date', asset_warranty_expire = $warranty,
asset_location_id = $location_id, asset_contact_id = $contact_id, asset_client_id = $client_id,
asset_product_id = $product_id, asset_invoice_id = $invoice_id, asset_invoice_item_id = $item_id");
$asset_id = intval(mysqli_insert_id($mysqli));
mysqli_query($mysqli, "INSERT INTO asset_interfaces SET interface_name = '01', interface_primary = 1, interface_asset_id = $asset_id");
$history = escapeSql(mb_substr("$by created {$unit['name']} from $number", 0, 255));
mysqli_query($mysqli, "INSERT INTO asset_history SET asset_history_status = '$status_sql', asset_history_description = '$history', asset_history_asset_id = $asset_id");
logAudit("Asset", "Create", "$session_name created asset $name from invoice $number_sql", $client_id, $asset_id);
$done['added']++;
}
mysqli_commit($mysqli);
} catch (Throwable $e) {
mysqli_rollback($mysqli);
throw $e;
}
return $done;
}
/*
* Price changes reach the documents. When a product's price changes, its
* lines on recurring invoices, draft invoices and draft quotes keep the price
* they had. Those still at one of the product's old prices are listed to
* update. A price the product never had looks agreed with the client, so
* those lines are listed apart and left alone unless picked.
*/
/** The documents whose lines can follow a price change => label and icon. */
function productRepriceKinds() {
return [
'recurring' => ['label' => 'Recurring invoices', 'one' => 'Recurring invoice', 'icon' => 'fa-redo-alt'],
'invoice' => ['label' => 'Draft invoices', 'one' => 'Draft invoice', 'icon' => 'fa-file-invoice-dollar'],
'quote' => ['label' => 'Draft quotes', 'one' => 'Draft quote', 'icon' => 'fa-comment-dollar'],
];
}
/**
* Every line from a product that can still change price, in SQL to alias:
* kind, item_id, doc_id, number, status, client_id, item_name, item_price,
* item_quantity, item_product_id, frequency and currency. Recurring invoices
* not archived, draft invoices and draft quotes.
*/
function productRepriceUnionSql() {
return "SELECT 'recurring' AS kind, item_id, recurring_invoice_id AS doc_id, CONCAT(recurring_invoice_prefix, recurring_invoice_number) AS number,
IF(recurring_invoice_status = 1, 'Active', 'Paused') AS status, recurring_invoice_client_id AS client_id, item_name, item_price, item_quantity,
item_product_id, recurring_invoice_frequency AS frequency, recurring_invoice_currency_code AS currency
FROM recurring_invoice_items JOIN recurring_invoices ON recurring_invoice_id = item_recurring_invoice_id
WHERE recurring_invoice_archived_at IS NULL AND item_product_id > 0
UNION ALL
SELECT 'invoice', item_id, invoice_id, CONCAT(invoice_prefix, invoice_number), invoice_status, invoice_client_id, item_name, item_price, item_quantity,
item_product_id, NULL, invoice_currency_code
FROM invoice_items JOIN invoices ON invoice_id = item_invoice_id
WHERE invoice_status = 'Draft' AND invoice_archived_at IS NULL AND item_product_id > 0
UNION ALL
SELECT 'quote', item_id, quote_id, CONCAT(quote_prefix, quote_number), quote_status, quote_client_id, item_name, item_price, item_quantity,
item_product_id, item_frequency, quote_currency_code
FROM quote_items JOIN quotes ON quote_id = item_quote_id
WHERE quote_status = 'Draft' AND quote_archived_at IS NULL AND item_product_id > 0";
}
/** A line (the union aliased l) at one of its product's old prices, in SQL - with products joined. */
function productRepriceOldSql() {
return "EXISTS (SELECT 1 FROM product_prices WHERE price_product_id = l.item_product_id AND price_old = l.item_price)";
}
/** The filters the Update Prices page takes, as SQL on the union aliased l. */
function productRepriceFilterSql(array $filters) {
$sql = '';
if (!empty($filters['product_id'])) {
$sql .= ' AND l.item_product_id = ' . intval($filters['product_id']);
}
if (!empty($filters['client_id'])) {
$sql .= ' AND l.client_id = ' . intval($filters['client_id']);
}
if (!empty($filters['kind']) && isset(productRepriceKinds()[$filters['kind']])) {
$sql .= " AND l.kind = '{$filters['kind']}'";
if (!empty($filters['doc_id'])) {
$sql .= ' AND l.doc_id = ' . intval($filters['doc_id']);
}
}
return $sql;
}
/**
* How many lines are at an old price: ['lines', 'recurring', 'invoice',
* 'quote', 'products', 'monthly' - what updating the recurring ones would
* add a month at today's prices]. Same filters as productRepriceLines().
* Scoped to the agent's clients, archived products left out.
*/
function productRepriceCount(array $filters = []) {
global $mysqli;
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS `lines`, COALESCE(SUM(l.kind = 'recurring'), 0) AS recurring,
COALESCE(SUM(l.kind = 'invoice'), 0) AS invoice, COALESCE(SUM(l.kind = 'quote'), 0) AS quote, COUNT(DISTINCT l.item_product_id) AS products,
COALESCE(SUM(IF(l.kind = 'recurring', " . recurringInvoiceMonthlySql('(products.product_price - l.item_price) * l.item_quantity', 'l.frequency') . ", 0)), 0) AS monthly
FROM (" . productRepriceUnionSql() . ") AS l
JOIN products ON products.product_id = l.item_product_id
WHERE products.product_archived_at IS NULL AND l.item_price <> products.product_price AND " . productRepriceOldSql()
. productRepriceFilterSql($filters) . clientScopeSql('l.client_id')));
return array_map('floatval', $row ?: []) + ['lines' => 0, 'recurring' => 0, 'invoice' => 0, 'quote' => 0, 'products' => 0, 'monthly' => 0];
}
/** A product's last change of price - price_old, price_new, price_created_at - or null. */
function productPriceLastChange($product_id) {
global $mysqli;
$product_id = intval($product_id);
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT price_old, price_new, price_created_at FROM product_prices
WHERE price_product_id = $product_id AND price_old <> price_new ORDER BY price_created_at DESC, price_id DESC LIMIT 1")) ?: null;
}
/**
* The lines whose price isn't their product's, for the Update Prices page,
* by product: product_id => [product_id, product_name, product_type,
* product_price, product_currency_code, 'last' => productPriceLastChange(),
* 'lines' => [...]]. Each line is a productRepriceUnionSql() row with
* client_name, old (at one of the product's old prices) and suggested - the
* product's price, or for a price of its own the last change applied to it.
* Filters: product_id, client_id, kind and doc_id; up to 1000 lines.
*/
function productRepriceLines(array $filters = []) {
global $mysqli;
$groups = [];
$sql = mysqli_query($mysqli, "SELECT l.*, client_name, products.product_id, product_name, product_type, product_price, product_currency_code, " . productRepriceOldSql() . " AS old
FROM (" . productRepriceUnionSql() . ") AS l
JOIN products ON products.product_id = l.item_product_id
JOIN clients ON clients.client_id = l.client_id
WHERE products.product_archived_at IS NULL AND l.item_price <> products.product_price" . productRepriceFilterSql($filters) . clientScopeSql('l.client_id') . "
ORDER BY product_name ASC, products.product_id ASC, FIELD(l.kind, 'recurring', 'invoice', 'quote'), client_name ASC, l.doc_id ASC, l.item_id ASC
LIMIT 1000");
while ($row = mysqli_fetch_assoc($sql)) {
$product_id = intval($row['product_id']);
if (!isset($groups[$product_id])) {
$groups[$product_id] = [
'product_id' => $product_id, 'product_name' => $row['product_name'], 'product_type' => $row['product_type'], 'product_price' => $row['product_price'],
'product_currency_code' => $row['product_currency_code'], 'last' => productPriceLastChange($product_id), 'lines' => [],
];
}
$last = $groups[$product_id]['last'];
$row['old'] = intval($row['old']) === 1;
$row['suggested'] = !$row['old'] && $last && floatval($last['price_old']) > 0
? round(floatval($row['item_price']) * floatval($last['price_new']) / floatval($last['price_old']), 2)
: round(floatval($row['product_price']), 2);
$groups[$product_id]['lines'][] = $row;
}
return $groups;
}
/**
* Change the price of lines the Update Prices page lists. $changes: "kind:item_id"
* => the new price, raw. Every price is checked first. Each document's total
* follows, and its history says what changed. Returns ['lines' => n,
* 'documents' => n, 'monthly' => the change a month on recurring invoices],
* or null with $error.
*/
function productRepriceApply(array $changes, &$error = '') {
global $mysqli, $session_name;
$error = '';
$plan = [];
foreach ($changes as $key => $raw) {
if (!preg_match('/^(recurring|invoice|quote):(\d+)$/', (string) $key, $m)) {
continue;
}
[$kind, $item_id] = [$m[1], intval($m[2])];
$line = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT l.* FROM (" . productRepriceUnionSql() . ") AS l
JOIN products ON products.product_id = l.item_product_id
WHERE l.kind = '$kind' AND l.item_id = $item_id AND products.product_archived_at IS NULL" . clientScopeSql('l.client_id')));
if (!$line) {
$error = 'A line picked is no longer on a recurring invoice, draft or draft quote you can change - nothing was updated.';
return null;
}
$price = billingNumberInput($raw);
if ($price === null || $price < 0 || $price >= 1e11) {
$error = "The new price for {$line['item_name']} on {$line['number']} has to be a number, zero or more - nothing was updated.";
return null;
}
if (round($price, 2) != round(floatval($line['item_price']), 2)) {
$plan[] = [$line, $price];
}
}
if (!$plan) {
$error = 'Nothing to update - pick the lines to change, with a new price.';
return null;
}
$by = stripslashes((string) $session_name);
$done = ['lines' => 0, 'documents' => 0, 'monthly' => 0.0];
$documents = [];
mysqli_begin_transaction($mysqli);
try {
foreach ($plan as [$line, $price]) {
$kind = $line['kind'];
billingItemSetPrice($kind, $line['doc_id'], $line['item_id'], $price);
$from = billingMoney($line['item_price'], $line['currency']);
$to = billingMoney($price, $line['currency']);
billingLog($kind, $line['doc_id'], $kind === 'recurring' ? 'Price' : $line['status'], "{$line['item_name']} from $from to $to by $by - a catalog price change");
if ($kind === 'recurring') {
$done['monthly'] += recurringInvoiceMonthly(($price - floatval($line['item_price'])) * floatval($line['item_quantity']), $line['frequency']);
}
$documents["$kind:{$line['doc_id']}"] = true;
$done['lines']++;
}
mysqli_commit($mysqli);
} catch (Throwable $e) {
mysqli_rollback($mysqli);
throw $e;
}
$done['documents'] = count($documents);
$done['monthly'] = round($done['monthly'], 2);
return $done;
}
/**
* The margin on a document's lines that come from a product with a cost, at
* today's cost - for the agent's eyes only. By how often each line is billed
* for a quote ('' once, month, quarter, year), otherwise under ''. Each:
* ['cost', 'revenue', 'margin', 'percent', 'lines']. Empty when no line has a
* cost to go on.
*/
function billingMargin($type, array $items) {
global $mysqli;
$ids = [];
foreach ($items as $item) {
if (intval($item['item_product_id'] ?? 0)) {
$ids[intval($item['item_product_id'])] = true;
}
}
if (!$ids) {
return [];
}
$costs = [];
$sql = mysqli_query($mysqli, "SELECT product_id, product_cost FROM products WHERE product_cost > 0 AND product_id IN (" . implode(',', array_keys($ids)) . ")");
while ($row = mysqli_fetch_assoc($sql)) {
$costs[intval($row['product_id'])] = floatval($row['product_cost']);
}
$groups = [];
foreach ($items as $item) {
$id = intval($item['item_product_id'] ?? 0);
if (!isset($costs[$id])) {
continue;
}
$key = $type === 'quote' ? quoteFrequency($item['item_frequency'] ?? '') : '';
$groups[$key] = $groups[$key] ?? ['cost' => 0.0, 'revenue' => 0.0, 'lines' => 0];
$groups[$key]['cost'] += round(floatval($item['item_quantity']) * $costs[$id], 2);
$groups[$key]['revenue'] += floatval($item['item_subtotal']);
$groups[$key]['lines']++;
}
$order = array_flip(array_keys(quoteFrequencies()));
uksort($groups, function ($a, $b) use ($order) {
return ($order[$a] ?? 9) <=> ($order[$b] ?? 9);
});
foreach ($groups as $key => $group) {
$groups[$key]['margin'] = round($group['revenue'] - $group['cost'], 2);
$groups[$key]['percent'] = $group['revenue'] > 0 ? round(($group['revenue'] - $group['cost']) / $group['revenue'] * 100, 1) : null;
}
return $groups;
}
/** A document's margin for its sidebar - "$1,203.00 (41%) up front, $612.00 (30%) a month". '' when none. */
function billingMarginHtml($type, array $items, $currency, $period = '') {
$groups = billingMargin($type, $items);
if (!$groups) {
return '';
}
$parts = [];
$lines = 0;
foreach ($groups as $key => $group) {
$lines += $group['lines'];
$when = $type === 'quote' ? ($key === '' ? ' up front' : ' a ' . recurringInvoicePeriodWord($key)) : ($period !== '' ? " a $period" : '');
$percent = $group['percent'] === null ? '' : ' <span class="text-body-secondary">(' . escapeHtml(rtrim(rtrim(number_format($group['percent'], 1), '0'), '.')) . '%)</span>';
$tone = $group['margin'] < 0 ? 'text-danger-emphasis' : '';
$parts[] = '<span class="' . $tone . '">' . escapeHtml(billingMoney($group['margin'], $currency)) . '</span>' . $percent . escapeHtml($when);
}
return '<span title="On the ' . $lines . ' line' . ($lines === 1 ? '' : 's') . ' from products with a cost, at today\'s cost - only you see this">' . implode(', ', $parts) . '</span>';
}