From 3277567b87e152181c634ca03ff66b482a1f770c Mon Sep 17 00:00:00 2001 From: johnnyq Date: Fri, 28 Aug 2026 19:23:36 -0400 Subject: [PATCH] Demo Data Take 6 --- admin/post/demo_data_model.php | 161 ++++++++++++++++++++++++--------- admin/post/starter_content.php | 3 - admin/starter_content.php | 4 +- 3 files changed, 122 insertions(+), 46 deletions(-) diff --git a/admin/post/demo_data_model.php b/admin/post/demo_data_model.php index b2467ea01..911c7622f 100644 --- a/admin/post/demo_data_model.php +++ b/admin/post/demo_data_model.php @@ -95,6 +95,38 @@ function demoDataTaxes() { ]; } +// ------------------------------ +// demoCategoryId +// starterCategoryId returns 0 for a category that is not there, which is how a +// load run before the Categories pack ends up with every expense, invoice and +// ticket uncategorised. This creates what is missing instead, so the data is +// complete whatever order the packs were loaded in. +// ------------------------------ +function demoCategoryId($mysqli, $name, $type) { + + static $cache = []; + + $key = $type . '|' . $name; + if (isset($cache[$key])) { + return $cache[$key]; + } + + $id = starterCategoryId($mysqli, $name, $type); + + if (!$id) { + $id = starterInsert($mysqli, 'categories', [ + 'category_name' => $name, + 'category_type' => $type, + 'category_color' => $type === 'Income' ? '#3d9970' : '#dc3545', + 'category_created_at' => demoMonthDateTime(24, 1, 9, 0), + ]); + } + + $cache[$key] = $id; + + return $id; +} + // ------------------------------ // demoDataProducts // Catalogue lines for the things this MSP actually sells, sitting alongside @@ -165,7 +197,7 @@ function demoEnsureProducts($mysqli, $currency) { 'product_price' => $product[3], 'product_currency_code' => $currency, 'product_description' => $product[5], - 'product_category_id' => starterCategoryId($mysqli, $product[4], 'Income'), + 'product_category_id' => demoCategoryId($mysqli, $product[4], 'Income'), 'product_tax_id' => 0, ]); } @@ -399,28 +431,30 @@ function demoLink($mysqli, $table, $left_column, $left_id, $right_column, $right // ------------------------------ function demoEnsureAccounts($mysqli, $currency) { - $accounts = ['ids' => [], 'created' => 0]; - $existing = []; - - $sql = mysqli_query($mysqli, "SELECT account_id, account_name FROM accounts"); - while ($row = mysqli_fetch_assoc($sql)) { - $existing[mb_strtolower($row['account_name'])] = intval($row['account_id']); - } + $accounts = []; foreach (demoDataAccounts() as $account) { - $key = mb_strtolower($account[0]); - if (isset($existing[$key])) { - $accounts['ids'][$account[0]] = $existing[$key]; + + $name = escapeSql($account[0]); + $description = escapeSql($account[1]); + + // Name and description together are what make an account ours. An + // install with its own Operating Checking keeps it untouched and gets a + // separate one here, so the company ledger is always built against + // accounts this library owns rather than being skipped. + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT account_id FROM accounts WHERE account_name = '$name' AND account_description = '$description' LIMIT 1")); + if (!empty($row['account_id'])) { + $accounts[$account[0]] = intval($row['account_id']); continue; } - $accounts['ids'][$account[0]] = starterInsert($mysqli, 'accounts', [ + + $accounts[$account[0]] = starterInsert($mysqli, 'accounts', [ 'account_name' => $account[0], 'account_description' => $account[1], 'opening_balance' => number_format($account[2], 2, '.', ''), 'account_currency_code' => $currency, 'account_created_at' => demoMonthDateTime(24, 1, 9, 0), ]); - $accounts['created']++; } return $accounts; @@ -533,17 +567,14 @@ function demoDataLoad($mysqli) { 'billing' => 0, 'company' => 0, 'skipped_credentials' => 0, - 'skipped_company' => 0, ]; $existing = starterExistingNames($mysqli, 'clients', 'client_name'); - $accounts = demoEnsureAccounts($mysqli, $currency); $context = [ 'user_id' => intval($session_user_id ?? 0), 'currency' => $currency, - 'accounts' => $accounts['ids'], - 'accounts_created' => $accounts['created'], + 'accounts' => demoEnsureAccounts($mysqli, $currency), 'cash_account_id' => demoCashAccountId($mysqli), 'org_vendors' => demoEnsureOrgVendors($mysqli), 'products' => demoEnsureProducts($mysqli, $currency), @@ -576,17 +607,12 @@ function demoDataLoad($mysqli) { // The MSP's own operating costs and transfers. Only generated when the // accounts were ours to create - an install that already had an account // called Operating Checking has a real ledger, and we stay out of it. - if ($counts['clients']) { - - // The diary and the mileage log carry no money, so they go in either way - demoBuildInternalDiary($mysqli, $context, $counts); - - if ($context['accounts_created'] === count(demoDataAccounts())) { - demoBuildCompanyFinancials($mysqli, $context, $counts); - } else { - $counts['skipped_company'] = 1; - } - } + // The company side always gets built. It posts only to accounts and + // suppliers this library owns, so there is nothing to protect an existing + // install from - and both builders are guarded, so a run that only fills in + // missing clients does not double anything up. + demoBuildInternalDiary($mysqli, $context, $counts); + demoBuildCompanyFinancials($mysqli, $context, $counts); if (!$context['vault_open'] && $counts['clients']) { $counts['skipped_credentials'] = 1; @@ -858,6 +884,25 @@ function demoDataRemove($mysqli) { } + // The transfer categories are ours - the Categories pack has no equivalent. + // Anything else demoCategoryId had to create was a category this install was + // missing and genuinely needs, so those stay. + foreach (['Expense', 'Income'] as $category_type) { + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT category_id FROM categories WHERE category_name = 'Account Transfer' AND category_type = '$category_type' LIMIT 1")); + $category_id = intval($row['category_id'] ?? 0); + if (!$category_id) { + continue; + } + $in_use = 0; + foreach (['expenses' => 'expense_category_id', 'revenues' => 'revenue_category_id', 'invoices' => 'invoice_category_id', 'quotes' => 'quote_category_id', 'recurring_invoices' => 'recurring_invoice_category_id', 'recurring_expenses' => 'recurring_expense_category_id', 'products' => 'product_category_id', 'budget' => 'budget_category_id'] as $table => $column) { + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS total FROM $table WHERE $column = $category_id")); + $in_use = $in_use + intval($row['total'] ?? 0); + } + if (!$in_use) { + mysqli_query($mysqli, "DELETE FROM categories WHERE category_id = $category_id"); + } + } + // Closure days and budget lines, matched on exactly what we wrote foreach ([date('Y') - 1, date('Y'), date('Y') + 1] as $year) { foreach (demoDataHolidays($year) as $holiday) { @@ -869,7 +914,7 @@ function demoDataRemove($mysqli) { foreach ([date('Y') - 1, date('Y')] as $year) { foreach (demoDataBudgets() as $budget) { - $category_id = starterCategoryId($mysqli, $budget[0], 'Expense'); + $category_id = demoCategoryId($mysqli, $budget[0], 'Expense'); if (!$category_id) { continue; } @@ -2032,7 +2077,7 @@ function demoBuildTickets($mysqli, $profile, $index, $client_id, $contact_ids, $ $category_ids = []; $category_id = function ($name) use ($mysqli, &$category_ids) { if (!array_key_exists($name, $category_ids)) { - $category_ids[$name] = starterCategoryId($mysqli, $name, 'Ticket'); + $category_ids[$name] = demoCategoryId($mysqli, $name, 'Ticket'); } return (string)$category_ids[$name]; }; @@ -2424,10 +2469,10 @@ function demoBuildBilling($mysqli, $profile, $index, $client_id, $vendor_id, $mo $currency = $context['currency']; $tax = $context['taxes'][$profile['tax']] ?? null; - $income_category_id = starterCategoryId($mysqli, 'Managed Services', 'Income'); - $project_category_id = starterCategoryId($mysqli, 'Projects', 'Income') ?: $income_category_id; - $hardware_category_id = starterCategoryId($mysqli, 'Hardware Sales', 'Income') ?: $income_category_id; - $support_category_id = starterCategoryId($mysqli, 'Support', 'Income') ?: $income_category_id; + $income_category_id = demoCategoryId($mysqli, 'Managed Services', 'Income'); + $project_category_id = demoCategoryId($mysqli, 'Projects', 'Income') ?: $income_category_id; + $hardware_category_id = demoCategoryId($mysqli, 'Hardware Sales', 'Income') ?: $income_category_id; + $support_category_id = demoCategoryId($mysqli, 'Support', 'Income') ?: $income_category_id; $bank_account_id = $context['accounts']['Operating Checking'] ?? 0; $card_account_id = $context['accounts']['Merchant Settlement'] ?? $bank_account_id; @@ -2723,7 +2768,7 @@ function demoBuildBilling($mysqli, $profile, $index, $client_id, $vendor_id, $mo 'expense_payment_method' => 'Credit Card', 'expense_vendor_id' => $context['org_vendors'][$expense[3]] ?? 0, 'expense_client_id' => $client_id, - 'expense_category_id' => starterCategoryId($mysqli, $expense[2], 'Expense'), + 'expense_category_id' => demoCategoryId($mysqli, $expense[2], 'Expense'), 'expense_account_id' => $bank_account_id, 'expense_created_at' => demoMonthDateTime($month, 14, 16, 45), ]); @@ -2773,6 +2818,17 @@ function demoBuildBilling($mysqli, $profile, $index, $client_id, $vendor_id, $mo // ------------------------------ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { + // Already built on an earlier run - every company expense carries one of + // our own suppliers, so one of those is enough to tell + $vendor_ids = array_map('intval', $context['org_vendors']); + if ($vendor_ids) { + $vendor_ids = implode(',', $vendor_ids); + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(expense_id) AS total FROM expenses WHERE expense_client_id = 0 AND expense_vendor_id IN ($vendor_ids)")); + if (intval($row['total'] ?? 0)) { + return; + } + } + $currency = $context['currency']; $vendors = $context['org_vendors']; $operating_id = $context['accounts']['Operating Checking'] ?? 0; @@ -2797,6 +2853,16 @@ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { ['Bench stock - spare drives, cables and small parts', 640.00, 'Tools and Test Equipment', 4, 19, 'Ridgeline Technology Distribution'], ['UniFi shelf stock for the workshop', 1150.00, 'Hardware - Cost of Goods', 6, 7, 'Ubiquiti Store'], ['pfSense Plus support subscriptions', 795.00, 'Software', 12, 22, 'Netgate'], + ['Office supplies and printing', 145.00, 'Office Supplies', 1, 14, 'Ridgeline Technology Distribution'], + ['Team lunch', 96.00, 'Meals', 1, 17, 'Monongahela Property Group'], + ['Mobile phones and data for the team', 268.00, 'Telecom and Internet', 1, 8, 'Three Rivers Colocation'], + ['Professional body dues and subscriptions', 210.00, 'Dues and Subscriptions', 3, 20, 'Alcott and Reyes CPA'], + ['Van servicing and tyres', 480.00, 'Vehicle and Fuel', 6, 13, 'Ridgeline Technology Distribution'], + ['Replacement bench laptop and monitors', 1650.00, 'Equipment', 12, 16, 'Ridgeline Technology Distribution'], + ['Subcontract cabling work', 1250.00, 'Contractor', 4, 25, 'Ridgeline Technology Distribution'], + ['Courier and shipping', 88.00, 'Shipping and Postage', 1, 27, 'Ridgeline Technology Distribution'], + ['Quarterly estimated tax payment', 4200.00, 'Taxes', 3, 10, 'Alcott and Reyes CPA'], + ['Conference and travel', 1450.00, 'Travel', 12, 18, 'Alcott and Reyes CPA'], ]; // Which purchases put hardware on the shelf, and what they bring in @@ -2824,7 +2890,7 @@ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { 'expense_payment_method' => $cost[0] === 'Payroll' ? 'ACH' : 'Credit Card', 'expense_vendor_id' => $vendors[$cost[5]] ?? 0, 'expense_client_id' => 0, - 'expense_category_id' => starterCategoryId($mysqli, $cost[2], 'Expense'), + 'expense_category_id' => demoCategoryId($mysqli, $cost[2], 'Expense'), 'expense_account_id' => $operating_id, 'expense_created_at' => demoMonthDateTime($month, $cost[4], 17, 0), ]); @@ -2868,7 +2934,7 @@ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { 'expense_payment_method' => 'Cash', 'expense_vendor_id' => $vendors[$item[3]] ?? 0, 'expense_client_id' => 0, - 'expense_category_id' => starterCategoryId($mysqli, $item[2], 'Expense'), + 'expense_category_id' => demoCategoryId($mysqli, $item[2], 'Expense'), 'expense_account_id' => $context['cash_account_id'], 'expense_created_at' => demoMonthDateTime($month, 23, 18, 10), ]); @@ -2897,7 +2963,7 @@ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { 'recurring_expense_currency_code' => $currency, 'recurring_expense_vendor_id' => $vendors[$recurring[5]] ?? 0, 'recurring_expense_client_id' => 0, - 'recurring_expense_category_id' => starterCategoryId($mysqli, $recurring[2], 'Expense'), + 'recurring_expense_category_id' => demoCategoryId($mysqli, $recurring[2], 'Expense'), 'recurring_expense_account_id' => $operating_id, 'recurring_expense_created_at' => demoMonthDateTime(24, $recurring[4], 9, 0), ]); @@ -2927,7 +2993,7 @@ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { // The plan the actual spend gets measured against foreach ([date('Y') - 1, date('Y')] as $year) { foreach (demoDataBudgets() as $budget) { - $category_id = starterCategoryId($mysqli, $budget[0], 'Expense'); + $category_id = demoCategoryId($mysqli, $budget[0], 'Expense'); if (!$category_id) { continue; } @@ -3050,6 +3116,12 @@ function demoBuildInternalDiary($mysqli, $context, &$counts) { return; } + // Already built on an earlier run + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(event_id) AS total FROM calendar_events WHERE event_calendar_id = $internal_id AND event_client_id = 0")); + if (intval($row['total'] ?? 0)) { + return; + } + // Weekly and monthly fixtures, running back a quarter and forward a month // title, calendar, description, weekday offset from Monday, start hour, length in hours, every N weeks $fixtures = [ @@ -3150,6 +3222,13 @@ function demoBuildInternalDiary($mysqli, $context, &$counts) { // ------------------------------ function demoTransfer($mysqli, $from_account_id, $to_account_id, $amount, $currency, $method, $notes, $month, $day, &$counts) { + // Both halves are categorised as a transfer. The handler leaves these at + // zero, but a blank category on the expense list looks like missing data - + // and because the same amount lands on the income and the expense side, the + // profit and loss still nets to nothing. + $expense_category_id = demoCategoryId($mysqli, 'Account Transfer', 'Expense'); + $revenue_category_id = demoCategoryId($mysqli, 'Account Transfer', 'Income'); + $date = demoMonthDate($month, $day); if (demoIsFuture($date)) { return; @@ -3163,7 +3242,7 @@ function demoTransfer($mysqli, $from_account_id, $to_account_id, $amount, $curre 'expense_currency_code' => $currency, 'expense_reference' => $reference, 'expense_vendor_id' => 0, - 'expense_category_id' => 0, + 'expense_category_id' => $expense_category_id, 'expense_client_id' => 0, 'expense_account_id' => $from_account_id, 'expense_created_at' => $created_at, @@ -3174,7 +3253,7 @@ function demoTransfer($mysqli, $from_account_id, $to_account_id, $amount, $curre 'revenue_amount' => number_format($amount, 2, '.', ''), 'revenue_currency_code' => $currency, 'revenue_reference' => $reference, - 'revenue_category_id' => 0, + 'revenue_category_id' => $revenue_category_id, 'revenue_account_id' => $to_account_id, 'revenue_client_id' => 0, 'revenue_created_at' => $created_at, diff --git a/admin/post/starter_content.php b/admin/post/starter_content.php index 0ad221b3d..a7ab0f734 100644 --- a/admin/post/starter_content.php +++ b/admin/post/starter_content.php @@ -61,9 +61,6 @@ if (isset($_POST['load_demo_data'])) { $summary = "{$counts['clients']} clients, {$counts['contacts']} contacts, {$counts['assets']} assets, {$counts['documentation']} documentation records, {$counts['tickets']} tickets, {$counts['projects']} projects, {$counts['billing']} billing records, {$counts['company']} company records"; - if (!empty($counts['skipped_company'])) { - $summary = $summary . " (company financials skipped - this install already has its own accounts)"; - } logAudit("Demo Data", "Create", "$session_name loaded demo data - $summary"); diff --git a/admin/starter_content.php b/admin/starter_content.php index 565dc2880..5fbba06c8 100644 --- a/admin/starter_content.php +++ b/admin/starter_content.php @@ -134,8 +134,8 @@ foreach ($starter_content_status as $pack_status) { the library, so renaming a demo client stops it being removable. Contacts use addresses on the reserved .example domain, which cannot receive mail, and the agreements are created with email notification switched off. Anything shared - accounts, suppliers, response targets, tax rates, catalogue lines and - calendars - is matched on more than its name, so records this install already had are never touched. If the - install already has bank accounts of its own, the company side of the books is left alone entirely. + calendars - is matched on more than its name, so records this install already had are never touched, and the + company side of the books is always built against accounts this page created rather than yours.