mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
784 lines
31 KiB
PHP
784 lines
31 KiB
PHP
<?php
|
|
|
|
/*
|
|
* ITFlow - GET/POST request handler for products
|
|
*
|
|
* Products and services in the catalog - functions/products.php does the
|
|
* saving, so New, Edit, Copy and Import follow the same rules.
|
|
*/
|
|
|
|
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
|
|
|
|
/** The product fields from a posted form, raw. */
|
|
function productFormInput() {
|
|
$in = [];
|
|
foreach (['name', 'type', 'category', 'price', 'cost', 'tax', 'frequency', 'seat_source', 'code', 'vendor', 'vendor_part', 'reorder_level', 'reorder_qty', 'location',
|
|
'asset_type', 'make', 'model', 'warranty_months', 'description', 'notes'] as $key) {
|
|
if (array_key_exists($key, $_POST)) {
|
|
$in[$key] = (string) $_POST[$key];
|
|
}
|
|
}
|
|
// A switch posts nothing when off
|
|
if (array_key_exists('type', $_POST)) {
|
|
$in['track_stock'] = !empty($_POST['track_stock']);
|
|
}
|
|
return $in;
|
|
}
|
|
|
|
if (isset($_POST['add_product'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$product_id = productSave(productFormInput(), 0, $error);
|
|
if (!$product_id) {
|
|
flashAlert(escapeHtml($error), 'error');
|
|
redirect();
|
|
}
|
|
$product = productRow($product_id);
|
|
$name = escapeSql($product['product_name']);
|
|
$type_label = productTypes()[$product['product_type']]['label'];
|
|
|
|
logAudit("Product", "Create", "$session_name created " . strtolower($type_label) . " $name", 0, $product_id);
|
|
|
|
flashAlert("$type_label <strong>" . escapeHtml($product['product_name']) . "</strong> added");
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
if (isset($_POST['edit_product'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
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');
|
|
redirect();
|
|
}
|
|
$product = productRow($product_id);
|
|
$name = escapeSql($product['product_name']);
|
|
|
|
logAudit("Product", "Edit", "$session_name edited product $name", 0, $product_id);
|
|
|
|
// 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 <strong>" . intval($behind['lines']) . "</strong> line" . ($behind['lines'] == 1 ? ' is' : 's are') . " on recurring invoices, drafts or quotes at an old price. Update them from its page.";
|
|
}
|
|
}
|
|
|
|
flashAlert("<strong>" . escapeHtml($product['product_name']) . "</strong> saved$price_note");
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
if (isset($_GET['copy_product'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$product = productRow(intval($_GET['copy_product']));
|
|
$new_id = $product ? productCopy($product['product_id']) : 0;
|
|
if (!$new_id) {
|
|
flashAlert('That product no longer exists.', 'error');
|
|
redirect('products.php');
|
|
}
|
|
$name = escapeSql($product['product_name']);
|
|
|
|
logAudit("Product", "Create", "$session_name copied product $name", 0, $new_id);
|
|
|
|
flashAlert("Copy of <strong>" . escapeHtml($product['product_name']) . "</strong> made - give it a code and check its details");
|
|
|
|
redirect("product.php?product_id=$new_id");
|
|
|
|
}
|
|
|
|
if (isset($_GET['archive_product'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$product_id = intval($_GET['archive_product']);
|
|
|
|
$product_name = escapeSql(getFieldById('products', $product_id, 'product_name'));
|
|
|
|
mysqli_query($mysqli,"UPDATE products SET product_archived_at = NOW() WHERE product_id = $product_id");
|
|
|
|
logAudit("Product", "Archive", "$session_name archived product $product_name", 0, $product_id);
|
|
|
|
flashAlert("Product <strong>" . escapeHtml(stripslashes($product_name)) . "</strong> archived - it is no longer offered on new lines", 'error');
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
if (isset($_GET['restore_product'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$product_id = intval($_GET['restore_product']);
|
|
|
|
$product_name = escapeSql(getFieldById('products', $product_id, 'product_name'));
|
|
|
|
mysqli_query($mysqli,"UPDATE products SET product_archived_at = NULL WHERE product_id = $product_id");
|
|
|
|
logAudit("Product", "Restore", "$session_name restored product $product_name", 0, $product_id);
|
|
|
|
flashAlert("Product <strong>" . escapeHtml(stripslashes($product_name)) . "</strong> restored");
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
if (isset($_GET['delete_product'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 3);
|
|
|
|
$product_id = intval($_GET['delete_product']);
|
|
|
|
$product_name = escapeSql(getFieldById('products', $product_id, 'product_name'));
|
|
|
|
productDelete($product_id);
|
|
|
|
logAudit("Product", "Delete", "$session_name deleted product $product_name");
|
|
|
|
flashAlert("Product <strong>" . escapeHtml(stripslashes($product_name)) . "</strong> deleted", 'error');
|
|
|
|
// Not back to its page - it is gone
|
|
redirect('products.php?archived=1');
|
|
|
|
}
|
|
|
|
if (isset($_POST['bulk_edit_product_category'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$category_id = intval($_POST['bulk_category_id']);
|
|
|
|
// Get Category name for logging and Notification
|
|
$category_name = escapeSql(getFieldById('categories', $category_id, 'category_name'));
|
|
|
|
// Assign category to Selected Products
|
|
if (isset($_POST['product_ids'])) {
|
|
|
|
// Get Count
|
|
$count = count($_POST['product_ids']);
|
|
|
|
foreach($_POST['product_ids'] as $product_id) {
|
|
$product_id = intval($product_id);
|
|
|
|
// Get Product Details for Logging
|
|
$product_name = escapeSql(getFieldById('products', $product_id, 'product_name'));
|
|
|
|
mysqli_query($mysqli,"UPDATE products SET product_category_id = $category_id WHERE product_id = $product_id");
|
|
|
|
logAudit("Product", "Edit", "$session_name assigned product $product_name to category $category_name", 0, $product_id);
|
|
|
|
} // End Assign Product Loop
|
|
|
|
logAudit("Product", "Edit", "$session_name assigned category $category_name to $count product(s)");
|
|
|
|
flashAlert("Assigned category <strong>" . escapeHtml(stripslashes($category_name)) . "</strong> to <strong>$count</strong> product(s)");
|
|
}
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
if (isset($_POST['bulk_archive_products'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
if (isset($_POST['product_ids'])) {
|
|
|
|
$count = count($_POST['product_ids']);
|
|
|
|
// Cycle through array and archive each record
|
|
foreach ($_POST['product_ids'] as $product_id) {
|
|
|
|
$product_id = intval($product_id);
|
|
|
|
$product_name = escapeSql(getFieldById('products', $product_id, 'product_name'));
|
|
|
|
mysqli_query($mysqli,"UPDATE products SET product_archived_at = NOW() WHERE product_id = $product_id");
|
|
|
|
logAudit("Product", "Archive", "$session_name archived product $product_name", 0, $product_id);
|
|
}
|
|
|
|
logAudit("Product", "Bulk Archive", "$session_name archived $count product(s)");
|
|
|
|
flashAlert("Archived <strong>$count</strong> product(s)", 'error');
|
|
|
|
}
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
if (isset($_POST['bulk_restore_products'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
if (isset($_POST['product_ids'])) {
|
|
|
|
$count = count($_POST['product_ids']);
|
|
|
|
// Cycle through array and unarchive each record
|
|
foreach ($_POST['product_ids'] as $product_id) {
|
|
|
|
$product_id = intval($product_id);
|
|
|
|
$product_name = escapeSql(getFieldById('products', $product_id, 'product_name'));
|
|
|
|
mysqli_query($mysqli,"UPDATE products SET product_archived_at = NULL WHERE product_id = $product_id");
|
|
|
|
logAudit("Product", "Restore", "$session_name restored product $product_name", 0, $product_id);
|
|
|
|
}
|
|
|
|
logAudit("Product", "Bulk Restore", "$session_name restored $count product(s)");
|
|
|
|
flashAlert("Restored <strong>$count</strong> product(s)");
|
|
|
|
}
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
if (isset($_POST['bulk_delete_products'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 3);
|
|
|
|
if (isset($_POST['product_ids'])) {
|
|
|
|
$count = count($_POST['product_ids']);
|
|
|
|
// Cycle through array and delete each record
|
|
foreach ($_POST['product_ids'] as $product_id) {
|
|
$product_id = intval($product_id);
|
|
|
|
$product_name = escapeSql(getFieldById('products', $product_id, 'product_name'));
|
|
|
|
productDelete($product_id);
|
|
|
|
logAudit("Product", "Delete", "$session_name deleted product $product_name");
|
|
|
|
}
|
|
|
|
logAudit("Product", "Bulk Delete", "$session_name deleted $count product(s)");
|
|
|
|
flashAlert("Deleted <strong>$count</strong> product(s)", 'error');
|
|
|
|
}
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
if (isExportRequest('export_products')) {
|
|
|
|
validateCSRFToken();
|
|
|
|
// Exports are reads - see CONTRIBUTING.md
|
|
enforceUserPermission('module_sales');
|
|
|
|
$format = resolveExportFormat($_POST['export_products']);
|
|
|
|
// Filters inherited from the products page - mirrors agent/products.php
|
|
$filter_summary = [];
|
|
|
|
// Archived Filter
|
|
$archived = (isset($_POST['archived']) && $_POST['archived'] == 1);
|
|
if ($archived) {
|
|
$filter_summary['Archived'] = 'Archived only';
|
|
}
|
|
|
|
$client_id = 0; // for Logging
|
|
$file_name_prepend = "$session_company_name-";
|
|
|
|
// Type Filter - the page's tabs
|
|
$type_filter = (string) ($_POST['type'] ?? '');
|
|
$type_queries = [
|
|
'service' => ["AND product_type = 'service'", 'Services'],
|
|
'product' => ["AND product_type = 'product'", 'Products'],
|
|
'recurring' => ["AND product_frequency IS NOT NULL", 'Billed each month, quarter or year'],
|
|
'stocked' => ["AND product_track_stock = 1", 'Stock tracked'],
|
|
'reorder' => ["AND " . productReorderSql(), 'To reorder'],
|
|
'hardware' => ["AND " . productHardwareSql(), 'Become client assets'],
|
|
];
|
|
$type_query = '';
|
|
if (isset($type_queries[$type_filter])) {
|
|
[$type_query, $filter_summary['Showing']] = $type_queries[$type_filter];
|
|
}
|
|
|
|
$archive_query = $archived ? "product_archived_at IS NOT NULL" : "product_archived_at IS NULL";
|
|
|
|
// Category Filter
|
|
if (!empty($_POST['category'])) {
|
|
$filter_category_id = intval($_POST['category']);
|
|
$category_query = "AND (category_id = $filter_category_id)";
|
|
$filter_summary['Category'] = getFieldById('categories', $filter_category_id, 'category_name');
|
|
} else {
|
|
// Default - any
|
|
$category_query = '';
|
|
}
|
|
|
|
// Vendor Filter
|
|
$vendor_query = '';
|
|
if (!empty($_POST['vendor'])) {
|
|
$filter_vendor_id = intval($_POST['vendor']);
|
|
$vendor_query = "AND product_vendor_id = $filter_vendor_id";
|
|
$filter_summary['Vendor'] = exportRecordName('vendors', $filter_vendor_id, 'vendor_name', 'vendor_client_id');
|
|
}
|
|
|
|
// Search Filter
|
|
$q = escapeSql($_POST['q'] ?? '');
|
|
if (!empty($q)) {
|
|
$filter_summary['Search'] = $_POST['q'];
|
|
}
|
|
|
|
$sql = mysqli_query(
|
|
$mysqli,
|
|
"SELECT products.*, category_name, tax_name, tax_percent, vendor_name, " . productStockSql() . " AS product_stock
|
|
FROM products
|
|
LEFT JOIN categories ON product_category_id = category_id
|
|
LEFT JOIN taxes ON product_tax_id = tax_id
|
|
LEFT JOIN vendors ON product_vendor_id = vendor_id
|
|
WHERE (product_name LIKE '%$q%' OR product_description LIKE '%$q%' OR product_code LIKE '%$q%' OR product_vendor_part LIKE '%$q%' OR product_location LIKE '%$q%'
|
|
OR category_name LIKE '%$q%' OR vendor_name LIKE '%$q%' OR product_price LIKE '%$q%' OR tax_name LIKE '%$q%')
|
|
$type_query
|
|
AND $archive_query
|
|
$category_query
|
|
$vendor_query
|
|
ORDER BY product_name ASC"
|
|
);
|
|
|
|
$num_rows = mysqli_num_rows($sql);
|
|
|
|
guardExportHasRows($num_rows, 'products');
|
|
|
|
if ($num_rows > 0) {
|
|
|
|
guardExportPdfRowCount($format, $num_rows);
|
|
|
|
$export = beginExport('products', $format, $file_name_prepend . 'Products', 'Products', summarizeExportFilters($filter_summary));
|
|
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$margin = productMargin($row['product_price'], $row['product_cost']);
|
|
$row['product_type_label'] = productTypes()[$row['product_type']]['label'] ?? $row['product_type'];
|
|
$row['product_billed'] = productBilledText($row['product_frequency'], $row['product_seat_source']);
|
|
$row['product_margin'] = $margin ? $margin['percent'] . '%' : '';
|
|
$row['product_tax'] = $row['tax_name'] ? taxLabel($row) : '';
|
|
$row['product_stock_display'] = intval($row['product_track_stock']) ? billingQuantity($row['product_stock']) : '';
|
|
$row['product_reorder_display'] = intval($row['product_track_stock']) && floatval($row['product_reorder_level']) > 0 ? billingQuantity($row['product_reorder_level']) : '';
|
|
$row['product_order_display'] = intval($row['product_track_stock']) && floatval($row['product_reorder_qty']) > 0 ? billingQuantity($row['product_reorder_qty']) : '';
|
|
$row['product_stock_value'] = intval($row['product_track_stock']) && floatval($row['product_stock']) > 0 ? round(floatval($row['product_stock']) * floatval($row['product_cost']), 2) : '';
|
|
$row['product_warranty_display'] = intval($row['product_warranty_months']) > 0 ? intval($row['product_warranty_months']) : '';
|
|
addExportRow($export, $row);
|
|
}
|
|
|
|
finishExport($export);
|
|
}
|
|
|
|
logAudit("Product", "Export", "$session_name exported $num_rows product(s) to a " . strtoupper($format) . " file", $client_id);
|
|
|
|
exit;
|
|
|
|
}
|
|
|
|
/** A stock quantity from a form - a number above zero (zero or more with $zero), up to 2 places, or null. */
|
|
function productStockInput($value, $zero = false) {
|
|
$raw = trim(str_replace([',', ' '], '', (string) $value));
|
|
if ($raw === '' || !is_numeric($raw) || floatval($raw) >= 1e9 || round(floatval($raw), 2) != floatval($raw)) {
|
|
return null;
|
|
}
|
|
$qty = round(floatval($raw), 2);
|
|
return $qty > 0 || ($zero && $qty == 0) ? $qty : null;
|
|
}
|
|
|
|
/*
|
|
* The Stock window - mode receive (it came in, at a unit cost, on an
|
|
* expense), count (what is on the shelf now, the difference recorded) or
|
|
* write_off (damaged, lost, used by us, sent back).
|
|
*/
|
|
if (isset($_POST['product_stock_save'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$product = productRow(intval($_POST['product_id'] ?? 0));
|
|
if (!$product) {
|
|
flashAlert('That product no longer exists.', 'error');
|
|
redirect('products.php');
|
|
}
|
|
$product_id = intval($product['product_id']);
|
|
$name_html = '<strong>' . escapeHtml($product['product_name']) . '</strong>';
|
|
if (!intval($product['product_track_stock'])) {
|
|
flashAlert("$name_html doesn't track stock - turn on Track stock in Edit first.", 'error');
|
|
redirect();
|
|
}
|
|
$mode = (string) ($_POST['mode'] ?? 'receive');
|
|
$on_hand = productOnHand($product_id);
|
|
$note = trim((string) ($_POST['note'] ?? ''));
|
|
$product_name = escapeSql($product['product_name']);
|
|
|
|
if ($mode === 'receive') {
|
|
$qty = productStockInput($_POST['qty'] ?? '');
|
|
if ($qty === null) {
|
|
flashAlert('Enter how many came in - a number above zero.', 'error');
|
|
redirect();
|
|
}
|
|
$unit_cost = trim(str_replace([',', '$', ' '], '', (string) ($_POST['unit_cost'] ?? '')));
|
|
if ($unit_cost !== '' && (!is_numeric($unit_cost) || floatval($unit_cost) < 0 || floatval($unit_cost) >= 1e13)) {
|
|
flashAlert('The unit cost has to be a number, zero or more.', 'error');
|
|
redirect();
|
|
}
|
|
// Linked to an expense only by a role that can see expenses, and one it can see (the window's list)
|
|
$expense = lookupUserPermission('module_financial') >= 1 ? intval($_POST['expense'] ?? 0) : 0;
|
|
if ($expense && !mysqli_num_rows(mysqli_query($mysqli, "SELECT expense_id FROM expenses WHERE expense_id = $expense " . clientScopeSql('expense_client_id')))) {
|
|
$expense = 0;
|
|
}
|
|
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)) {
|
|
$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);
|
|
}
|
|
logAudit("Product", "Stock", "$session_name received " . billingQuantity($qty) . " of product $product_name", 0, $product_id);
|
|
flashAlert(billingQuantity($qty) . " of $name_html received - " . billingQuantity($on_hand + $qty) . " on the shelf" . escapeHtml($cost_note));
|
|
|
|
} elseif ($mode === 'count') {
|
|
$counted = productStockInput($_POST['counted'] ?? '', true);
|
|
if ($counted === null) {
|
|
flashAlert('Enter what is on the shelf - zero or more.', 'error');
|
|
redirect();
|
|
}
|
|
$difference = round($counted - $on_hand, 2);
|
|
if ($difference == 0) {
|
|
flashAlert(billingQuantity($counted) . " of $name_html on the shelf - that is what ITFlow had, nothing to change");
|
|
redirect();
|
|
}
|
|
productStockMove($product_id, $difference, 'adjusted', ['note' => 'Counted ' . billingQuantity($counted) . ($note !== '' ? " - $note" : '')]);
|
|
logAudit("Product", "Stock", "$session_name counted product $product_name - " . billingQuantity($counted) . ' on the shelf, ' . productStockQty($difference), 0, $product_id);
|
|
flashAlert("$name_html counted - " . billingQuantity($counted) . ' on the shelf, ' . productStockQty($difference) . ' from what ITFlow had');
|
|
|
|
} elseif ($mode === 'write_off') {
|
|
$qty = productStockInput($_POST['qty'] ?? '');
|
|
if ($qty === null) {
|
|
flashAlert('Enter how many to write off - a number above zero.', 'error');
|
|
redirect();
|
|
}
|
|
if ($qty > $on_hand) {
|
|
flashAlert("Only " . billingQuantity($on_hand) . " of $name_html on the shelf - count it first if there are more.", 'error');
|
|
redirect();
|
|
}
|
|
$reason = in_array($_POST['reason'] ?? '', productWriteOffReasons(), true) ? $_POST['reason'] : 'Other';
|
|
productStockMove($product_id, -$qty, 'written_off', ['note' => $reason . ($note !== '' ? " - $note" : '')]);
|
|
logAudit("Product", "Stock", "$session_name wrote off " . billingQuantity($qty) . " of product $product_name - " . escapeSql($reason), 0, $product_id);
|
|
flashAlert(billingQuantity($qty) . " of $name_html written off - " . billingQuantity($on_hand - $qty) . ' on the shelf');
|
|
|
|
} else {
|
|
flashAlert('That is not something the Stock window does.', 'error');
|
|
}
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
/*
|
|
* Count Stock - what is on the shelf for each product counted (counted[id]),
|
|
* the differences recorded together. Blank ones are left alone.
|
|
*/
|
|
if (isset($_POST['product_stock_count'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$note = trim((string) ($_POST['note'] ?? ''));
|
|
$changed = $same = 0;
|
|
$bad = [];
|
|
foreach ((array) ($_POST['counted'] ?? []) as $product_id => $value) {
|
|
if (trim((string) $value) === '') {
|
|
continue;
|
|
}
|
|
$product = productRow(intval($product_id));
|
|
if (!$product || !intval($product['product_track_stock'])) {
|
|
continue;
|
|
}
|
|
$counted = productStockInput($value, true);
|
|
if ($counted === null) {
|
|
$bad[] = $product['product_name'];
|
|
continue;
|
|
}
|
|
$difference = round($counted - productOnHand($product['product_id']), 2);
|
|
if ($difference == 0) {
|
|
$same++;
|
|
continue;
|
|
}
|
|
productStockMove($product['product_id'], $difference, 'adjusted', ['note' => 'Stock count - ' . billingQuantity($counted) . ($note !== '' ? " - $note" : '')]);
|
|
logAudit("Product", "Stock", "$session_name counted product " . escapeSql($product['product_name']) . ' - ' . billingQuantity($counted) . ' on the shelf, ' . productStockQty($difference), 0, intval($product['product_id']));
|
|
$changed++;
|
|
}
|
|
|
|
logAudit("Product", "Stock", "$session_name saved a stock count - $changed changed, $same as expected");
|
|
|
|
$message = "Stock count saved - <strong>$changed</strong> changed, <strong>$same</strong> as expected";
|
|
if ($bad) {
|
|
$message .= '. Not a number, left alone: ' . escapeHtml(implode(', ', $bad));
|
|
}
|
|
flashAlert($message, $bad && !$changed ? 'error' : 'success');
|
|
|
|
redirect('products.php?type=stocked');
|
|
|
|
}
|
|
|
|
/*
|
|
* Import from a CSV with a header row. Columns are found by their heading,
|
|
* in any order - only Name is needed:
|
|
* Name, Type, Code, Category, Description, Price, Cost, Tax, Billed, Counted,
|
|
* Vendor, Part Number, Track Stock, Reorder At, Order, Location, Notes
|
|
* The old three column file (Product Name, Description, Price) still works.
|
|
* A row whose code matches a product already here updates it when asked to.
|
|
*/
|
|
if (isset($_POST["import_products_csv"])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
if (empty($_FILES["file"]["tmp_name"]) || !is_uploaded_file($_FILES["file"]["tmp_name"])) {
|
|
flashAlert("Choose a CSV file to import.", 'error');
|
|
redirect();
|
|
}
|
|
if (strtolower(pathinfo((string) $_FILES['file']['name'], PATHINFO_EXTENSION)) !== 'csv') {
|
|
flashAlert("That isn't a CSV file.", 'error');
|
|
redirect();
|
|
}
|
|
$update_existing = !empty($_POST['update_existing']);
|
|
|
|
$file = fopen($_FILES["file"]["tmp_name"], "r");
|
|
$header = $file ? fgetcsv($file, 0, ",", '"', '\\') : false;
|
|
if (!$header) {
|
|
flashAlert("The file is empty.", 'error');
|
|
redirect();
|
|
}
|
|
|
|
// Which column is which, by its heading
|
|
$aliases = [
|
|
'name' => ['name', 'product name', 'product', 'item'],
|
|
'type' => ['type'],
|
|
'code' => ['code', 'sku', 'product code'],
|
|
'category' => ['category'],
|
|
'description' => ['description'],
|
|
'price' => ['price', 'unit price'],
|
|
'cost' => ['cost', 'unit cost'],
|
|
'tax' => ['tax'],
|
|
'frequency' => ['billed', 'frequency', 'billing'],
|
|
'seat_source' => ['counted', 'per'],
|
|
'vendor' => ['vendor', 'supplier'],
|
|
'vendor_part' => ['part number', 'vendor part', 'mpn', 'part'],
|
|
'track_stock' => ['track stock', 'tracked', 'stock tracked'],
|
|
'reorder_level' => ['reorder at', 'reorder level', 'reorder point', 'minimum stock'],
|
|
'reorder_qty' => ['order', 'order qty', 'reorder qty', 'reorder quantity'],
|
|
'location' => ['location'],
|
|
'asset_type' => ['asset type', 'asset', 'client asset'],
|
|
'make' => ['make', 'manufacturer', 'brand'],
|
|
'model' => ['model'],
|
|
'warranty_months' => ['warranty', 'warranty months', 'warranty (months)'],
|
|
'notes' => ['notes', 'internal notes'],
|
|
];
|
|
$columns = [];
|
|
foreach ($header as $index => $heading) {
|
|
$heading = strtolower(trim(preg_replace('/^\xEF\xBB\xBF/', '', (string) $heading)));
|
|
foreach ($aliases as $key => $names) {
|
|
if (in_array($heading, $names, true) && !isset($columns[$key])) {
|
|
$columns[$key] = $index;
|
|
}
|
|
}
|
|
}
|
|
// A price list update can go by code alone
|
|
if (!isset($columns['name']) && !($update_existing && isset($columns['code']))) {
|
|
fclose($file);
|
|
flashAlert("The first row needs a Name column - download the template to see the layout.", 'error');
|
|
redirect();
|
|
}
|
|
|
|
// Names to ids, matched without case
|
|
$lookup = function ($sql) use ($mysqli) {
|
|
$map = [];
|
|
$result = mysqli_query($mysqli, $sql);
|
|
while ($row = mysqli_fetch_row($result)) {
|
|
$map[mb_strtolower(trim((string) $row[1]))] = intval($row[0]);
|
|
}
|
|
return $map;
|
|
};
|
|
$categories = $lookup("SELECT category_id, category_name FROM categories WHERE category_type = 'Income' AND category_archived_at IS NULL");
|
|
$taxes = $lookup("SELECT tax_id, tax_name FROM taxes WHERE tax_archived_at IS NULL");
|
|
$vendors = $lookup("SELECT vendor_id, vendor_name FROM vendors WHERE vendor_client_id = 0 AND vendor_archived_at IS NULL");
|
|
$frequencies = ['' => '', 'once' => '', 'one time' => '', 'one-time' => '', 'monthly' => 'month', 'month' => 'month', 'quarterly' => 'quarter', 'quarter' => 'quarter', 'yearly' => 'year', 'year' => 'year', 'annually' => 'year', 'annual' => 'year'];
|
|
$seat_sources = ['' => '', 'as typed' => '', 'per user' => 'contacts', 'user' => 'contacts', 'users' => 'contacts', 'per device' => 'assets', 'device' => 'assets', 'devices' => 'assets'];
|
|
|
|
$added = $updated = 0;
|
|
$skipped = [];
|
|
$line = 1;
|
|
while (($row = fgetcsv($file, 0, ",", '"', '\\')) !== false) {
|
|
$line++;
|
|
if ($row === [null] || !array_filter($row, function ($v) { return trim((string) $v) !== ''; })) {
|
|
continue;
|
|
}
|
|
$in = [];
|
|
foreach ($columns as $key => $index) {
|
|
$in[$key] = trim((string) ($row[$index] ?? ''));
|
|
}
|
|
if (isset($in['type'])) {
|
|
$type = strtolower($in['type']);
|
|
$in['type'] = in_array($type, ['product', 'products', 'hardware', 'goods'], true) ? 'product' : 'service';
|
|
}
|
|
if (isset($in['category'])) {
|
|
$in['category'] = $categories[mb_strtolower($in['category'])] ?? 0;
|
|
}
|
|
if (isset($in['tax'])) {
|
|
$in['tax'] = $taxes[mb_strtolower($in['tax'])] ?? 0;
|
|
}
|
|
if (isset($in['vendor'])) {
|
|
$in['vendor'] = $vendors[mb_strtolower($in['vendor'])] ?? 0;
|
|
}
|
|
if (isset($in['frequency'])) {
|
|
$in['frequency'] = $frequencies[strtolower($in['frequency'])] ?? '';
|
|
}
|
|
if (isset($in['seat_source'])) {
|
|
$in['seat_source'] = $seat_sources[strtolower($in['seat_source'])] ?? '';
|
|
}
|
|
if (isset($in['track_stock'])) {
|
|
$in['track_stock'] = in_array(strtolower($in['track_stock']), ['1', 'yes', 'y', 'true', 'tracked'], true);
|
|
}
|
|
|
|
// The same code as one here - update it, or leave it
|
|
$existing_id = 0;
|
|
if (($in['code'] ?? '') !== '') {
|
|
$code = escapeSql($in['code']);
|
|
$existing_id = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT product_id FROM products WHERE product_code = '$code' ORDER BY product_archived_at IS NOT NULL, product_id LIMIT 1"))[0] ?? 0);
|
|
}
|
|
if ($existing_id && !$update_existing) {
|
|
$skipped[] = "row $line - " . $in['code'] . " is already here";
|
|
continue;
|
|
}
|
|
if ($existing_id) {
|
|
// An update leaves the name alone when the file has none for it
|
|
if (($in['name'] ?? '') === '') {
|
|
unset($in['name']);
|
|
}
|
|
} else {
|
|
if (($in['name'] ?? '') === '') {
|
|
$skipped[] = "row $line has no name";
|
|
continue;
|
|
}
|
|
// A new one starts as the kind the file says, a product when it doesn't say
|
|
$in['type'] = $in['type'] ?? 'product';
|
|
}
|
|
|
|
$error = '';
|
|
if (productSave($in, $existing_id, $error, 'import')) {
|
|
$existing_id ? $updated++ : $added++;
|
|
} else {
|
|
$skipped[] = "row $line - " . rtrim($error, '.');
|
|
}
|
|
}
|
|
fclose($file);
|
|
|
|
logAudit("Product", "Import", "$session_name imported products from a CSV file - $added added, $updated updated, " . count($skipped) . " skipped");
|
|
|
|
$message = "<strong>$added</strong> added" . ($update_existing ? ", <strong>$updated</strong> updated" : '');
|
|
if ($skipped) {
|
|
$message .= ", <strong>" . count($skipped) . "</strong> 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 .= ". <strong>" . intval($behind['lines']) . "</strong> 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 = "<strong>{$done['lines']}</strong> price" . ($done['lines'] === 1 ? '' : 's') . " updated on <strong>{$done['documents']}</strong> 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');
|
|
fputcsv($f, ['Name', 'Type', 'Code', 'Category', 'Description', 'Price', 'Cost', 'Tax', 'Billed', 'Counted', 'Vendor', 'Part Number', 'Track Stock', 'Reorder At', 'Order', 'Location', 'Asset Type', 'Make', 'Model', 'Warranty', 'Notes'], ',', '"', '\\');
|
|
fputcsv($f, ['Managed Workstation', 'Service', 'MS-WKS', 'Managed Services', 'Per workstation, per month. Monitoring, patching and support.', '65.00', '18.00', '', 'Monthly', 'Per device', '', '', '', '', '', '', '', '', '', '', ''], ',', '"', '\\');
|
|
fputcsv($f, ['24 Port PoE Switch', 'Product', 'HW-SW24', 'Hardware Sales', '24 port managed PoE switch.', '785.00', '520.00', '', 'One time', '', '', 'USW-PRO-24-POE', 'Yes', '2', '5', 'Shelf B2', 'Switch', 'Ubiquiti', 'USW-Pro-24-PoE', '12', 'Order from the distributor, 2 day lead time'], ',', '"', '\\');
|
|
fseek($f, 0);
|
|
|
|
header('Content-Type: text/csv');
|
|
header('Content-Disposition: attachment; filename="Products-Template.csv";');
|
|
fpassthru($f);
|
|
|
|
exit;
|
|
|
|
}
|