diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d9a4861..977ab7565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 2.9.6) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 2.9.7) in the same pass. Nothing else is required. -This is a big release, with seventeen database steps in one go, so take a backup before you update. +This is a big release, with eighteen database steps in one go, so take a backup before you update. The update also clears out data that deletes in older versions left behind, all of it pointing at something that no longer exists: @@ -17,7 +17,7 @@ The update also clears out data that deletes in older versions left behind, all - the tasks, approvals and history of deleted tickets, the projects of deleted clients, and tickets still pointing at a deleted project - project template entries whose ticket template is gone, and the same service link saved twice -It also gives every existing network a type (LAN, WAN, Guest and so on) guessed from its name or description, which you can change afterwards, and fills in the issue date, names covered and fingerprint of every certificate already on file. Invoices made from a quote before the update are linked to that quote, so each one shows where it came from. Products that ever had stock added keep tracking their stock, and the rest are set to ordered as needed (see Breaking Changes, Products). Their past stock changes are sorted into received, sold and returned from their notes, and linked to the invoice they name. No product becomes a client asset when it is sold until you give it an asset type (see New Features, Products). +It also gives every existing network a type (LAN, WAN, Guest and so on) guessed from its name or description, which you can change afterwards, and fills in the issue date, names covered and fingerprint of every certificate already on file. Invoices made from a quote before the update are linked to that quote, so each one shows where it came from. Products that ever had stock added keep tracking their stock, and the rest are set to ordered as needed (see Breaking Changes, Products). Their past stock changes are sorted into received, sold and returned from their notes, and linked to the invoice they name. No product becomes a client asset when it is sold until you give it an asset type (see New Features, Products). Each product's price history starts with the update, so lines on recurring invoices are only flagged as behind once their product's price changes. If you are coming from 26.08 or earlier, the database update still has to be run from the command line one time, exactly as described under Upgrading to 26.09 below. @@ -124,7 +124,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - **Dashboard** (rebuilt) - Needs attention along the top: - tickets past their SLA, unassigned tickets and overdue projects - - overdue and draft invoices, resolved tickets not invoiced yet, stock to reorder, and hardware sold in the last 90 days that isn't on the client's assets yet + - overdue and draft invoices, resolved tickets not invoiced yet, stock to reorder, lines on recurring invoices, drafts and quotes at an old price, and hardware sold in the last 90 days that isn't on the client's assets yet - domains, certificates, licenses and warranties expiring in the next 30 days - services needing attention and service reviews due @@ -272,6 +272,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Products has a Hardware tab, and each row says the asset it becomes. - The packing slip lists the serial numbers under each line. - The starter catalog's hardware comes with its asset type and warranty, and so does the demo data's. + - Price changes reach recurring invoices: + - Every change to a product's price or cost is kept, with who made it and whether it was edited, imported or came from received stock, on a Prices tab on its page. Pricing says when its price last changed and from what. + - Lines on recurring invoices, draft invoices and draft quotes still at one of its old prices are flagged on the product's page, the Products page, each of those documents and Needs attention on the dashboard, with what updating them adds a month. + - Update Prices lists them by product - all of them from the Products menu, or one product's, one client's or one document's. Lines at an old price are picked, ready for today's price. A price the product never had looks agreed with the client, so it is marked Own price and left alone unless picked, with the last change applied to it as the suggestion. Each new price can be changed, Catalog price or the same percent fills in a product's lines at once, and the footer says what recurring invoices bring in a month after. Invoices already sent, and quotes out with clients, keep their prices. + - Each line changed goes in its document's history, and the document's tax and total follow. + - Saving a new price, or importing a price list, says how many lines are at an old price. - Import takes every field, including Reorder At, Order, Asset Type, Make, Model and Warranty. Categories, taxes and vendors are matched by name. A new price list can update products by their code, and a file with only Code and Price will do. Download the template for the layout. - Export adds cost, margin, how it is billed, vendor, part number, stock and its value, the reorder level, and the asset it becomes with its make, model and warranty. It can export just what is to reorder, or just hardware. - Invoices, quotes and recurring invoices show agents their margin on lines from products with a cost, at today's cost. A quote shows it up front and each month. @@ -521,6 +527,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 2.9.4: `products.product_cost`, `product_vendor_id`, `product_vendor_part`, `product_notes`, `product_frequency` (NULL once, or `month`, `quarter`, `year`), `product_seat_source` (NULL, `contacts` or `assets`) and `product_track_stock`, set for products with any `product_stock` rows. Keys on `item_product_id` in `invoice_items`, `quote_items` and `recurring_invoice_items`, and on `product_stock.stock_product_id`. - 2.9.5: `product_stock.stock_qty` becomes `decimal(15,2)`, with `stock_type` (`received`, `sold`, `returned`, `adjusted` or `written_off`, default `adjusted`), `stock_unit_cost`, `stock_invoice_id` (keyed) and `stock_user_id`. Existing rows take their invoice from a note ending "Invoice n" and their type from the sign and the note. `products.product_reorder_level` and `product_reorder_qty`. - 2.9.6: `products.product_asset_type` (one of the asset types, NULL for none), `product_make`, `product_model` and `product_warranty_months`. `assets.asset_product_id`, `asset_invoice_id` and `asset_invoice_item_id`, keyed on the product and the invoice. + - 2.9.7: `product_prices` - a product's price and cost history: `price_old`, `price_new`, `price_cost_old`, `price_cost_new`, `price_source` (`edit`, `import` or `receive`), `price_user_id` and `price_product_id`, keyed. - New shared function files, loaded by `functions.php`: `functions/asset.php`, `asset_interface.php`, `certificate.php`, `client_overview.php`, `contact.php`, `credential.php`, `dashboard.php`, `links.php`, `location.php`, `notifications.php`, `project.php`, `rack.php`, `search.php`, `service.php`, `software.php`, `tags.php`, `ticket.php`, `ticket_fields.php`, `user.php` and `vendor.php`. They hold the queries and rules, so the pages, the API and cron share one definition. - Removed post models: `agent/post/{asset_interface,certificate,credential,domain,event,location,network_ip,network,recurring_ticket,vendor}_model.php`, `admin/post/user_model.php` and `api/v1/certificates/certificate_model.php`. Their handlers call the shared functions above. Interface handlers moved to `agent/post/asset_interface.php`. - New scripts: `js/itflow_select.js`, `quick_pick.js`, `tag_picker.js`, `global_search.js`, `notifications.js`, `dashboard.js`, `client_overview.js`, `client_switcher.js`, `credentials.js` and `autosubmit.js`, and `agent/js/calendar.js`, `calendar_event_modal.js`, `files.js`, `interface_form.js`, `network_form.js`, `project_modal.js` and `ticket_schedule_modal.js`. @@ -644,6 +651,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Sold hardware, in `functions/products.php`: `productAssetTypes()` (the `$asset_types_array` list), `productAssetType()`, `productAssetIcon()`, `productHardwareSql()`, `productHardwareUnits()` (a line's quantity rounded up, none for a credit), `productWarrantyUntil()`, `productHardwareInvoiceSql()` (not drafts or cancelled, not made by a recurring invoice), `productHardwareShortSql()`, `productHardwarePending($days)`, `productInvoiceHardware($invoice_id)`, `productAssets($product_id)` and `productAssetCandidates()`. `productSaleAssets($invoice_id, $lines, $units, $shared, &$error)` checks every row first, then adds or links them in one transaction, each new one with its primary interface, and each with a history line and an audit log entry. - Handler `invoice_assets_save` (POST `invoice_id`, `status`, `location`, `lines[item_id][make|model|warranty]` and `units[item_id][n][name|serial|contact|existing]`; needs Sales, and Support at edit) from `agent/modals/invoice/invoice_assets.php?invoice_id=`. `billingItemDelete()` clears `asset_invoice_item_id` on the line's assets and `delete_invoice` clears both invoice links - the assets stay. - `productSave()` and `productCopy()` take `asset_type`, `make`, `model` and `warranty_months`, with import aliases and export columns for them. The products list and export take `type=hardware`, and `invoices.php` takes `hardware=1`. `dashboardAttention()` adds Hardware not on assets for agents with Support access, `billingPdfRender()` reads the serials for the packing slip, and the asset page joins the invoice and the product. Seeds: columns 12 to 15 on `demoDataProducts()` and an eighth and ninth on `starterContentProducts()`. CSS: `.itflow-hw-*`. + - Price history: `productSave($in, $product_id, &$error, $source)` records a change to an existing product's price or cost through `productPriceRecord()` - `edit_product` passes `edit`, `import_products_csv` passes `import` and `product_stock_save` passes `receive` when a delivery sets the cost. Also `productPriceSources()`, `productPrices()` and `productPriceLastChange()`. `productDelete()` and the demo data removal delete the history too. + - Repricing: `productRepriceKinds()`, `productRepriceUnionSql()` (every line from a product on recurring invoices not archived, draft invoices and draft quotes, as one union), `productRepriceOldSql()` (at a price in the product's history), `productRepriceFilterSql()`, `productRepriceCount($filters)` (lines, by kind, products, and the change a month on recurring invoices) and `productRepriceLines($filters)` (by product, with `old` and `suggested`). `productRepriceApply($changes, &$error)` checks every line and price, then changes them in one transaction through `billingItemSetPrice($type, $doc_id, $item_id, $price)` (new in `functions/billing.php` - the line's amounts at its tax rate, then `billingRecalc()`), with a history line on each document. + - New page `agent/products_reprice.php` (`?product_id=`, `client_id`, `kind` and `doc_id`) posting `products_reprice_save` (`pick[]` of `kind:item_id`, and `price[kind:item_id]`). `dashboardAttention()` adds Lines at an old price, and the invoice, quote and recurring invoice pages link their own. CSS: `.itflow-reprice-*`. - The billing helpers (`billingTimeInvoice()`, `quoteConvert()`, `recurringInvoiceGenerate()` and the rest) take the agent's name as plain text. Their handlers now pass `stripslashes($session_name)`, so a name with an apostrophe no longer shows a backslash in history and the audit log. - Sidebar brand: `agent/css/sidebar_brand_fix.css` and `agent/js/sidebar_brand_fix.js` from 26.10 are replaced by CSS in `css/itflow_custom.css`. `includes/load_company_settings.php` now sets `$session_company_logo`. - `alertMessageHtml()` no longer double-encodes, and save messages built from `escapeSql()` values use `stripslashes()` at the flash. diff --git a/admin/database_updates/2.9.7.php b/admin/database_updates/2.9.7.php new file mode 100644 index 000000000..092d21fa6 --- /dev/null +++ b/admin/database_updates/2.9.7.php @@ -0,0 +1,26 @@ +
Margin
+ 'invoice', 'doc_id' => $invoice_id]) : null; if ($reprice_doc && $reprice_doc['lines']) { ?> +
Prices
+
line at an old price
+
Client
Not sent yet - the client can't see it
diff --git a/agent/post/product.php b/agent/post/product.php index dbb26f856..3995aa833 100644 --- a/agent/post/product.php +++ b/agent/post/product.php @@ -55,6 +55,7 @@ if (isset($_POST['edit_product'])) { enforceUserPermission('module_sales', 2); $product_id = intval($_POST['product_id'] ?? 0); + $price_before = getFieldById('products', $product_id, 'product_price'); if (!productSave(productFormInput(), $product_id, $error)) { flashAlert(escapeHtml($error), 'error'); @@ -65,7 +66,17 @@ if (isset($_POST['edit_product'])) { logAudit("Product", "Edit", "$session_name edited product $name", 0, $product_id); - flashAlert("" . escapeHtml($product['product_name']) . " saved"); + // A new price - say how many lines are still at the old one + $price_note = ''; + if ($price_before !== null && round(floatval($price_before), 2) != round(floatval($product['product_price']), 2)) { + $behind = productRepriceCount(['product_id' => $product_id]); + $price_note = " - its price is now " . escapeHtml(billingMoney($product['product_price'], $product['product_currency_code'])); + if ($behind['lines']) { + $price_note .= ", and " . intval($behind['lines']) . " line" . ($behind['lines'] == 1 ? ' is' : 's are') . " on recurring invoices, drafts or quotes at an old price. Update them from its page."; + } + } + + flashAlert("" . escapeHtml($product['product_name']) . " saved$price_note"); redirect(); @@ -453,7 +464,8 @@ if (isset($_POST['product_stock_save'])) { productStockMove($product_id, $qty, 'received', ['unit_cost' => $unit_cost === '' ? null : $unit_cost, 'expense_id' => $expense, 'note' => $note]); $cost_note = ''; if ($unit_cost !== '' && !empty($_POST['update_cost']) && round(floatval($unit_cost), 2) != round(floatval($product['product_cost']), 2)) { - productSave(['cost' => $unit_cost], $product_id); + $cost_error = ''; + productSave(['cost' => $unit_cost], $product_id, $cost_error, 'receive'); $cost_note = ', and its cost is now ' . billingMoney($unit_cost, $product['product_currency_code']); logAudit("Product", "Edit", "$session_name changed the cost of product $product_name to " . escapeSql(billingMoney($unit_cost, $product['product_currency_code'])) . " from a delivery", 0, $product_id); } @@ -692,7 +704,7 @@ if (isset($_POST["import_products_csv"])) { } $error = ''; - if (productSave($in, $existing_id, $error)) { + if (productSave($in, $existing_id, $error, 'import')) { $existing_id ? $updated++ : $added++; } else { $skipped[] = "row $line - " . rtrim($error, '.'); @@ -706,12 +718,51 @@ if (isset($_POST["import_products_csv"])) { if ($skipped) { $message .= ", " . count($skipped) . " skipped: " . escapeHtml(implode('; ', array_slice($skipped, 0, 5))) . (count($skipped) > 5 ? '...' : ''); } + // A new price list - say how many lines are at an old price now + if ($updated) { + $behind = productRepriceCount(); + if ($behind['lines']) { + $message .= ". " . intval($behind['lines']) . " line" . ($behind['lines'] == 1 ? ' is' : 's are') . " on recurring invoices, drafts or quotes at an old price - Update Prices is in the Products menu"; + } + } flashAlert($message, $skipped && !$added && !$updated ? 'error' : 'success'); redirect(); } +if (isset($_POST['products_reprice_save'])) { + + validateCSRFToken(); + + enforceUserPermission('module_sales', 2); + + // Only the lines picked, at the price typed for each + $changes = []; + foreach ((array) ($_POST['pick'] ?? []) as $key) { + $key = (string) $key; + $changes[$key] = (string) ($_POST['price'][$key] ?? ''); + } + + // functions/products.php - every price is checked before any line changes + $done = productRepriceApply($changes, $error); + if ($done === null) { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } + + logAudit("Product", "Edit", "$session_name updated the price of {$done['lines']} line(s) on {$done['documents']} recurring invoice(s), draft(s) and quote(s) from the catalog"); + + $message = "{$done['lines']} price" . ($done['lines'] === 1 ? '' : 's') . " updated on {$done['documents']} document" . ($done['documents'] === 1 ? '' : 's'); + if ($done['monthly'] != 0) { + $message .= " - recurring invoices bring in " . escapeHtml(billingMoney(abs($done['monthly']), $session_company_currency)) . ($done['monthly'] > 0 ? ' more' : ' less') . " a month"; + } + flashAlert($message); + + redirect(); + +} + if (isset($_GET['download_products_csv_template'])) { $f = fopen('php://memory', 'w'); diff --git a/agent/product.php b/agent/product.php index 05e7ebf89..db1272dcc 100644 --- a/agent/product.php +++ b/agent/product.php @@ -11,8 +11,10 @@ * * Then in tabs with counts: its stock moves with what was on the shelf after * each, the recurring invoices it is on (what that brings in a month), open - * quotes, the invoices it was sold on, the client assets made from it, and - * its history. Figures are for the clients the agent can see. + * quotes, the invoices it was sold on, the client assets made from it, its + * price changes and its history. Figures are for the clients the agent can + * see. When lines on recurring invoices, drafts or quotes are still at one of + * its old prices, it says so, with Update Prices. */ require_once "includes/inc_all.php"; @@ -55,6 +57,11 @@ if (!$product) { $assets = $can_see_assets ? productAssets($product_id, 200) : []; $asset_clients = count(array_unique(array_column($assets, 'client_id'))); + // Its price over time, and the lines still at an old price + $prices = productPrices($product_id, 100); + $last_change = productPriceLastChange($product_id); + $behind = productRepriceCount(['product_id' => $product_id]); + $usage = productUsage($product_id); $moves = $type === 'product' ? productStockMoves($product_id, 200) : []; $history = productHistory($product_id, 100); @@ -65,10 +72,11 @@ if (!$product) { 'recurring' => ['label' => 'Recurring', 'icon' => 'fa-redo-alt', 'count' => count($usage['recurring'])], 'invoices' => ['label' => 'Invoices', 'icon' => 'fa-file-invoice-dollar', 'count' => count($usage['invoices'])], 'quotes' => ['label' => 'Open quotes', 'icon' => 'fa-comment-dollar', 'count' => count($usage['quotes'])], + 'prices' => ['label' => 'Prices', 'icon' => 'fa-tags', 'count' => count($prices)], 'history' => ['label' => 'History', 'icon' => 'fa-history', 'count' => count($history)], ]; if (!$product['product_frequency']) { - $tabs = ['invoices' => $tabs['invoices'], 'quotes' => $tabs['quotes'], 'recurring' => $tabs['recurring'], 'history' => $tabs['history']]; + $tabs = ['invoices' => $tabs['invoices'], 'quotes' => $tabs['quotes'], 'recurring' => $tabs['recurring'], 'prices' => $tabs['prices'], 'history' => $tabs['history']]; } // The client assets made from it, after the invoices it was sold on if ($can_see_assets && ($asset_type !== '' || $assets)) { @@ -218,6 +226,22 @@ if (!$product) { + + +
+ +
+ line at an old price - + 0 ? '+' : '-') . billingMoney(abs($behind['monthly']), $currency)) . ' a month' : '' ?>. +
+ Update Prices +
+ +
@@ -232,6 +256,10 @@ if (!$product) {
Price
Cost
0 ? $money($product['product_cost']) : 'Not set' ?>
Margin
-' ?>
+ +
Changed
+
from
+
Tax
None' ?>
Billed
@@ -569,6 +597,52 @@ if (!$product) {
+ +
+ +
+ + + + + + + + + + + + + + + + +
WhenPriceChangeCostHowBy
+ + → + + + + + 0 ? '+' : '') . ($price_old > 0 ? round($price_moved / $price_old * 100, 1) . '%' : $money($price_moved)) ?> + + + → + + 0 ? $money($price_row['price_cost_new']) : '-' ?> + + -' ?>
+
+ +
Its price and cost haven't changed since this was kept.
+ +
+
diff --git a/agent/products.php b/agent/products.php index d4d1402dd..71da7168f 100644 --- a/agent/products.php +++ b/agent/products.php @@ -108,6 +108,8 @@ $stock_stat = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(GREA WHERE product_track_stock = 1 AND product_archived_at IS NULL")); $tracked_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM products WHERE product_track_stock = 1 AND product_archived_at IS NULL"))[0]); $reorder_count = productReorderCount(); +// Lines on recurring invoices, drafts and quotes still at an old price +$reprice = productRepriceCount(); $catalog_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM products"))[0]); $filter_url = function ($changes) { @@ -178,6 +180,18 @@ $add_type = in_array($view, ['product', 'hardware', 'stocked', 'reorder'], true)
+ + +
+ +
+ line at an old price + on recurring invoices, drafts or quotes, from product 0 ? ' more' : ' less') . ' a month' : '' ?>. +
+ Update Prices +
+ +

Products and Services

@@ -202,6 +216,9 @@ $add_type = in_array($view, ['product', 'hardware', 'stocked', 'reorder'], true) Count Stock + + Update Prices + Import diff --git a/agent/products_reprice.php b/agent/products_reprice.php new file mode 100644 index 000000000..317ce4959 --- /dev/null +++ b/agent/products_reprice.php @@ -0,0 +1,299 @@ + intval($_GET['product_id'] ?? 0), + 'client_id' => intval($_GET['client_id'] ?? 0), + 'kind' => isset(productRepriceKinds()[$_GET['kind'] ?? '']) ? $_GET['kind'] : '', + 'doc_id' => intval($_GET['doc_id'] ?? 0), +]; +$groups = productRepriceLines($filters); +$kinds = productRepriceKinds(); +$can_edit = lookupUserPermission('module_sales') >= 2; +$line_count = array_sum(array_map(function ($group) { + return count($group['lines']); +}, $groups)); +$period_words = ['month' => 'a month', 'quarter' => 'a quarter', 'year' => 'a year']; +$doc_pages = ['recurring' => 'recurring_invoice.php?recurring_invoice_id=', 'invoice' => 'invoice.php?invoice_id=', 'quote' => 'quote.php?quote_id=']; + +// What it is narrowed to, each with a link that takes it off +$narrowed = []; +$clear_url = function ($keys) use ($filters) { + $params = array_filter(array_diff_key($filters, array_flip($keys))); + return 'products_reprice.php' . ($params ? '?' . http_build_query($params) : ''); +}; +if ($filters['product_id']) { + $narrowed[] = ['Product', getFieldById('products', $filters['product_id'], 'product_name') ?: 'a product', $clear_url(['product_id'])]; +} +if ($filters['client_id']) { + $narrowed[] = ['Client', getFieldById('clients', $filters['client_id'], 'client_name') ?: 'a client', $clear_url(['client_id'])]; +} +if ($filters['kind']) { + $doc = $filters['doc_id'] ? billingRow($filters['kind'], $filters['doc_id']) : null; + $narrowed[] = [$kinds[$filters['kind']]['one'], $doc ? billingNumber($filters['kind'], $doc) : $kinds[$filters['kind']]['label'], $clear_url(['kind', 'doc_id'])]; +} + +$page_title = 'Update Prices'; + +?> + + + +
+ + +
+
+

Update Prices

+
+ +
+

+ Catalog lines on recurring invoices, draft invoices and draft quotes whose price isn't the product's price now. + Lines still at one of its old prices are picked. A price it never had looks agreed with the client, so it is left alone unless you pick it. + Invoices already sent, and quotes out with clients, keep their prices. +

+ +
+ + : Clear + +
+ +
+ + 0 ? floatval($last['price_new']) / floatval($last['price_old']) : 0; + $percent = $ratio ? round(($ratio - 1) * 100, 1) : 0; ?> +
+
+ +
+ +
+ Now + + · was until + ( 0 ? '+' : '' ?>%) + +
+
+ +
+ + + + +
+ +
+
+ + + + + + + + + + + + + + + + + 1 / 12, 'quarter' => 1 / 3][$frequency] ?? 1 : 0; ?> + + + + + + + + + + + + + + +
OnClientLineQtyNowNew priceChange
+ data-reprice-pick> + + +
+
+ + +
Own price
+ +
+ +
+ + +
+ + + +
+
+
+ + + +
+ +
+
+ = 1000) { ?> +
The first 1000 lines - update these, or narrow it to a product, and the rest show.
+ + + + + +
+
+ + + +
Margin
+ 'quote', 'doc_id' => $quote_id]) : null; if ($reprice_doc && $reprice_doc['lines']) { ?> +
Prices
+
line at an old price
+
Client
Not sent yet - the client can't see it
diff --git a/agent/recurring_invoice.php b/agent/recurring_invoice.php index ab87b86e8..3929dfd25 100644 --- a/agent/recurring_invoice.php +++ b/agent/recurring_invoice.php @@ -234,6 +234,10 @@ $paper = [
Margin
+ 'recurring', 'doc_id' => $recurring_invoice_id]) : null; if ($reprice_doc && $reprice_doc['lines']) { ?> +
Prices
+
line at an old price
+
Sending
diff --git a/css/itflow_custom.css b/css/itflow_custom.css index 995b07c9b..d816af4f8 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -6195,6 +6195,20 @@ a.itflow-report-stat:focus-visible { .itflow-hw-assets { padding-left: 2.25rem; } + +/* Update Prices - lines not picked are muted, the footer stays in reach */ +.itflow-reprice-off td:not(.checkbox-column) { + opacity: .55; +} +.itflow-reprice-table .input-group { + min-width: 8.5rem; +} +.itflow-reprice-footer { + position: sticky; + bottom: 0; + z-index: 2; + background-color: var(--bs-body-bg); +} .itflow-money { font-variant-numeric: tabular-nums; white-space: nowrap; diff --git a/db.sql b/db.sql index 1b041390b..5b77bc67f 100644 --- a/db.sql +++ b/db.sql @@ -1756,6 +1756,28 @@ CREATE TABLE `payments` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `product_prices` +-- + +DROP TABLE IF EXISTS `product_prices`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `product_prices` ( + `price_id` int(11) NOT NULL AUTO_INCREMENT, + `price_old` decimal(15,2) NOT NULL, + `price_new` decimal(15,2) NOT NULL, + `price_cost_old` decimal(15,2) NOT NULL DEFAULT 0.00, + `price_cost_new` decimal(15,2) NOT NULL DEFAULT 0.00, + `price_source` varchar(20) NOT NULL DEFAULT 'edit', + `price_created_at` datetime NOT NULL DEFAULT current_timestamp(), + `price_user_id` int(11) NOT NULL DEFAULT 0, + `price_product_id` int(11) NOT NULL, + PRIMARY KEY (`price_id`), + KEY `price_product_id` (`price_product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `product_stock` -- diff --git a/functions/billing.php b/functions/billing.php index 80fc6408a..1fac28e89 100644 --- a/functions/billing.php +++ b/functions/billing.php @@ -315,6 +315,28 @@ function billingItemSave($type, $doc_id, array $in, $item_id = 0, &$error = '') return $item_id; } +/** Change a line's price - its amounts at its tax rate, then the document's total. False when the line isn't on it. */ +function billingItemSetPrice($type, $doc_id, $item_id, $price) { + global $mysqli; + $t = billingType($type); + $doc_id = intval($doc_id); + $item_id = intval($item_id); + $item = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT item_quantity, item_subtotal, item_tax, item_tax_id, tax_percent FROM {$t['items']} + LEFT JOIN taxes ON tax_id = item_tax_id + WHERE item_id = $item_id AND {$t['item_fk']} = $doc_id")); + if (!$item) { + return false; + } + // Its tax's rate - or, when that tax is gone, the rate it was charged at + $percent = $item['tax_percent'] !== null ? floatval($item['tax_percent']) + : (intval($item['item_tax_id']) && floatval($item['item_subtotal']) != 0 ? floatval($item['item_tax']) / floatval($item['item_subtotal']) * 100 : 0); + $price = round(floatval($price), 2); + [$subtotal, $tax, $total] = billingLineAmounts($item['item_quantity'], $price, $percent); + mysqli_query($mysqli, "UPDATE {$t['items']} SET item_price = $price, item_subtotal = $subtotal, item_tax = $tax, item_total = $total WHERE item_id = $item_id"); + billingRecalc($type, $doc_id); + return true; +} + /** Take a line off a document - a tracked product goes back on the shelf. The line, or null. */ function billingItemDelete($type, $doc_id, $item_id) { global $mysqli; diff --git a/functions/dashboard.php b/functions/dashboard.php index 64767ba0a..b780fe684 100644 --- a/functions/dashboard.php +++ b/functions/dashboard.php @@ -269,6 +269,14 @@ function dashboardAttention($access) { 'label' => 'Stock to reorder', 'icon' => 'shopping-basket', 'tone' => 'warning', 'detail' => 'At or under the reorder level', 'url' => '/agent/products.php?type=reorder', ]; + // Lines on recurring invoices, drafts and quotes still at an old catalog price + $reprice = productRepriceCount(); + $items[] = [ + 'count' => intval($reprice['lines']), + 'label' => 'Lines at an old price', 'icon' => 'tags', 'tone' => 'warning', + 'detail' => $reprice['monthly'] > 0 ? '+' . dashboardMoney($reprice['monthly']) . ' a month on recurring' : ($reprice['lines'] ? 'On recurring invoices, drafts and quotes' : null), + 'url' => '/agent/products_reprice.php', + ]; // Hardware sold in the last 90 days that isn't on the client's assets yet if ($access['support']) { $hardware = productHardwarePending(90); diff --git a/functions/products.php b/functions/products.php index d23456b52..6ed1372ef 100644 --- a/functions/products.php +++ b/functions/products.php @@ -112,9 +112,11 @@ function productVendors() { * 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 = '') { +function productSave(array $in, $product_id = 0, &$error = '', $source = 'edit') { global $mysqli, $session_company_currency; $error = ''; $product_id = intval($product_id); @@ -273,6 +275,7 @@ function productSave(array $in, $product_id = 0, &$error = '') { } 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 ?? ''); @@ -280,6 +283,42 @@ function productSave(array $in, $product_id = 0, &$error = '') { 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; @@ -299,11 +338,12 @@ function productCopy($product_id) { ], 0, $error); } -/** Delete a product and its stock. Lines it is on keep their text and amounts. */ +/** 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"); } @@ -867,6 +907,197 @@ function productSaleAssets($invoice_id, array $lines, array $units, array $share 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