Demo Data Take 6

This commit is contained in:
johnnyq
2026-08-28 19:23:36 -04:00
parent 8d25337774
commit 3277567b87
3 changed files with 122 additions and 46 deletions

View File

@@ -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,

View File

@@ -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");

View File

@@ -134,8 +134,8 @@ foreach ($starter_content_status as $pack_status) {
the library, so <strong>renaming a demo client stops it being removable</strong>. Contacts use addresses on the
reserved <strong>.example</strong> 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.
</p>
<?php if ($demo_data_status['other_clients'] && !$demo_data_loaded) { ?>