mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-06-15 11:01:06 -04:00
Implement Phase 3: Multi-attribute search AND logic and Phase 4: Sort by attribute columns
Phase 3 - Multi-attribute Search: - Add parse_attribute_search() method to parse search syntax like 'color: blue size: large' or 'color:blue AND size:large' - Update search_by_attributes() to support AND/OR logic for multiple attribute queries - Add search_by_attribute_value() private method for single value search Phase 4 - Sort by Attribute Columns: - Add get_attribute_sort_definition_id() method to detect attribute column sorting - Update Item::search() to join attribute tables when sorting by attribute columns - Update tabular_helper to make attribute columns sortable - Add sanitizeSortColumnAttribute() method in Items controller to validate attribute definition IDs as sort columns Fixes #2722 - sorting by attribute columns now works
This commit is contained in:
@@ -63,6 +63,33 @@ class Items extends Secure_Controller
|
||||
$this->config = config(OSPOS::class)->settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize sort column allowing standard columns and attribute definition IDs
|
||||
*
|
||||
* @param string|null $field The requested sort field
|
||||
* @param string $default The default sort field
|
||||
* @param array $attribute_ids Allowed attribute definition IDs
|
||||
* @return string The validated sort field
|
||||
*/
|
||||
private function sanitizeSortColumnAttribute(?string $field, string $default, array $attribute_ids): string
|
||||
{
|
||||
if ($field === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$allowed_columns = ['items.item_id', 'item_number', 'name', 'category', 'company_name', 'cost_price', 'unit_price', 'quantity'];
|
||||
|
||||
if (in_array($field, $allowed_columns)) {
|
||||
return $field;
|
||||
}
|
||||
|
||||
if (ctype_digit($field) && in_array((int) $field, $attribute_ids, true)) {
|
||||
return $field;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
@@ -105,7 +132,11 @@ class Items extends Secure_Controller
|
||||
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
|
||||
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
|
||||
$sort = $this->sanitizeSortColumn(item_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'item_id');
|
||||
|
||||
$definition_names = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS);
|
||||
$attribute_column_ids = array_keys($definition_names);
|
||||
|
||||
$sort = $this->sanitizeSortColumnAttribute($this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'item_id', $attribute_column_ids);
|
||||
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
|
||||
|
||||
$this->item_lib->set_item_location($this->request->getGet('stock_location'));
|
||||
|
||||
@@ -422,7 +422,7 @@ function get_items_manage_table_headers(): string
|
||||
$headers[] = ['item_pic' => lang('Items.image'), 'sortable' => false];
|
||||
|
||||
foreach ($definitionsWithTypes as $definition_id => $definitionInfo) {
|
||||
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => false];
|
||||
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => true];
|
||||
}
|
||||
|
||||
$headers[] = ['inventory' => '', 'escape' => false];
|
||||
|
||||
@@ -132,6 +132,43 @@ class Item extends Model
|
||||
return $this->search($search, $filters, 0, 0, 'items.name', 'asc', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse search string for attribute-specific queries
|
||||
* Supports syntax like "color: blue size: large" or "color:blue AND size:large"
|
||||
*
|
||||
* @param string $search The raw search string
|
||||
* @return array{terms: array, attributes: array} Parsed terms and attribute queries
|
||||
*/
|
||||
public function parse_attribute_search(string $search): array
|
||||
{
|
||||
$result = [
|
||||
'terms' => [],
|
||||
'attributes' => []
|
||||
];
|
||||
|
||||
if (empty($search)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$pattern = '/(\w+)\s*:\s*([^\s,]+)(?:\s+(?:AND|OR)\s+)?/i';
|
||||
$remaining = preg_replace($pattern, '', $search);
|
||||
|
||||
if (preg_match_all($pattern, $search, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$attrName = strtolower(trim($match[1]));
|
||||
$attrValue = trim($match[2]);
|
||||
$result['attributes'][$attrName][] = $attrValue;
|
||||
}
|
||||
}
|
||||
|
||||
$remaining = trim(preg_replace('/\s+/', ' ', $remaining));
|
||||
if (!empty($remaining)) {
|
||||
$result['terms'][] = $remaining;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for items by attribute values
|
||||
* Returns an array of item_ids matching the attribute search criteria
|
||||
@@ -139,14 +176,78 @@ class Item extends Model
|
||||
* @param string $search Search term
|
||||
* @param array $definition_ids Attribute definition IDs to search within
|
||||
* @param bool $include_deleted Whether to include deleted items
|
||||
* @param string $logic 'AND' or 'OR' for multiple attribute matching
|
||||
* @return array Array of matching item_ids
|
||||
*/
|
||||
public function search_by_attributes(string $search, array $definition_ids, bool $include_deleted = false): array
|
||||
public function search_by_attributes(string $search, array $definition_ids, bool $include_deleted = false, string $logic = 'OR'): array
|
||||
{
|
||||
if (empty($definition_ids) || empty($search)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parsed = $this->parse_attribute_search($search);
|
||||
$matching_item_ids = [];
|
||||
|
||||
if (!empty($parsed['attributes'])) {
|
||||
$attribute = model(Attribute::class);
|
||||
$all_definitions = $attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH);
|
||||
$definition_name_to_id = [];
|
||||
|
||||
foreach ($all_definitions as $id => $def_info) {
|
||||
$definition_name_to_id[strtolower($def_info['name'])] = $id;
|
||||
}
|
||||
|
||||
foreach ($parsed['attributes'] as $attr_name => $values) {
|
||||
if (!isset($definition_name_to_id[$attr_name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$definition_id = $definition_name_to_id[$attr_name];
|
||||
|
||||
foreach ($values as $value) {
|
||||
$builder = $this->db->table('attribute_links');
|
||||
$builder->select('DISTINCT attribute_links.item_id');
|
||||
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
|
||||
$builder->join('items', 'items.item_id = attribute_links.item_id');
|
||||
$builder->like('attribute_values.attribute_value', $value);
|
||||
$builder->where('attribute_links.definition_id', $definition_id);
|
||||
$builder->where('attribute_links.sale_id', null);
|
||||
$builder->where('attribute_links.receiving_id', null);
|
||||
$builder->where('items.deleted', $include_deleted);
|
||||
|
||||
$found_ids = array_column($builder->get()->getResultArray(), 'item_id');
|
||||
|
||||
if ($logic === 'AND') {
|
||||
if (empty($matching_item_ids)) {
|
||||
$matching_item_ids = $found_ids;
|
||||
} else {
|
||||
$matching_item_ids = array_intersect($matching_item_ids, $found_ids);
|
||||
}
|
||||
} else {
|
||||
$matching_item_ids = array_unique(array_merge($matching_item_ids, $found_ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($parsed['terms'])) {
|
||||
$term = implode(' ', $parsed['terms']);
|
||||
return $this->search_by_attribute_value($term, $definition_ids, $include_deleted);
|
||||
}
|
||||
|
||||
return $matching_item_ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for items by a single attribute value
|
||||
*
|
||||
* @param string $search Search term
|
||||
* @param array $definition_ids Attribute definition IDs to search within
|
||||
* @param bool $include_deleted Whether to include deleted items
|
||||
* @return array Array of matching item_ids
|
||||
*/
|
||||
private function search_by_attribute_value(string $search, array $definition_ids, bool $include_deleted = false): array
|
||||
{
|
||||
$builder = $this->db->table('attribute_links');
|
||||
$builder->select('DISTINCT attribute_links.item_id');
|
||||
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
|
||||
@@ -163,6 +264,21 @@ class Item extends Model
|
||||
return array_column($builder->get()->getResultArray(), 'item_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attribute definition ID from column name for sorting
|
||||
*
|
||||
* @param string $sort_column The sort column name
|
||||
* @return int|null The definition ID or null if not an attribute column
|
||||
*/
|
||||
private function get_attribute_sort_definition_id(string $sort_column): ?int
|
||||
{
|
||||
if (!ctype_digit($sort_column)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $sort_column;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a search on items
|
||||
*/
|
||||
@@ -279,6 +395,17 @@ class Item extends Model
|
||||
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id', 'left');
|
||||
}
|
||||
|
||||
// Handle attribute column sorting
|
||||
$sort_definition_id = $this->get_attribute_sort_definition_id($sort);
|
||||
if ($sort_definition_id !== null && $attributes_enabled && !$count_only) {
|
||||
$sort_alias = "sort_attr_{$sort_definition_id}";
|
||||
$builder->join("attribute_links AS {$sort_alias}", "{$sort_alias}.item_id = items.item_id AND {$sort_alias}.definition_id = {$sort_definition_id} AND {$sort_alias}.sale_id IS NULL AND {$sort_alias}.receiving_id IS NULL", 'left');
|
||||
$builder->join("attribute_values AS {$sort_alias}_val", "{$sort_alias}_val.attribute_id = {$sort_alias}.attribute_id", 'left');
|
||||
$builder->orderBy("{$sort_alias}_val.attribute_value", $order);
|
||||
} else {
|
||||
$builder->orderBy($sort, $order);
|
||||
}
|
||||
|
||||
$builder->where('items.deleted', $filters['is_deleted']);
|
||||
|
||||
if ($filters['empty_upc']) {
|
||||
@@ -305,7 +432,6 @@ class Item extends Model
|
||||
}
|
||||
|
||||
$builder->groupBy('items.item_id');
|
||||
$builder->orderBy($sort, $order);
|
||||
|
||||
if ($rows > 0) {
|
||||
$builder->limit($rows, $limit_from);
|
||||
|
||||
Reference in New Issue
Block a user