diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b8ae3ac..302d3c5b9 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 3.2.8) 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 3.2.9) in the same pass. Nothing else is required. -This is a big release, with forty-nine database steps in one go, so take a backup before you update. +This is a big release, with fifty 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: @@ -49,7 +49,7 @@ Canned responses keep their names, what they say and their ticket category. One Document templates keep their names, summaries, what they say and their images. Words in square brackets already in them are now filled in when a document is made: [Client], [Site], [Contact], [Date] and the others listed under New Features (Document templates) from the client, and the rest are asked for as plain, optional text until you set them up on the template's page - one left blank stays as it is, as before. Documents made from a template before the update don't know which one, so a template's use is counted from the update - link one to its template from the document's page to count it. No template is required of anyone, and none asks for reviews, until you set it up on its page. The starter document templates you added from Maintenance > Starter Content stay as they are; Update on that page brings them up to the new versions, which fill in from the client, ask for what they need and are reviewed on a schedule. -License templates keep everything they hold. A license called exactly what one license template is called - as the New License form named it when started from one - counts as made from that template, so a template's use starts out counted; where two templates share a name, neither is given its licenses. Other licenses don't know a template, so they count from the update. +License templates keep everything they hold. A license called exactly what one license template is called - as the New License form named it when started from one - counts as made from that template, so a template's use starts out counted; where two templates share a name, neither is given its licenses. Other licenses don't know a template, so they count from the update. No template sets seats, a renewal term, a vendor or a product until you give it one. 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. @@ -552,6 +552,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Templates can be copied, archived - not offered on New License, while the licenses made from them stay - restored and deleted. - The template window has its icons, a summary and room for the notes every license from it should carry. - A license started from a template on New License remembers it, and its quick look says which template it came from. + - A template can carry the seats a license starts with, how often it renews, its vendor - picked from the vendor templates - and the product in Products & Services it is sold as. The page and the list show them, and the list finds a template by its vendor or product. + - A license made from one starts with its seats, and its renewal date is worked out from when it was bought - a year on for a yearly one, or from today when there is no purchase date. Its vendor is the client's own one made from the vendor template, or one of that name; a client without it has it added from the vendor template, by anyone who can add vendors. + - On New License, picking a template fills in its seats, vendor and renewal date as well, and the renewal date follows the purchase date until you type one in. A vendor you picked yourself stays. + - Renew starts on the template's term for licenses made from it. + - New licenses for several clients at once, from the template's page or the list: pick the clients, skip the ones that already have one, and give the seats, dates and order reference once. + - A license's quick look shows the product it is sold as, with its price, to anyone who can see Sales. - **Assets** - The asset list has: - type icons, colored status pills and OS icons @@ -1547,6 +1553,10 @@ If you are coming from 26.08 or earlier, the database update still has to be run - New `functions/software_template.php`: `softwareTemplateRow($id)` (with `created_by_name` and `updated_by_name`), `softwareTemplateUsage($ids)` (`count`, `clients`, `seats`, `used`, `renewing`, `expired`, `last` - current licenses of current clients), `softwareTemplateFields($post, &$error)`, `softwareTemplateSave($id, $fields)`, `softwareTemplateCopy($id)`, `softwareTemplateDelete($id)` (clears the template from its licenses, leaving `software_updated_at` alone), `softwareTemplateLiveId($id)` and `softwareTemplateMakeLicense($id, $client_id, $post)` (through `softwareFormSet()`). - Admin: `admin/software_templates.php` takes `q`, `sort` - `software_template_name`, `software_template_type`, `software_template_license_type`, `used_count`, `last_used` or `changed_at` - `order`, `page` and `show=archived`; new `admin/software_template.php` (`software_template_id`). `admin/modals/software_template/software_template_form.php` is shared by `software_template_add.php` and `software_template_edit.php` (`id`); new `software_template_use.php` (`id`, `client_id`) posts `add_software_from_template` to `agent/post.php`. `admin/post/software_template.php`: `add_software_template` (redirects to its page), `edit_software_template`, GET `copy_software_template`, `archive_software_template`, `restore_software_template` and `delete_software_template` (`from_page`). Audit entries are Software Template Create, Edit, Archive, Restore and Delete. - Agent: `add_software` keeps the `software_template_id` the New License form posts when it is a current template. `add_software_from_template` makes the license with `softwareTemplateMakeLicense()` - `seats`, `key`, `purchase`, `expire` and `purchase_reference` - checks the client and redirects to `/agent/software.php?client_id=`. The license quick look shows the template. + - Database 3.2.9: `software_templates.software_template_seats` (null for no limit), `software_template_term` (a `SOFTWARE_RENEWAL_TERMS` key, or null), `software_template_vendor_template_id` and `software_template_product_id` (0 for none). + - `functions/software_template.php`: `softwareTemplateRow()` also returns the vendor template's name and archived date and the product's name, price, cost, currency, frequency, seat source and archived date; `softwareTemplateFields()` reads `seats`, `term`, `vendor_template_id` and `product_id` (one that exists, archived or not); new `softwareTemplateLinkId($table, $column, $id)`, `softwareTemplateRenewal($term, $purchase)`, `softwareTemplatePriceText($template)`, `softwareVendorFromTemplate($vendor_template_id, $client_id, $make = true, &$made)` (the client's vendor made from it, then one of its name, then - with `$make` - one made through `vendorFormSet()`) and `softwareTemplateMakeForClients($id, $client_ids, $post, $skip_existing = true, $make_vendor = true)` (`made`, `skipped`, `vendors`). `softwareTemplateMakeLicense($id, $client_id, $post, $make_vendor = true, &$vendor_made)` takes the template's seats when `seats` isn't posted and its term from `purchase` when `expire` is blank. + - Admin: new `admin/modals/software_template/software_template_use_several.php` (`id`) posts `make_licenses_from_template` (`client_ids[]`, `skip_existing`, `seats`, `purchase`, `expire`, `purchase_reference`) to `admin/post/software_template.php`, which redirects to the template's page. Deleting a vendor template or a product (`productDelete()`) clears it from the license templates that had it, leaving `software_template_updated_at` alone. + - Agent: the New License form's templates carry `seats`, `term`, `vendor_template_id` and `vendor`; the vendor picker is `data-software-vendor` (with `data-vendor-can-make` for someone with `module_client` 2) and its options `data-vendor-template-id`. `add_software` takes `vendor=template:N`, made with `softwareVendorFromTemplate()` when the agent has `module_client` 2. `add_software_from_template` and `make_licenses_from_template` audit a vendor they made as Vendor Create. The Renew window starts on the template's term when the licenses share one. - Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`. - Removed unused helpers and files: - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()`, `clientOverviewPersonInitials()`, and `getMonthlyTax()` and `getQuarterlyTax()`, which looked a tax up by name diff --git a/admin/database_updates/3.2.9.php b/admin/database_updates/3.2.9.php new file mode 100644 index 000000000..2c459276c --- /dev/null +++ b/admin/database_updates/3.2.9.php @@ -0,0 +1,17 @@ +
@@ -67,6 +88,55 @@ if ($license_type !== '' && !in_array($license_type, $license_types, true)) {
+
+ +
+ + +
+
What a license starts with - change it for each client.
+
+
+ +
+ + +
+
Its renewal date is worked out from when it was bought.
+
+
+ +
+ + +
+
From the vendor templates - the client's own vendor made from it, or one made for them.
+
+
+ +
+ + +
+
From Products & Services - what you charge for it.
+
diff --git a/admin/modals/software_template/software_template_use.php b/admin/modals/software_template/software_template_use.php index 959547611..8458e87d4 100644 --- a/admin/modals/software_template/software_template_use.php +++ b/admin/modals/software_template/software_template_use.php @@ -3,10 +3,11 @@ /* * New license for a client from a license template - from Admin > License * Templates and the template's page. The template gives it its name, type, - * license type, summary and notes; this window the client, seats, key, order - * reference and dates. It goes on the client's Licenses - * (add_software_from_template, agent/post/software.php). &client_id= picks - * the client. + * license type, summary, notes and vendor, and starts its seats and renewal + * date (its term from the purchase date, when left blank); this window the + * client, seats, key, order reference and dates. It goes on the client's + * Licenses (add_software_from_template, agent/post/software.php). &client_id= + * picks the client. */ require_once '../../includes/modal_header.php'; @@ -22,7 +23,10 @@ if (!$template || $template['software_template_archived_at']) { ?>
- +
@@ -75,6 +79,7 @@ if (!$template || $template['software_template_archived_at']) { ?>
+
Blank is after it was bought (or today).
@@ -84,7 +89,7 @@ if (!$template || $template['software_template_archived_at']) { ?>
-
Its name, type, license type, summary and notes come from the template. Put devices and users on it, and pick its vendor, from the client's Licenses.
+
Its name, type, license type, summary and notes come from the template' . escapeHtml($vendor_name) . ' - added for them if they haven\'t got it' : '' ?>. Put devices and users on it from the client's Licenses.
- +
+ + + +
diff --git a/agent/modals/software/software_form.php b/agent/modals/software/software_form.php index ac476c762..8c1b4d6d8 100644 --- a/agent/modals/software/software_form.php +++ b/agent/modals/software/software_form.php @@ -9,9 +9,11 @@ * * Tabs: Details (with its tags), License (type, seats, key, dates), Seats (the client's * devices and users, with a running count against the seats bought) and - * Notes. A new license can start from a template, which fills in the details - * and is remembered (software_template_id), and take a batch of individual - * keys, one per line. + * Notes. A new license can start from a template, which fills in the details, + * seats, vendor (the client's own one made from the template's vendor + * template, or "template:N" to make it - add_software) and renewal date (its + * term from the purchase date) and is remembered (software_template_id), and + * take a batch of individual keys, one per line. * * Should not be accessed directly. */ @@ -32,7 +34,7 @@ if ($software_license_type_raw !== '' && !in_array($software_license_type_raw, $ // The client's own lists - only once the client is known $vendor_options = $asset_options = $contact_options = []; if ($client_id) { - $sql = mysqli_query($mysqli, "SELECT vendor_archived_at, vendor_id, vendor_name, vendor_description FROM vendors + $sql = mysqli_query($mysqli, "SELECT vendor_archived_at, vendor_id, vendor_name, vendor_description, vendor_template_id FROM vendors WHERE vendor_client_id = $client_id AND (vendor_archived_at IS NULL OR vendor_id = $software_vendor_id) ORDER BY vendor_favorite DESC, vendor_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { @@ -63,8 +65,11 @@ if ($client_id) { $template_options = []; if ($software_form_mode === 'add') { $sql = mysqli_query($mysqli, "SELECT software_template_id, software_template_name, software_template_version, software_template_description, - software_template_type, software_template_license_type, software_template_notes - FROM software_templates WHERE software_template_archived_at IS NULL ORDER BY software_template_name ASC"); + software_template_type, software_template_license_type, software_template_notes, software_template_seats, software_template_term, + vendor_template_id, vendor_template_name + FROM software_templates + LEFT JOIN vendor_templates ON vendor_template_id = software_template_vendor_template_id AND software_template_vendor_template_id > 0 AND vendor_template_archived_at IS NULL + WHERE software_template_archived_at IS NULL ORDER BY software_template_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $template_options[intval($row['software_template_id'])] = [ 'name' => $row['software_template_name'], @@ -73,9 +78,15 @@ if ($software_form_mode === 'add') { 'type' => $row['software_template_type'], 'license_type' => (string) $row['software_template_license_type'], 'notes' => (string) $row['software_template_notes'], + 'seats' => $row['software_template_seats'] === null ? '' : (string) intval($row['software_template_seats']), + 'term' => (string) $row['software_template_term'], + 'vendor_template_id' => intval($row['vendor_template_id']), + 'vendor' => (string) $row['vendor_template_name'], ]; } } +// Who may have a template's vendor made for the client as the license is saved +$vendor_can_make = intval(lookupUserPermission('module_client')) >= 2; $software_type_style = softwareTypeStyle($software_type); $form_id = 'softwareForm' . ($software_form_mode === 'edit' ? $software_id : 'New'); @@ -130,7 +141,7 @@ $form_id = 'softwareForm' . ($software_form_mode === 'edit' ? $software_id : 'Ne
-
Fills in the name, type, license type, description and notes - change anything after. The license remembers it.
+
Fills in the name, type, license type, description, seats, vendor, renewal date and notes - change anything after. The license remembers it.
@@ -169,11 +180,11 @@ $form_id = 'softwareForm' . ($software_form_mode === 'edit' ? $software_id : 'Ne
- > - +
@@ -366,21 +377,73 @@ $form_id = 'softwareForm' . ($software_form_mode === 'edit' ? $software_id : 'Ne // Renewal a term after it was bought - or after today var purchase = form.querySelector('[data-software-purchase]'); var expire = form.querySelector('[data-software-expire]'); + function renewOn(term) { + var from = purchase.value ? new Date(purchase.value + 'T00:00:00') : new Date(); + if (term.slice(-1) === 'y') { + from.setFullYear(from.getFullYear() + parseInt(term, 10)); + } else { + from.setMonth(from.getMonth() + parseInt(term, 10)); + } + var pad = function (n) { return (n < 10 ? '0' : '') + n; }; + expire.value = from.getFullYear() + '-' + pad(from.getMonth() + 1) + '-' + pad(from.getDate()); + expire.dispatchEvent(new Event('change', { bubbles: true })); + } form.querySelectorAll('[data-renew-term]').forEach(function (button) { button.addEventListener('click', function () { - var from = purchase.value ? new Date(purchase.value + 'T00:00:00') : new Date(); - var term = button.dataset.renewTerm; - if (term.slice(-1) === 'y') { - from.setFullYear(from.getFullYear() + parseInt(term, 10)); - } else { - from.setMonth(from.getMonth() + parseInt(term, 10)); - } - var pad = function (n) { return (n < 10 ? '0' : '') + n; }; - expire.value = from.getFullYear() + '-' + pad(from.getMonth() + 1) + '-' + pad(from.getDate()); - expire.dispatchEvent(new Event('change', { bubbles: true })); + renewOn(button.dataset.renewTerm); }); }); + // A template's term keeps the renewal date in step with the purchase date - until the date is typed in + var templateTerm = ''; + purchase.addEventListener('change', function () { + if (templateTerm) { + renewOn(templateTerm); + } + }); + expire.addEventListener('input', function () { + templateTerm = ''; + }); + + // The client's vendor for a template: theirs made from its vendor template, or of its name - else one to make as it is saved + var vendor = form.querySelector('[data-software-vendor]'); + var vendorPicked = ''; + function templateVendor(t) { + if (!vendor) { + return; + } + var made = vendor.querySelector('option[data-vendor-from-template]'); + if (made) { + made.remove(); + } + if (vendor.value !== '' && vendor.value !== vendorPicked) { + return; + } + var value = ''; + if (t.vendor_template_id) { + var options = Array.prototype.slice.call(vendor.options); + var match = options.filter(function (o) { return o.dataset.vendorTemplateId === String(t.vendor_template_id); })[0] + || options.filter(function (o) { return o.value !== '' && o.text.trim().toLowerCase() === t.vendor.toLowerCase(); })[0]; + if (match) { + value = match.value; + } else if (vendor.hasAttribute('data-vendor-can-make')) { + var option = new Option(t.vendor, 'template:' + t.vendor_template_id); + option.dataset.vendorFromTemplate = '1'; + option.dataset.description = 'New - added for the client from the vendor template'; + option.dataset.icon = 'fas fa-plus'; + vendor.add(option); + value = option.value; + } + } + vendorPicked = value; + if (vendor.itflowSelect) { + vendor.itflowSelect.setValue(value); + } else { + vendor.value = value; + vendor.dispatchEvent(new Event('change', { bubbles: true })); + } + } + // Starting from a template fills in what it knows var templates = ; var picker = form.querySelector('[data-software-template]'); @@ -393,6 +456,13 @@ $form_id = 'softwareForm' . ($software_form_mode === 'edit' ? $software_id : 'Ne form.querySelector('[name="version"]').value = t.version; form.querySelector('[name="description"]').value = t.description; form.querySelector('[name="notes"]').value = t.notes; + total.value = t.seats; + seats(); + templateTerm = t.term; + if (t.term) { + renewOn(t.term); + } + templateVendor(t); [['type', t.type], ['license_type', t.license_type]].forEach(function (pair) { var select = form.querySelector('[name="' + pair[0] + '"]'); if (!pair[1]) { diff --git a/agent/modals/software/software_renew.php b/agent/modals/software/software_renew.php index af2a7e042..03d8f16cf 100644 --- a/agent/modals/software/software_renew.php +++ b/agent/modals/software/software_renew.php @@ -5,7 +5,8 @@ * Move each renewal date on by a term - from its current date, or from today * if there is none or it has passed - or set one date for all of them. The * new date for each shows before you save. Optionally record the new order - * or invoice reference. + * or invoice reference. Licenses from license templates that renew on the + * same term start on that term; the rest on a year. */ require_once '../../../includes/modal_header.php'; @@ -30,7 +31,14 @@ if (!$licenses) { } $single = count($licenses) === 1; -$default_term = '1y'; + +// The term their templates renew on, when they all share one +$terms = []; +foreach ($licenses as $row) { + $term = intval($row['software_template_id'] ?? 0) ? (string) getFieldById('software_templates', $row['software_template_id'], 'software_template_term') : ''; + $terms[$term] = true; +} +$default_term = count($terms) === 1 && isset(SOFTWARE_RENEWAL_TERMS[(string) array_key_first($terms)]) ? (string) array_key_first($terms) : '1y'; ob_start(); diff --git a/agent/post/software.php b/agent/post/software.php index d0f31bf30..bc63c60fc 100644 --- a/agent/post/software.php +++ b/agent/post/software.php @@ -56,12 +56,18 @@ if (isset($_POST['add_software'])) { enforceClientAccess(); - $set = softwareFormSet($_POST, $client_id); - if ($set === null) { + if (trim((string) ($_POST['name'] ?? '')) === '') { flashAlert('A license needs a name.', 'error'); redirect(); } + // A template's vendor the client hasn't got yet is made for them from its vendor template - by someone who can add vendors + $vendor_made = false; + if (preg_match('/^template:(\d+)$/', (string) ($_POST['vendor'] ?? ''), $match)) { + $_POST['vendor'] = softwareVendorFromTemplate($match[1], $client_id, intval(lookupUserPermission('module_client')) >= 2, $vendor_made); + } + $set = softwareFormSet($_POST, $client_id); + // Started from a template, it remembers which mysqli_query($mysqli, "INSERT INTO software SET $set, software_client_id = $client_id, software_template_id = " . softwareTemplateLiveId($_POST['software_template_id'] ?? 0)); $software_id = mysqli_insert_id($mysqli); @@ -71,10 +77,14 @@ if (isset($_POST['add_software'])) { tagSetForRecord('license', $software_id, (array) ($_POST['tags'] ?? [])); $name = trim($_POST['name']); + $vendor_name = $vendor_made ? (string) getFieldById('vendors', $_POST['vendor'], 'vendor_name') : ''; + if ($vendor_made) { + logAudit("Vendor", "Create", "$session_name created vendor " . escapeSql($vendor_name) . " from a template for license " . escapeSql($name), $client_id, intval($_POST['vendor'])); + } logAudit("Software", "Create", "$session_name created license " . escapeSql($name . softwareSeatChangeText($changes)), $client_id, $software_id); $warning = softwareSeatWarning($software_id); - flashAlert("License $name created" . ($keys_added ? " with $keys_added key" . ($keys_added === 1 ? '' : 's') : '') . $warning, $warning ? 'warning' : 'success'); + flashAlert("License $name created" . ($keys_added ? " with $keys_added key" . ($keys_added === 1 ? '' : 's') : '') . ($vendor_made ? ", and vendor " . escapeHtml($vendor_name) . " added for the client" : '') . $warning, $warning ? 'warning' : 'success'); redirect(); @@ -96,15 +106,20 @@ if (isset($_POST['add_software_from_template'])) { redirect(); } $template = softwareTemplateRow($_POST['software_template_id'] ?? 0); - $software_id = softwareTemplateMakeLicense($_POST['software_template_id'] ?? 0, $client_id, $_POST); + $software_id = softwareTemplateMakeLicense($_POST['software_template_id'] ?? 0, $client_id, $_POST, intval(lookupUserPermission('module_client')) >= 2, $vendor_made); if (!$software_id) { flashAlert('That license template is gone or archived.', 'error'); redirect(); } + $vendor_id = intval(getFieldById('software', $software_id, 'software_vendor_id')); + $vendor_name = $vendor_made ? (string) getFieldById('vendors', $vendor_id, 'vendor_name') : ''; + if ($vendor_made) { + logAudit("Vendor", "Create", "$session_name created vendor " . escapeSql($vendor_name) . " from a template for license " . escapeSql($template['software_template_name']), $client_id, $vendor_id); + } logAudit("Software", "Create", "$session_name created license " . escapeSql($template['software_template_name']) . " from template " . escapeSql($template['software_template_name']), $client_id, $software_id); - flashAlert("License " . escapeHtml($template['software_template_name']) . " created from template " . escapeHtml($template['software_template_name']) . " - put its devices and users on it"); + flashAlert("License " . escapeHtml($template['software_template_name']) . " created from template " . escapeHtml($template['software_template_name']) . "" . ($vendor_made ? ", with vendor " . escapeHtml($vendor_name) . " added for the client" : '') . " - put its devices and users on it"); redirect("/agent/software.php?client_id=$client_id"); diff --git a/db.sql b/db.sql index c0569f9ae..8e525043c 100644 --- a/db.sql +++ b/db.sql @@ -3250,6 +3250,10 @@ CREATE TABLE `software_templates` ( `software_template_type` varchar(200) NOT NULL, `software_template_license_type` varchar(200) DEFAULT NULL, `software_template_notes` text DEFAULT NULL, + `software_template_seats` int(11) DEFAULT NULL, + `software_template_term` varchar(10) DEFAULT NULL, + `software_template_vendor_template_id` int(11) NOT NULL DEFAULT 0, + `software_template_product_id` int(11) NOT NULL DEFAULT 0, `software_template_created_at` datetime NOT NULL DEFAULT current_timestamp(), `software_template_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(), `software_template_archived_at` datetime DEFAULT NULL, diff --git a/functions/products.php b/functions/products.php index 3e3783c74..604d94ebc 100644 --- a/functions/products.php +++ b/functions/products.php @@ -338,10 +338,11 @@ function productCopy($product_id) { ], 0, $error); } -/** Delete a product, its stock and its price history. 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; license templates sold as it no longer are. */ function productDelete($product_id) { global $mysqli; $product_id = intval($product_id); + mysqli_query($mysqli, "UPDATE software_templates SET software_template_product_id = 0, software_template_updated_at = software_template_updated_at WHERE software_template_product_id = $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"); diff --git a/functions/software_template.php b/functions/software_template.php index 4a10ec1f5..bdae13321 100644 --- a/functions/software_template.php +++ b/functions/software_template.php @@ -9,6 +9,13 @@ * (software.software_template_id, from database 3.2.8 on), so each template's * use is counted: how many licenses, for how many clients, with how many seats. * + * From database 3.2.9 on a template also carries the seats a license starts + * with, how often it renews - its renewal date worked out from when it was + * bought, and Renew offering that term - the vendor template its vendor comes + * from (the client's own vendor made from it, or one of that name, or one made + * from it there and then) and the product it is sold as. Licenses can be made + * from one for several clients at once. + * * Shared by admin/software_templates.php, admin/software_template.php, * admin/modals/software_template/*, admin/post/software_template.php and the * New License window and its handlers on the agent side @@ -23,12 +30,16 @@ function softwareTemplateRow($software_template_id) { return null; } $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT software_template_id, software_template_name, software_template_description, software_template_version, - software_template_type, software_template_license_type, software_template_notes, software_template_created_at, software_template_updated_at, + software_template_type, software_template_license_type, software_template_notes, software_template_seats, software_template_term, + software_template_vendor_template_id, software_template_product_id, software_template_created_at, software_template_updated_at, software_template_archived_at, software_template_created_by, software_template_updated_by, - creator.user_name AS created_by_name, updater.user_name AS updated_by_name + creator.user_name AS created_by_name, updater.user_name AS updated_by_name, + vendor_template_name, vendor_template_archived_at, product_name, product_price, product_cost, product_currency_code, product_frequency, product_seat_source, product_archived_at FROM software_templates LEFT JOIN users creator ON creator.user_id = software_template_created_by AND software_template_created_by > 0 LEFT JOIN users updater ON updater.user_id = software_template_updated_by AND software_template_updated_by > 0 + LEFT JOIN vendor_templates ON vendor_template_id = software_template_vendor_template_id AND software_template_vendor_template_id > 0 + LEFT JOIN products ON product_id = software_template_product_id AND software_template_product_id > 0 WHERE software_template_id = $software_template_id")); return $row ?: null; } @@ -63,7 +74,10 @@ function softwareTemplateUsage(array $software_template_ids) { /* * The template window's fields, checked - or null with $error saying why. * $post: name, version, description (a line on what it is), type (a software - * type from Admin > Categories), license_type and notes. + * type from Admin > Categories), license_type, notes, seats (blank for no + * limit), term (one of SOFTWARE_RENEWAL_TERMS, or blank), vendor_template_id + * (a vendor template, or none) and product_id (a product, or none) - an + * archived one stays, the window only offering it to the template that has it. */ function softwareTemplateFields(array $post, &$error = '') { $error = ''; @@ -91,6 +105,10 @@ function softwareTemplateFields(array $post, &$error = '') { 'type' => mb_substr($type, 0, 200), 'license_type' => mb_substr($line('license_type'), 0, 200), 'notes' => trim(strip_tags((string) ($post['notes'] ?? ''))), + 'seats' => trim((string) ($post['seats'] ?? '')) === '' ? null : max(0, min(1000000, intval($post['seats']))), + 'term' => isset(SOFTWARE_RENEWAL_TERMS[(string) ($post['term'] ?? '')]) ? (string) $post['term'] : '', + 'vendor_template_id' => softwareTemplateLinkId('vendor_templates', 'vendor_template_id', $post['vendor_template_id'] ?? 0), + 'product_id' => softwareTemplateLinkId('products', 'product_id', $post['product_id'] ?? 0), ]; return $fields; } @@ -102,7 +120,10 @@ function softwareTemplateSave($software_template_id, array $f) { $user_id = intval($session_user_id ?? 0); $set = "software_template_name = '" . escapeSql($f['name']) . "', software_template_version = '" . escapeSql($f['version']) . "', software_template_description = '" . escapeSql($f['description']) . "', software_template_type = '" . escapeSql($f['type']) . "', - software_template_license_type = '" . escapeSql($f['license_type']) . "', software_template_notes = '" . escapeSql($f['notes']) . "'"; + software_template_license_type = '" . escapeSql($f['license_type']) . "', software_template_notes = '" . escapeSql($f['notes']) . "', + software_template_seats = " . (($f['seats'] ?? null) === null ? 'NULL' : intval($f['seats'])) . ", + software_template_term = " . (($f['term'] ?? '') !== '' ? "'" . escapeSql($f['term']) . "'" : 'NULL') . ", + software_template_vendor_template_id = " . intval($f['vendor_template_id'] ?? 0) . ", software_template_product_id = " . intval($f['product_id'] ?? 0); if ($software_template_id) { mysqli_query($mysqli, "UPDATE software_templates SET $set, software_template_updated_by = $user_id WHERE software_template_id = $software_template_id"); return $software_template_id; @@ -124,6 +145,10 @@ function softwareTemplateCopy($software_template_id) { 'type' => (string) $row['software_template_type'], 'license_type' => (string) $row['software_template_license_type'], 'notes' => (string) $row['software_template_notes'], + 'seats' => $row['software_template_seats'] === null ? null : intval($row['software_template_seats']), + 'term' => (string) $row['software_template_term'], + 'vendor_template_id' => intval($row['software_template_vendor_template_id']), + 'product_id' => intval($row['software_template_product_id']), ]); } @@ -141,19 +166,88 @@ function softwareTemplateLiveId($software_template_id) { return $row && !$row['software_template_archived_at'] ? intval($row['software_template_id']) : 0; } +/** A vendor template or product a template links to, when it exists - or 0. */ +function softwareTemplateLinkId($table, $column, $id) { + global $mysqli; + $id = intval($id); + return $id > 0 && mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM $table WHERE $column = $id")) ? $id : 0; +} + +/* + * When a license bought on $purchase (Y-m-d, or today when blank) renews + * with a template's $term - Y-m-d, or '' without a term. + */ +function softwareTemplateRenewal($term, $purchase = '') { + $intervals = ['1m' => 'P1M', '3m' => 'P3M', '6m' => 'P6M', '1y' => 'P1Y', '2y' => 'P2Y', '3y' => 'P3Y']; + if (!isset($intervals[(string) $term])) { + return ''; + } + $from = softwareFormDate($purchase); + return (new DateTime($from ?: 'today'))->add(new DateInterval($intervals[$term]))->format('Y-m-d'); +} + +/** A template's price as it is sold - "$22.00 · Monthly, per user" - or ''. */ +function softwareTemplatePriceText(array $template) { + if (empty($template['product_name'])) { + return ''; + } + $billed = productBilledText($template['product_frequency'], $template['product_seat_source']); + return billingMoney($template['product_price'], $template['product_currency_code']) . ' · ' . $billed; +} + +/* + * A client's vendor for a vendor template - the client's current vendor made + * from it, or one of its name; or, with $make, one made from the template + * there and then ($made says so). 0 when there is none, or the template is + * gone or archived. + */ +function softwareVendorFromTemplate($vendor_template_id, $client_id, $make = true, &$made = false) { + global $mysqli; + $made = false; + $client_id = intval($client_id); + $template = vendorTemplateRow($vendor_template_id); + if (!$template || !$client_id) { + return 0; + } + $vendor_template_id = intval($template['vendor_template_id']); + $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT vendor_id FROM vendors WHERE vendor_client_id = $client_id AND vendor_archived_at IS NULL + AND (vendor_template_id = $vendor_template_id OR LOWER(vendor_name) = LOWER('" . escapeSql($template['vendor_template_name']) . "')) + ORDER BY vendor_template_id = $vendor_template_id DESC, vendor_id ASC LIMIT 1")); + if ($row) { + return intval($row[0]); + } + if (!$make) { + return 0; + } + $set = vendorFormSet(vendorTemplateFormFields($template)); + if ($set === null) { + return 0; + } + mysqli_query($mysqli, "INSERT INTO vendors SET $set, vendor_client_id = $client_id"); + $made = true; + return intval(mysqli_insert_id($mysqli)); +} + /* * Make a client's license from a template - its name, version, summary, type, - * license type and notes - with what $post gives it: seats (blank is no - * limit), key, purchase_reference, purchase and expire (Y-m-d). It remembers - * the template. The new license's id, or 0 when the template is gone or - * archived. + * license type and notes, its vendor (softwareVendorFromTemplate() - made for + * the client when it hasn't got it, with $make_vendor) - with what $post + * gives it: seats (the template's when not posted at all; blank is no + * limit), key, purchase_reference, purchase and expire (Y-m-d - blank for the + * template's term from the purchase date). It remembers the template. The new + * license's id, or 0 when the template is gone or archived; $vendor_made says + * when a vendor was made for it. */ -function softwareTemplateMakeLicense($software_template_id, $client_id, array $post = []) { +function softwareTemplateMakeLicense($software_template_id, $client_id, array $post = [], $make_vendor = true, &$vendor_made = false) { global $mysqli; + $vendor_made = false; $row = softwareTemplateRow($software_template_id); if (!$row || $row['software_template_archived_at']) { return 0; } + $client_id = intval($client_id); + $seats = array_key_exists('seats', $post) ? trim((string) $post['seats']) : (string) $row['software_template_seats']; + $expire = trim((string) ($post['expire'] ?? '')); $set = softwareFormSet([ 'name' => $row['software_template_name'], 'version' => $row['software_template_version'], @@ -161,12 +255,49 @@ function softwareTemplateMakeLicense($software_template_id, $client_id, array $p 'type' => $row['software_template_type'], 'license_type' => $row['software_template_license_type'], 'notes' => $row['software_template_notes'], - 'seats' => $post['seats'] ?? '', + 'seats' => $seats, 'key' => $post['key'] ?? '', 'purchase_reference' => $post['purchase_reference'] ?? '', 'purchase' => $post['purchase'] ?? '', - 'expire' => $post['expire'] ?? '', - ], intval($client_id)); - mysqli_query($mysqli, "INSERT INTO software SET $set, software_client_id = " . intval($client_id) . ", software_template_id = " . intval($row['software_template_id'])); + 'expire' => $expire !== '' ? $expire : softwareTemplateRenewal($row['software_template_term'], $post['purchase'] ?? ''), + 'vendor' => softwareVendorFromTemplate($row['software_template_vendor_template_id'], $client_id, $make_vendor, $vendor_made), + ], $client_id); + mysqli_query($mysqli, "INSERT INTO software SET $set, software_client_id = $client_id, software_template_id = " . intval($row['software_template_id'])); return intval(mysqli_insert_id($mysqli)); } + +/* + * Licenses from a template for several clients at once, each with the same + * $post (softwareTemplateMakeLicense()). With $skip_existing, a client with a + * current license from it is passed over. ['made' => [client id => license + * id], 'skipped' => [client id => name], 'vendors' => [client id => true] for + * the clients a vendor was made for] - only current clients. + */ +function softwareTemplateMakeForClients($software_template_id, array $client_ids, array $post, $skip_existing = true, $make_vendor = true) { + global $mysqli; + $software_template_id = intval($software_template_id); + $result = ['made' => [], 'skipped' => [], 'vendors' => []]; + $client_ids = array_values(array_unique(array_filter(array_map('intval', $client_ids)))); + if (!$client_ids) { + return $result; + } + $sql = mysqli_query($mysqli, "SELECT client_id, client_name, EXISTS (SELECT 1 FROM software WHERE software_client_id = client_id + AND software_template_id = $software_template_id AND software_archived_at IS NULL) AS has_one + FROM clients WHERE client_archived_at IS NULL AND client_id IN (" . implode(',', $client_ids) . ") ORDER BY client_name ASC"); + while ($row = mysqli_fetch_assoc($sql)) { + $client_id = intval($row['client_id']); + if ($skip_existing && intval($row['has_one'])) { + $result['skipped'][$client_id] = (string) $row['client_name']; + continue; + } + $software_id = softwareTemplateMakeLicense($software_template_id, $client_id, $post, $make_vendor, $vendor_made); + if (!$software_id) { + break; + } + $result['made'][$client_id] = $software_id; + if ($vendor_made) { + $result['vendors'][$client_id] = true; + } + } + return $result; +}