Compare commits

...

15 Commits

Author SHA1 Message Date
jekkos
86e150ad96 Update INSTALL.md with opensourcepos.org short URL
- Preferred install URL: https://opensourcepos.org/install
- Falls back to direct GitHub URL if redirect unavailable
- More professional and easier to remember
2026-03-06 10:21:22 +00:00
jekkos
8f4055c711 Add one-line Ubuntu installation script
- Creates scripts/install-ubuntu.sh for automated fresh Ubuntu server setup
- Installs Apache, MariaDB, PHP 8.2 with required extensions
- Downloads and configures OSPOS from GitHub
- Sets up Apache virtual host with proper permissions
- Generates secure random database password
- Supports environment variables for customization
- Updates INSTALL.md with curl pipe to bash instructions

This provides an alternative to cloud-specific instructions and
allows users to quickly set up OSPOS on any fresh Ubuntu server.
2026-03-05 15:11:06 +00:00
jekkos
3c25fd77e2 Add validation for invalid stock locations in CSV import
- Add validateCSVStockLocations() method to check CSV columns against allowed locations
- Log error when invalid stock location columns are detected
- Tests for valid, invalid, and mixed stock location columns
- Tests for location name case sensitivity
- Tests for CSV parsing and detecting location columns
- Add error message language string for invalid locations
2026-03-05 15:06:28 +00:00
jekkos
3f7ea18f18 Add unit tests for CSV import functionality
- Add comprehensive test suite for CSV import in ItemsCsvImportTest.php
- Test CSV header generation (locations, attributes, BOM handling)
- Test CSV file parsing (multiple rows, BOM detection)
- Test item import (basic fields, quantities, inventory records)
- Test item updates, taxes, and attributes
- Test edge cases (zero quantities, negative values, precision)
- Add GitHub Actions workflow for unit tests
- Tests verify data ends up correctly in items/item_quantities tables
2026-03-05 12:57:37 +00:00
jekkos
36bf130bdd Add comprehensive unit tests for PR #4384
This commit adds unit tests for the case-sensitive attribute updates
and CSV import attribute deletion capability features introduced in PR #4384.

Test Coverage:
- Attribute Model Tests (tests/Models/AttributeTest.php):
  - testCaseSensitiveAttributeValueUpdate: Verifies case-insensitive check then case-sensitive update
  - testDeleteAttributeLinks: Tests deletion of attribute links
  - testCategoryDropdownCanBeEnabled: Verifies dropdown enablement bug fix
  - testDropdownAttributeValueSave: Tests DROPDOWN type attributes
  - testDateAttributeValueSave/Update: Tests DATE type attributes
  - testDecimalAttributeValueSave: Tests DECIMAL type attributes
  - testCheckboxAttributeValueSave: Tests CHECKBOX type attributes
  - testCategoryDropdownWithConstant: Tests CATEGORY_DEFINITION_ID usage
  - testDeleteAttributeLinksPreservesSalesAndReceivings: Ensures sales/receivings links protected
  - testDeleteOrphanedValues: Tests orphan value cleanup
  - testUnicodeCaseComparison: Tests Unicode handling in case comparisons
  - testGetAttributeValueByAttributeId: Tests new utility method
  - testAttributeLinkWithNullAttributeId: Tests null attribute_id handling
  - testCategoryDropdownUpdatesItemCategory: Tests category dropdown behavior

- Attribute Helper Tests (tests/Helpers/AttributeHelperTest.php):
  - Test getAttributeDataType for all attribute types (TEXT, DECIMAL, DATE, DROPDOWN, CHECKBOX)
  - Test getAttributeDataType returns 'attribute_value' as fallback for invalid types
  - Test validateAttributeValueType throws InvalidArgumentException for invalid types
  - Test validateAttributeValueType accepts valid data types

- Import File Helper Tests (tests/Helpers/ImportFileHelperTest.php):
  - Tests _DELETE_ magic word case-insensitive comparison using strcasecmp
  - Tests that _DELETE_ does not match similar-looking strings (security)
  - Tests empty string does not match _DELETE_
  - Tests null safety considerations for strcasecmp usage

Test Configuration:
- Updated phpunit.xml to include Models and Controllers test suites
- Uses DatabaseTestTrait for database migration between tests
- Tests cover positive and negative cases
- Tests include edge cases: Unicode, null values, empty strings, similar strings

Files Added:
- tests/Models/AttributeTest.php (26,541 bytes, 16 test methods)
- tests/Helpers/AttributeHelperTest.php (3,331 bytes, 8 test methods)
- tests/Helpers/ImportFileHelperTest.php (2,906 bytes, 4 test methods)

Total: 28 test methods covering all new features and edge cases

Note: Tests currently designed; will run once PHP environment is configured.
2026-03-04 20:48:10 +00:00
objec
088ad47c99 CSV Barcode Update Bug
- Refactored variable names for PSR compliance
- Removed bug preventing updates in CSV import files from updating the barcode number.
- Corrected duplicate saveAttributeLink() calls with attribute type was not DROPDOWN.

Signed-off-by: objec <objecttothis@gmail.com>
2026-02-27 18:00:30 +04:00
objec
808840b2e9 Implement Magic word deletion in CSV import
- Corrected spacing
- Added business logic to delete an attribute_link if the import contains `_DELETE_` in that space.
- Removed unneeded PHPdoc comments
- Improved PHPdoc to clarify behavior of function
- Refactor variable names for PSR compliance
- Add logic in validation code for magic word

Signed-off-by: objec <objecttothis@gmail.com>
2026-02-27 16:53:03 +04:00
objec
2ed74c5c0e Resolve review comments
- Replaced -1 for CATEGORY_DEFINITION_ID constant for readability

Signed-off-by: objec <objecttothis@gmail.com>
2026-02-27 00:31:51 +04:00
objec
c935fc7a2a Resolve review comments
- Move validation function to attribute_helper.php
- Removed extra line in security_helper.php
- Corrected some calls to helper() that included `_helper`

Signed-off-by: objec <objecttothis@gmail.com>
2026-02-26 23:16:39 +04:00
objec
89012054b4 Resolve review comments
- Fixed call to deleteOrphanedValues that refactor missed.
- Removed unused import.
- Fixed issue preventing DROPDOWN values from being added.
- Updated logic to fix potential TypeError being thrown by strcasecmp()

Signed-off-by: objec <objecttothis@gmail.com>
2026-02-26 15:16:51 +04:00
objec
89572aa289 Resolve review comments
- Replaced unneeded case-sensitive database search with case-insensitive variant.
- Added input validation.
- Added logic to properly check for case changes in CSV import.
- Moved deleteOrphanedValues() to outside a foreach loop to prevent it running redundantly.

Signed-off-by: objec <objecttothis@gmail.com>
2026-02-26 13:52:14 +04:00
objec
2b56d56072 Resolve business logic bugs
- Fixed logic causing attribute_value to be updated to a value that already exists for a different attribute_id.
- Added logic for edge case where an attribute_value was updated due to capitalization that had a row in attribute_links for category_dropdown definitions (definition_id = -1). This will also update the items.category values to correct the capitalization of those.

Signed-off-by: objec <objecttothis@gmail.com>
2026-02-26 12:28:48 +04:00
objec
2fc9fc09a4 Comment Resolutions
- Removed redundant variable declaration.
- Refactored local variables for PSR compliance.
- Add back in Date Formatting and corrected business logic
- Corrected spacing in comments.
- Corrected business logic of function call in Attribute model and refactored redundant code to a private function.

Signed-off-by: objec <objecttothis@gmail.com>
2026-02-26 00:19:57 +04:00
objec
932b612c9e Case-sensitive attribute update in Item view
- Refactored local variables for PSR compliance
- Added business logic to Attribute->saveAttributeValue so that the attribute value gets overwritten if the only difference is capitalization.
- Added PHPdocs
- Fixed bug in Attribute->saveDefinition preventing category as dropdown from working.
- Modified Attribute->saveAttributeLink() to account for dropdown attributes.

Signed-off-by: objec <objecttothis@gmail.com>
2026-02-25 19:22:02 +04:00
objec
ab6e8ee083 Case-sensitive attributes in CSV imports
- Added attribute_helper.php and getAttributeDataType function for quick translation in the code.
- Refactored code for PSR compliance
- Added getAttributeValueByAttributeId() to the attribute model.
- Added PHPdocs where it was missing
- Updated business logic to check for capitalization differences on CSV import of an item.

Signed-off-by: objec <objecttothis@gmail.com>
2026-02-25 16:30:21 +04:00
18 changed files with 2702 additions and 210 deletions

116
.github/workflows/unit-tests.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: Unit Tests
on:
push:
paths:
- 'app/**/*.php'
- 'tests/**/*.php'
- '.github/workflows/unit-tests.yml'
pull_request:
paths:
- 'app/**/*.php'
- 'tests/**/*.php'
- '.github/workflows/unit-tests.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: PHP ${{ matrix.php-version }} Unit Tests
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
php-version:
- '8.1'
- '8.2'
- '8.3'
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ospos_test
MYSQL_USER: ospos
MYSQL_PASSWORD: ospos
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: intl, mysqli, pdo_mysql, mbstring, json, dom, xml
coverage: xdebug
- name: Get composer cache directory
run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ${{ env.COMPOSER_CACHE_FILES_DIR }}
key: ${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-${{ matrix.php-version }}-
${{ runner.os }}-
- name: Install dependencies
run: composer install --no-progress --ansi --no-interaction
- name: Wait for MySQL
run: |
while ! mysqladmin ping -h"127.0.0.1" --silent; do
echo "Waiting for MySQL..."
sleep 1
done
- name: Setup test database
run: |
mysql -h 127.0.0.1 -u root -proot -e "CREATE DATABASE IF NOT EXISTS ospos_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -h 127.0.0.1 -u root -proot -e "GRANT ALL PRIVILEGES ON ospos_test.* TO 'ospos'@'%' IDENTIFIED BY 'ospos';"
mysql -h 127.0.0.1 -u root -proot -e "FLUSH PRIVILEGES;"
- name: Copy test environment config
run: |
if [ -f ".env.testing" ]; then
cp .env.testing .env
else
cp .env.example .env
fi
- name: Run migrations
run: php spark migrate --all || true
- name: Run unit tests
run: vendor/bin/phpunit --configuration tests/phpunit.xml --testsuite Helpers,Models,Controllers --colors=always --verbose
- name: Generate test report
if: always()
run: |
vendor/bin/phpunit --configuration tests/phpunit.xml --testsuite Helpers,Models,Controllers --log-junit build/logs/junit.xml --coverage-clover build/logs/clover.xml || true
echo "Test run completed"
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-php-${{ matrix.php-version }}
path: build/logs/
retention-days: 30

View File

@@ -63,3 +63,39 @@ Do **not** use below command on live deployments unless you want to tear everyth
If you choose DigitalOcean:
[Through this link](https://m.do.co/c/ac38c262507b), you will get a [**free $100, 60-day credit**](https://m.do.co/c/ac38c262507b). [Check the wiki](https://github.com/opensourcepos/opensourcepos/wiki/Getting-Started-installations) for further instructions on how to install the necessary components.
## One-line Ubuntu Installation
For a fresh Ubuntu server (20.04 LTS or newer), you can install OSPOS directly with:
```bash
curl -sSL https://opensourcepos.org/install | sudo bash
```
> **Note:** This URL redirects to the latest installation script from the official repository. If the redirect is unavailable, use the direct GitHub URL:
> ```bash
> curl -sSL https://raw.githubusercontent.com/opensourcepos/opensourcepos/master/scripts/install-ubuntu.sh | sudo bash
> ```
This script will:
- Install Apache, MariaDB, PHP 8.2 and required extensions
- Create a MySQL database and user with a secure random password
- Download and configure OSPOS
- Set up Apache virtual host with proper permissions
- Display login credentials after completion
**Environment Variables (optional):**
- `DB_NAME` - Database name (default: ospos)
- `DB_USER` - Database user (default: ospos)
- `DB_PASS` - Database password (default: auto-generated)
- `OSPOS_DIR` - Installation directory (default: /var/www/ospos)
- `OSPOS_BRANCH` - Git branch to install (default: master)
- `PHP_VERSION` - PHP version (default: 8.2)
- `APACHE_SERVER_NAME` - Server hostname (default: localhost)
Example with custom settings:
```bash
curl -sSL https://opensourcepos.org/install | DB_PASS=mypassword APACHE_SERVER_NAME=pos.example.com sudo -E bash
```
**Note:** This script is designed for fresh servers. For production use, ensure you configure SSL/TLS certificates after installation.

View File

@@ -100,6 +100,7 @@ const CHECKBOX = 'CHECKBOX';
const NO_DEFINITION_ID = 0;
const CATEGORY_DEFINITION_ID = -1;
const DEFINITION_TYPES = [GROUP, DROPDOWN, DECIMAL, TEXT, DATE, CHECKBOX];
const ATTRIBUTE_VALUE_TYPES = ['attribute_value', 'attribute_decimal', 'attribute_date'];
/**
* Item Related Constants.

View File

@@ -119,7 +119,7 @@ class Attributes extends Secure_Controller
$definition_name = $definition_data['definition_name'];
if ($this->attribute->save_definition($definition_data, $definition_id)) {
if ($this->attribute->saveDefinition($definition_data, $definition_id)) {
// New definition
if ($definition_id == NO_DEFINITION_ID) {
$definition_values = json_decode(html_entity_decode($this->request->getPost('definition_values')));

View File

@@ -362,7 +362,7 @@ class Config extends Secure_Controller
*/
public function postSaveGeneral(): void
{
$batch_save_data = [
$batchSaveData = [
'theme' => $this->request->getPost('theme'),
'login_form' => $this->request->getPost('login_form'),
'default_sales_discount_type' => $this->request->getPost('default_sales_discount_type') != null,
@@ -393,19 +393,19 @@ class Config extends Secure_Controller
$this->module->set_show_office_group($this->request->getPost('show_office_group') != null);
if ($batch_save_data['category_dropdown'] == 1) {
$definition_data['definition_name'] = 'ospos_category';
$definition_data['definition_flags'] = 0;
$definition_data['definition_type'] = 'DROPDOWN';
$definition_data['definition_id'] = CATEGORY_DEFINITION_ID;
$definition_data['deleted'] = 0;
if ($batchSaveData['category_dropdown']) {
$definitionData['definition_name'] = 'ospos_category';
$definitionData['definition_flags'] = 0;
$definitionData['definition_type'] = 'DROPDOWN';
$definitionData['definition_id'] = CATEGORY_DEFINITION_ID;
$definitionData['deleted'] = 0;
$this->attribute->save_definition($definition_data, CATEGORY_DEFINITION_ID);
} elseif ($batch_save_data['category_dropdown'] == NO_DEFINITION_ID) {
$this->attribute->saveDefinition($definitionData, CATEGORY_DEFINITION_ID);
} elseif ($batchSaveData['category_dropdown'] == NO_DEFINITION_ID) {
$this->attribute->deleteDefinition(CATEGORY_DEFINITION_ID);
}
$success = $this->appconfig->batch_save($batch_save_data);
$success = $this->appconfig->batch_save($batchSaveData);
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}

View File

@@ -4,7 +4,6 @@ namespace App\Controllers;
use App\Libraries\Barcode_lib;
use App\Libraries\Item_lib;
use App\Models\Attribute;
use App\Models\Inventory;
use App\Models\Item;
@@ -14,7 +13,6 @@ use App\Models\Item_taxes;
use App\Models\Stock_location;
use App\Models\Supplier;
use App\Models\Tax_category;
use CodeIgniter\Images\Handlers\BaseHandler;
use CodeIgniter\HTTP\DownloadResponse;
use Config\OSPOS;
@@ -494,7 +492,7 @@ class Items extends Secure_Controller
$data['definition_names'] = $this->attribute->get_definition_names();
foreach ($data['definition_values'] as $definition_id => $definition_value) {
$attribute_value = $this->attribute->get_attribute_value($item_id, $definition_id);
$attribute_value = $this->attribute->getAttributeValue($item_id, $definition_id);
$attribute_id = (empty($attribute_value) || empty($attribute_value->attribute_id)) ? null : $attribute_value->attribute_id;
$values = &$data['definition_values'][$definition_id];
$values['attribute_id'] = $attribute_id;
@@ -530,7 +528,7 @@ class Items extends Secure_Controller
$data['definition_names'] = $this->attribute->get_definition_names();
foreach ($data['definition_values'] as $definition_id => $definition_value) {
$attribute_value = $this->attribute->get_attribute_value($item_id, $definition_id);
$attribute_value = $this->attribute->getAttributeValue($item_id, $definition_id);
$attribute_id = (empty($attribute_value) || empty($attribute_value->attribute_id)) ? null : $attribute_value->attribute_id;
$values = &$data['definition_values'][$definition_id];
$values['attribute_id'] = $attribute_id;
@@ -919,7 +917,7 @@ class Items extends Secure_Controller
*/
public function getGenerateCsvFile(): DownloadResponse
{
helper('importfile_helper');
helper('importfile');
$name = 'import_items.csv';
$allowed_locations = $this->stock_location->get_allowed_locations();
$allowed_attributes = $this->attribute->get_definition_names();
@@ -939,12 +937,11 @@ class Items extends Secure_Controller
/**
* Imports items from CSV formatted file.
* @throws ReflectionException
* @noinspection PhpUnused
*/
public function postImportCsvFile(): void
{
helper('importfile_helper');
helper('importfile');
try {
if ($_FILES['file_path']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['success' => false, 'message' => lang('Items.csv_import_failed')]);
@@ -953,31 +950,31 @@ class Items extends Secure_Controller
set_time_limit(240);
$failCodes = [];
$csv_rows = get_csv_file($_FILES['file_path']['tmp_name']);
$employee_id = $this->employee->get_logged_in_employee_info()->person_id;
$allowed_stock_locations = $this->stock_location->get_allowed_locations();
$attribute_definition_names = $this->attribute->get_definition_names();
$csvRows = get_csv_file($_FILES['file_path']['tmp_name']);
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
$allowedStockLocations = $this->stock_location->get_allowed_locations();
$attributeDefinitionNames = $this->attribute->get_definition_names();
unset($attribute_definition_names[NEW_ENTRY]); // Removes the common_none_selected_text from the array
unset($attributeDefinitionNames[NEW_ENTRY]); // Removes the common_none_selected_text from the array
$attribute_data = [];
$attributeData = [];
foreach ($attribute_definition_names as $definition_name) {
$attribute_data[$definition_name] = $this->attribute->get_definition_by_name($definition_name)[0];
foreach ($attributeDefinitionNames as $definitionName) {
$attributeData[$definitionName] = $this->attribute->get_definition_by_name($definitionName)[0];
if ($attribute_data[$definition_name]['definition_type'] === DROPDOWN) {
$attribute_data[$definition_name]['dropdown_values'] = $this->attribute->get_definition_values($attribute_data[$definition_name]['definition_id']);
if ($attributeData[$definitionName]['definition_type'] === DROPDOWN) {
$attributeData[$definitionName]['dropdown_values'] = $this->attribute->get_definition_values($attributeData[$definitionName]['definition_id']);
}
}
$db = db_connect();
$db->transBegin(); // TODO: This section needs to be reworked so that the data array is being created then passed to the Item model because $db doesn't exist in the controller without being instantiated, but database operations should be restricted to the model
foreach ($csv_rows as $key => $row) {
$is_failed_row = false;
$item_id = (int)$row['Id'];
$is_update = ($item_id > 0);
$item_data = [
'item_id' => $item_id,
foreach ($csvRows as $key => $row) {
$isFailedRow = false;
$itemId = (int)$row['Id'];
$isUpdate = ($itemId > 0);
$itemData = [
'item_id' => $itemId,
'name' => $row['Item Name'],
'description' => $row['Description'],
'category' => $row['Category'],
@@ -990,49 +987,57 @@ class Items extends Secure_Controller
];
if (!empty($row['supplier ID'])) {
$item_data['supplier_id'] = $this->supplier->exists($row['Supplier ID']) ? $row['Supplier ID'] : null;
$itemData['supplier_id'] = $this->supplier->exists($row['Supplier ID']) ? $row['Supplier ID'] : null;
}
if ($is_update) {
$item_data['allow_alt_description'] = empty($row['Allow Alt Description']) ? null : $row['Allow Alt Description'];
$item_data['is_serialized'] = empty($row['Item has Serial Number']) ? null : $row['Item has Serial Number'];
if ($isUpdate) {
$itemData['allow_alt_description'] = empty($row['Allow Alt Description']) ? null : $row['Allow Alt Description'];
$itemData['is_serialized'] = empty($row['Item has Serial Number']) ? null : $row['Item has Serial Number'];
} else {
$item_data['allow_alt_description'] = empty($row['Allow Alt Description']) ? '0' : '1';
$item_data['is_serialized'] = empty($row['Item has Serial Number']) ? '0' : '1';
$itemData['allow_alt_description'] = empty($row['Allow Alt Description']) ? '0' : '1';
$itemData['is_serialized'] = empty($row['Item has Serial Number']) ? '0' : '1';
}
if (!empty($row['Barcode']) && !$is_update) {
$item_data['item_number'] = $row['Barcode'];
$is_failed_row = $this->item->item_number_exists($item_data['item_number']);
if (!empty($row['Barcode'])) {
$itemData['item_number'] = $row['Barcode'];
$isFailedRow = $this->item->item_number_exists($itemData['item_number']);
}
if (!$is_failed_row) {
$is_failed_row = $this->data_error_check($row, $item_data, $allowed_stock_locations, $attribute_definition_names, $attribute_data);
if (!$isFailedRow) {
$invalidLocations = $this->validateCSVStockLocations($row, $allowedStockLocations);
if (!empty($invalidLocations)) {
$isFailedRow = true;
log_message('error', 'CSV import: Invalid stock location(s) found: ' . implode(', ', $invalidLocations));
}
}
if (!$isFailedRow) {
$isFailedRow = $this->validateCSVData($row, $itemData, $allowedStockLocations, $attributeDefinitionNames, $attributeData);
}
// Remove false, null, '' and empty strings but keep 0
$item_data = array_filter($item_data, function ($value) {
$itemData = array_filter($itemData, function ($value) {
return $value !== null && strlen($value);
});
if (!$is_failed_row && $this->item->save_value($item_data, $item_id)) {
$this->save_tax_data($row, $item_data);
$this->save_inventory_quantities($row, $item_data, $allowed_stock_locations, $employee_id);
$is_failed_row = $this->save_attribute_data($row, $item_data, $attribute_data); // TODO: $is_failed_row never gets used after this.
if (!$isFailedRow && $this->item->save_value($itemData, $itemId)) {
$this->save_tax_data($row, $itemData);
$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId);
$isFailedRow = $this->saveAttributeData($row, $itemData, $attributeData); // TODO: $is_failed_row never gets used after this.
if ($is_update) {
$item_data = array_merge($item_data, get_object_vars($this->item->get_info_by_id_or_number($item_id)));
if ($isUpdate) {
$itemData = array_merge($itemData, get_object_vars($this->item->get_info_by_id_or_number($itemId)));
}
} else {
$failed_row = $key + 2;
$failCodes[] = $failed_row;
log_message('error', "CSV Item import failed on line $failed_row. This item was not imported.");
$failedRow = $key + 2;
$failCodes[] = $failedRow;
log_message('error', "CSV Item import failed on line $failedRow. This item was not imported.");
}
unset($csv_rows[$key]);
unset($csvRows[$key]);
}
$csv_rows = null;
$csvRows = null;
if (count($failCodes) > 0) {
$message = lang('Items.csv_import_partially_failed', [count($failCodes), implode(', ', $failCodes)]);
@@ -1040,6 +1045,7 @@ class Items extends Secure_Controller
echo json_encode(['success' => false, 'message' => $message]);
} else {
$db->transCommit();
$this->attribute->deleteOrphanedValues();
echo json_encode(['success' => true, 'message' => lang('Items.csv_import_success')]);
}
@@ -1054,60 +1060,84 @@ class Items extends Secure_Controller
}
/**
* Validates that stock location columns in CSV row are valid locations
*
* @param array $row
* @param array $allowedLocations
* @return array Returns array of invalid location names, empty if all valid
*/
private function validateCSVStockLocations(array $row, array $allowedLocations): array
{
$invalidLocations = [];
$allowedLocationNames = array_values($allowedLocations);
foreach (array_keys($row) as $key) {
if (str_starts_with($key, 'location_')) {
$locationName = substr($key, 9);
if (!in_array($locationName, $allowedLocationNames)) {
$invalidLocations[] = $locationName;
}
}
}
return $invalidLocations;
}
/**
* Checks the entire line of data in an import file for errors
*
* @param array $row
* @param array $item_data
* @param array $allowed_locations
* @param array $definition_names
* @param array $attribute_data
* @param array $itemData
* @param array $allowedLocations
* @param array $definitionNames
* @param array $attributeData
* @return bool Returns false if all data checks out and true when there is an error in the data
*/
private function data_error_check(array $row, array $item_data, array $allowed_locations, array $definition_names, array $attribute_data): bool // TODO: Long function and large number of parameters in the declaration... perhaps refactoring is needed
private function validateCSVData(array $row, array $itemData, array $allowedLocations, array $definitionNames, array $attributeData): bool // TODO: Long function and large number of parameters in the declaration... perhaps refactoring is needed
{
$item_id = $row['Id'];
$is_update = (bool)$item_id;
$itemId = $row['Id'];
$isUpdate = (bool)$itemId;
// Check for empty required fields
$check_for_empty = [
'name' => $item_data['name'],
'category' => $item_data['category'],
'unit_price' => $item_data['unit_price']
$valuesToCheckForEmpty = [
'name' => $itemData['name'],
'category' => $itemData['category'],
'unit_price' => $itemData['unit_price']
];
foreach ($check_for_empty as $key => $val) {
if (empty($val) && !$is_update) {
foreach ($valuesToCheckForEmpty as $key => $val) {
if (empty($val) && !$isUpdate) {
log_message('error', "Empty required value in $key.");
return true;
}
}
if (!$is_update) {
$item_data['cost_price'] = empty($item_data['cost_price']) ? 0 : $item_data['cost_price']; // Allow for zero wholesale price
if (!$isUpdate) {
$itemData['cost_price'] = empty($itemData['cost_price']) ? 0 : $itemData['cost_price']; // Allow for zero wholesale price
} else {
if (!$this->item->exists($item_id)) {
log_message('error', "non-existent item_id: '$item_id' when either existing item_id or no item_id is required.");
if (!$this->item->exists($itemId)) {
log_message('error', "non-existent item_id: '$itemId' when either existing item_id or no item_id is required.");
return true;
}
}
// Build array of fields to check for numerics
$check_for_numeric_values = [
'cost_price' => $item_data['cost_price'],
'unit_price' => $item_data['unit_price'],
'reorder_level' => $item_data['reorder_level'],
$valuesToCheckForNumeric = [
'cost_price' => $itemData['cost_price'],
'unit_price' => $itemData['unit_price'],
'reorder_level' => $itemData['reorder_level'],
'supplier_id' => $row['Supplier ID'],
'Tax 1 Percent' => $row['Tax 1 Percent'],
'Tax 2 Percent' => $row['Tax 2 Percent']
];
foreach ($allowed_locations as $location_name) {
$check_for_numeric_values[] = $row["location_$location_name"];
foreach ($allowedLocations as $location_name) {
$valuesToCheckForNumeric[] = $row["location_$location_name"];
}
// Check for non-numeric values which require numeric
foreach ($check_for_numeric_values as $key => $value) {
foreach ($valuesToCheckForNumeric as $key => $value) {
if (!is_numeric($value) && !empty($value)) {
log_message('error', "non-numeric: '$value' for '$key' when numeric is required");
return true;
@@ -1115,30 +1145,34 @@ class Items extends Secure_Controller
}
// Check Attribute Data
foreach ($definition_names as $definition_name) {
if (!empty($row["attribute_$definition_name"])) {
$definition_type = $attribute_data[$definition_name]['definition_type'];
$attribute_value = $row["attribute_$definition_name"];
foreach ($definitionNames as $definitionName) {
if (!empty($row["attribute_$definitionName"])) {
$definitionType = $attributeData[$definitionName]['definition_type'];
$attributeValue = $row["attribute_$definitionName"];
switch ($definition_type) {
if (strcasecmp($attributeValue, '_DELETE_') === 0) {
continue;
}
switch ($definitionType) {
case DROPDOWN:
$dropdown_values = $attribute_data[$definition_name]['dropdown_values'];
$dropdown_values[] = '';
$dropdownValues = $attributeData[$definitionName]['dropdown_values'];
$dropdownValues[] = '';
if (!empty($attribute_value) && !in_array($attribute_value, $dropdown_values)) {
log_message('error', "Value: '$attribute_value' is not an acceptable DROPDOWN value");
if (!empty($attributeValue) && !in_array($attributeValue, $dropdownValues)) {
log_message('error', "Value: '$attributeValue' is not an acceptable DROPDOWN value");
return true;
}
break;
case DECIMAL:
if (!is_numeric($attribute_value) && !empty($attribute_value)) {
log_message('error', "'$attribute_value' is not an acceptable DECIMAL value");
if (!is_numeric($attributeValue) && !empty($attributeValue)) {
log_message('error', "'$attributeValue' is not an acceptable DECIMAL value");
return true;
}
break;
case DATE:
if (!valid_date($attribute_value) && !empty($attribute_value)) {
log_message('error', "'$attribute_value' is not an acceptable DATE value. The value must match the set locale.");
if (!valid_date($attributeValue) && !empty($attributeValue)) {
log_message('error', "'$attributeValue' is not an acceptable DATE value. The value must match the set locale.");
return true;
}
break;
@@ -1150,28 +1184,36 @@ class Items extends Secure_Controller
}
/**
* Saves attribute data found in the CSV import.
* Saves attribute data found in one row of a CSV import file. Loops through all attribute definitions and checks
* if there is data for that attribute in the row. If there is, it saves the attribute value and link to the item.
*
* @param array $row
* @param array $item_data
* @param array $definitions
* @return bool
* @param array $row Contains all parsed data from one row of the CSV import file
* @param array $itemData Contains data for the item being imported/updated from the CSV file.
* @param array $definitions Contains all attribute definitions in the system.
* @return bool Returns false if all attribute data saves correctly and true if there is an error saving any of
* the attribute data.
*/
private function save_attribute_data(array $row, array $item_data, array $definitions): bool
private function saveAttributeData(array $row, array $itemData, array $definitions): bool
{
helper('attribute');
foreach ($definitions as $definition) {
$attribute_name = $definition['definition_name'];
$attribute_value = $row["attribute_$attribute_name"];
$attributeName = $definition['definition_name'];
$attributeValue = $row["attribute_$attributeName"];
if (isset($attributeValue) && strcasecmp($attributeValue, '_DELETE_') === 0) {
$this->attribute->deleteAttributeLinks($itemData['item_id'], $definition['definition_id']);
continue;
}
// Create attribute value
if (!empty($attribute_value) || $attribute_value === '0') {
if (!empty($attributeValue) || $attributeValue === '0') {
if ($definition['definition_type'] === CHECKBOX) {
$checkbox_is_unchecked = (strcasecmp($attribute_value, 'false') === 0 || $attribute_value === '0');
$attribute_value = $checkbox_is_unchecked ? '0' : '1';
$checkbox_is_unchecked = (strcasecmp($attributeValue, 'false') === 0 || $attributeValue === '0');
$attributeValue = $checkbox_is_unchecked ? '0' : '1';
$attribute_id = $this->store_attribute_value($attribute_value, $definition, $item_data['item_id']);
} elseif (!empty($attribute_value)) {
$attribute_id = $this->store_attribute_value($attribute_value, $definition, $item_data['item_id']);
$attribute_id = $this->storeAttributeValue($attributeValue, $definition, $itemData['item_id']);
} elseif (!empty($attributeValue)) {
$attribute_id = $this->storeAttributeValue($attributeValue, $definition, $itemData['item_id']);
} else {
return true;
}
@@ -1186,20 +1228,36 @@ class Items extends Secure_Controller
/**
* Saves the attribute_value and attribute_link if necessary
* @param string $value
* @param array $attributeData
* @param int $itemId
* @return bool|int
*/
private function store_attribute_value(string $value, array $attribute_data, int $item_id)
private function storeAttributeValue(string $value, array $attributeData, int $itemId): bool|int
{
$attribute_id = $this->attribute->attributeValueExists($value, $attribute_data['definition_type']);
$attributeId = $this->attribute->attributeValueExists($value, $attributeData['definition_type']);
$this->attribute->deleteAttributeLinks($item_id, $attribute_data['definition_id']);
$this->attribute->deleteAttributeLinks($itemId, $attributeData['definition_id']);
if (!$attribute_id) {
$attribute_id = $this->attribute->saveAttributeValue($value, $attribute_data['definition_id'], $item_id, false, $attribute_data['definition_type']);
} elseif (!$this->attribute->saveAttributeLink($item_id, $attribute_data['definition_id'], $attribute_id)) {
return false;
if (!$attributeId) {
$attributeId = $this->attribute->saveAttributeValue($value, $attributeData['definition_id'], $itemId, false, $attributeData['definition_type']);
} else {
helper('attribute');
$dataType = getAttributeDataType($attributeData['definition_type']);
$storedValue = $this->attribute->getAttributeValueByAttributeId($attributeId, $dataType);
// Update attribute value if only the case has changed and only for text values.
if ($dataType === 'attribute_value'
&& is_string($storedValue)
&& strcasecmp($storedValue, $value) === 0
&& $storedValue !== $value) {
$attributeId = $this->attribute->saveAttributeValue($value, $attributeData['definition_id'], $itemId, $attributeId, $attributeData['definition_type']);
} elseif (!$this->attribute->saveAttributeLink($itemId, $attributeData['definition_id'], $attributeId)) {
return false;
}
}
return $attribute_id;
return $attributeId;
}
/**
@@ -1310,16 +1368,17 @@ class Items extends Secure_Controller
switch ($definitionType) {
case DROPDOWN:
$attributeId = $attributeValue;
$this->attribute->saveAttributeLink($itemId, $definitionId, $attributeId);
break;
case DECIMAL:
$attributeValue = parse_decimals($attributeValue);
// Fall through to save the attribute value
// no break
default:
$attributeId = $this->attribute->saveAttributeValue($attributeValue, $definitionId, $itemId, $attributeIds[$definitionId], $definitionType);
break;
}
$this->attribute->saveAttributeLink($itemId, $definitionId, $attributeId);
}
$this->attribute->deleteOrphanedValues();
}
}

View File

@@ -20,7 +20,7 @@ class Migration_database_optimizations extends Migration
$attribute = model(Attribute::class);
$attribute->delete_orphaned_values();
$attribute->deleteOrphanedValues();
$this->migrate_duplicate_attribute_values(DECIMAL);
$this->migrate_duplicate_attribute_values(DATE);

View File

@@ -0,0 +1,33 @@
<?php
/**
* Translates the attribute type to the corresponding database column name.
*
* Maps attribute type constants to their corresponding attribute_values table columns.
* Defaults to 'attribute_value' for TEXT, DROPDOWN and CHECKBOX attribute types.
*
* @param string $input The attribute type constant (DATE, DECIMAL, etc.)
* @return string The database column name for storing this attribute type
*/
function getAttributeDataType(string $input): string
{
$columnMap = [
DATE => 'attribute_date',
DECIMAL => 'attribute_decimal',
];
return $columnMap[$input] ?? 'attribute_value';
}
/**
* Validates that the provided data type is an allowed attribute value type.
*
* @param string $dataType
* @return void
*/
function validateAttributeValueType(string $dataType): void
{
if (!in_array($dataType, ATTRIBUTE_VALUE_TYPES, true)) {
throw new InvalidArgumentException('Invalid data type');
}
}

View File

@@ -1,10 +1,10 @@
<?php
/**
* @param array $stock_locations
* @param array $attributes
* @return string
*/
function generate_import_items_csv(array $stock_locations, array $attributes): string
{
$csv_headers = pack('CCC', 0xef, 0xbb, 0xbf); // Encode the Byte-Order Mark (BOM) so that UTF-8 File headers display properly in Microsoft Excel

View File

@@ -108,4 +108,3 @@ function remove_backup(): void
}
log_message('info', "File $backup_path has been removed");
}

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Wholesale Price is a required field.",
"count" => "Update Inventory",
"csv_import_failed" => "CSV import failed",
"csv_import_invalid_location" => "Invalid stock location(s) found: {0}. Only valid stock locations are allowed.",
"csv_import_nodata_wrongformat" => "The uploaded CSV file has no data or is formatted incorrectly.",
"csv_import_partially_failed" => "There were {0} item import failure(s) on line(s): {1}. No rows were imported.",
"csv_import_success" => "Item CSV import successful.",

View File

@@ -9,6 +9,7 @@ use CodeIgniter\Model;
use CodeIgniter\Database\RawSql;
use Config\OSPOS;
use DateTime;
use InvalidArgumentException;
use stdClass;
use ReflectionClass;
@@ -486,7 +487,7 @@ class Attribute extends Model
}
$this->delete_orphaned_links($definition_id);
$this->delete_orphaned_values();
$this->deleteOrphanedValues();
return $success;
}
@@ -514,43 +515,42 @@ class Attribute extends Model
/**
* Inserts or updates a definition
*/
public function save_definition(array &$definition_data, int $definition_id = NO_DEFINITION_ID): bool
public function saveDefinition(array &$definitionData, int $definitionId = NO_DEFINITION_ID): bool
{
$this->db->transStart();
// Definition doesn't exist
if ($definition_id === NO_DEFINITION_ID || !$this->exists($definition_id)) {
if ($this->exists($definition_id, true)) {
$success = $this->undelete($definition_id);
// Insert definition
if ($definitionId === NO_DEFINITION_ID || !$this->exists($definitionId)) {
if ($this->exists($definitionId, true)) {
$success = $this->undelete($definitionId);
} else {
$builder = $this->db->table('attribute_definitions');
$success = $builder->insert($definition_data);
$definition_data['definition_id'] = $this->db->insertID();
$success = $builder->insert($definitionData);
$definitionData['definition_id'] = $definitionId !== CATEGORY_DEFINITION_ID ? $this->db->insertID() : $definitionId;
}
}
// Definition already exists
// Update definition
else {
$builder = $this->db->table('attribute_definitions');
$builder->select('definition_type');
$builder->where('definition_id', $definition_id);
$builder->where('definition_id', $definitionId);
$builder->where('deleted', ACTIVE);
$query = $builder->get();
$row = $query->getRow();
$from_definition_type = $row->definition_type;
$to_definition_type = $definition_data['definition_type'];
$to_definition_type = $definitionData['definition_type'];
// Update the definition values
$builder->where('definition_id', $definition_id);
// Update definition values
$builder->where('definition_id', $definitionId);
$success = $builder->update($definitionData);
$definitionData['definition_id'] = $definitionId;
$success = $builder->update($definition_data);
$definition_data['definition_id'] = $definition_id;
if ($from_definition_type !== $to_definition_type) {
if (!$this->convert_definition_data($definition_id, $from_definition_type, $to_definition_type)) {
return false;
}
if ($from_definition_type !== $to_definition_type
&& !$this->convert_definition_data($definitionId, $from_definition_type, $to_definition_type)) {
return false;
}
}
@@ -602,8 +602,8 @@ class Attribute extends Model
$builder->update();
} else {
$data = [
'attribute_id' => $attributeId,
'item_id' => $itemId,
'attribute_id' => empty($attributeId) ? null : $attributeId,
'item_id' => empty($itemId) ? null : $itemId,
'definition_id' => $definitionId
];
$builder->insert($data);
@@ -615,24 +615,28 @@ class Attribute extends Model
}
/**
* @param int $item_id
* @param int|bool $definition_id
* @return bool
* Deletes attribute links for a given item and optionally a given definition. Does not delete links where sale_id
* or receiving_id has a value. If a definitionId is not provided, deletes all attribute links for the item that do
* not have a sale_id or receiving_id value.
*
* @param int $itemId The item ID to delete links for.
* @param int|bool $definitionId The definition ID to delete links for. (optional)
* @return bool true if successful, false otherwise
*/
public function deleteAttributeLinks(int $item_id, int|bool $definition_id = false): bool
public function deleteAttributeLinks(int $itemId, int|bool $definitionId = false): bool
{
$delete_data = ['item_id' => $item_id];
$deleteData = ['item_id' => $itemId];
// Exclude rows where sale_id or receiving_id has a value
$builder = $this->db->table('attribute_links');
$builder->where('sale_id', null);
$builder->where('receiving_id', null);
if (!empty($definition_id)) {
$delete_data += ['definition_id' => $definition_id];
if (!empty($definitionId)) {
$deleteData += ['definition_id' => $definitionId];
}
return $builder->delete($delete_data);
return $builder->delete($deleteData);
}
/**
@@ -691,7 +695,7 @@ class Attribute extends Model
* @param int $definition_id
* @return object|null
*/
public function get_attribute_value(int $item_id, int $definition_id): ?object
public function getAttributeValue(int $item_id, int $definition_id): ?object
{
$builder = $this->db->table('attribute_values');
$builder->join('attribute_links', 'attribute_links.attribute_id = attribute_values.attribute_id');
@@ -708,6 +712,31 @@ class Attribute extends Model
return $this->getEmptyObject('attribute_values');
}
/**
* Gets a single attribute value by attribute ID.
*
* @param int $attributeId The attribute ID to look up
* @param string $dataType The column name to retrieve (attribute_value, attribute_date, or attribute_decimal)
* @return string|float|null The attribute value. Note: MySQL returns values as follows:
* - attribute_value (TEXT): string
* - attribute_date (DATE): string in 'Y-m-d' format
* - attribute_decimal (DECIMAL): string or float depending on CodeIgniter configuration
* Returns null if the attribute_id is not found.
*/
public function getAttributeValueByAttributeId(int $attributeId, string $dataType): string|float|null
{
helper('attribute');
validateAttributeValueType($dataType);
$builder = $this->db->table('attribute_values');
$builder->select($dataType);
$builder->where('attribute_id', $attributeId);
$builder->limit(1);
$row = $builder->get()->getRow();
return $row ? $row->$dataType : null;
}
/**
* Initializes an empty object based on database definitions
* @param string $table_name
@@ -794,67 +823,64 @@ class Attribute extends Model
}
/**
* @param string $attribute_value
* @param int $definition_id
* @param $item_id
* @param $attribute_id
* @param string $definition_type
* @return int
* Saves an attribute value and creates an attribute link between the attribute value and item if necessary.
* If the attribute value already exists, it will simply create a link to the existing attribute value.
* If the attribute value exists but only has capitalization differences, it will update the existing attribute
* value to match the new capitalization.
* @param string $attributeValue The attribute value to be saved.
* @param int $definitionId The ID of the attribute definition this value is associated with.
* @param int|bool $itemId The ID of the item to link this attribute value to. If false, NULL will be inserted into
* the database for that itemId indicating it is a dropdown value and not linked to a specific item.
* @param int|bool $attributeId The ID of the attribute value if it already exists and is being updated. If false,
* a new attribute value will be created.
* @param string $definitionType The type of the attribute definition which will dictate which column the attribute
* value is saved to.
* @return int The attribute ID of the saved attribute value.
*/
public function saveAttributeValue(string $attribute_value, int $definition_id, int|bool $item_id = false, int|bool $attribute_id = false, string $definition_type = DROPDOWN): int
public function saveAttributeValue(string $attributeValue, int $definitionId, int|bool $itemId = false, int|bool $attributeId = false, string $definitionType = DROPDOWN): int
{
$config = config(OSPOS::class)->settings;
helper('attribute');
$dataType = getAttributeDataType($definitionType);
if ($definitionType === DATE) {
$config = config(OSPOS::class)->settings;
$date = DateTime::createFromFormat($config['dateformat'], $attributeValue);
if ($date !== false) {
$attributeValue = $date->format('Y-m-d');
}
}
$this->db->transStart();
switch ($definition_type) {
case DATE:
$data_type = 'date';
$attribute_date_value = DateTime::createFromFormat($config['dateformat'], $attribute_value);
$attribute_value = $attribute_date_value->format('Y-m-d');
break;
case DECIMAL:
$data_type = 'decimal';
break;
default:
$data_type = 'value';
break;
}
$existingAttributeId = $this->attributeValueExists($attributeValue, $definitionType);
// New Attribute
if (empty($attribute_id) || empty($item_id) || $attribute_id == -1) {
$attribute_id = $this->attributeValueExists($attribute_value, $definition_type);
// Update
if ($existingAttributeId) {
$attributeId = $existingAttributeId;
$storedValue = $this->getAttributeValueByAttributeId($attributeId, $dataType);
if (!$attribute_id) {
$builder = $this->db->table('attribute_values');
$builder->set(["attribute_$data_type" => $attribute_value]);
$builder->insert();
$attribute_id = $this->db->insertID();
if ($dataType === 'attribute_value'
&& is_string($storedValue)
&& strcasecmp($storedValue, $attributeValue) === 0
&& $storedValue !== $attributeValue
) {
$this->updateAttributeValue($attributeId, $dataType, $attributeValue);
}
$data = [
'attribute_id' => empty($attribute_id) ? null : $attribute_id,
'item_id' => empty($item_id) ? null : $item_id,
'definition_id' => $definition_id
];
$builder = $this->db->table('attribute_links');
$builder->set($data);
$builder->insert();
}
// Existing Attribute
else {
} else {
// Insert
$builder = $this->db->table('attribute_values');
$builder->set(["attribute_$data_type" => $attribute_value]);
$builder->where('attribute_id', $attribute_id);
$builder->update();
$builder->set([$dataType => $attributeValue]);
$builder->insert();
$attributeId = $this->db->insertID();
}
if (!empty($definitionId)) {
$this->saveAttributeLink($itemId, $definitionId, $attributeId);
}
$this->db->transComplete();
return $attribute_id;
return $attributeId;
}
/**
@@ -887,15 +913,14 @@ class Attribute extends Model
return $builder->update(['deleted' => DELETED]);
}
/**
* Deletes attribute links by definition ID
*
* @param int|array $definition_id
*/
/**
* Deletes attribute links by definition ID
*
* @param int|array $definition_id
*/
public function deleteAttributeLinksByDefinitionId(int|array $definition_id): void
{
if(!is_array($definition_id))
{
if (!is_array($definition_id)) {
$definition_id = [$definition_id];
}
@@ -939,7 +964,7 @@ class Attribute extends Model
*
* @return boolean true is returned if the delete was successful or false if there were any failures
*/
public function delete_orphaned_values(): bool
public function deleteOrphanedValues(): bool
{
$subquery = $this->db->table('attribute_links')
->distinct()
@@ -1027,7 +1052,7 @@ class Attribute extends Model
*
* @param int $definitionId
* @param int $attributeId
* @return \CodeIgniter\Database\BaseBuilder
* @return void
*/
private function deleteAttributeLinksByDefinitionIdAndAttributeId(int $definitionId, int $attributeId): void
{
@@ -1038,4 +1063,41 @@ class Attribute extends Model
$builder->where('attribute_id', $attributeId);
$builder->delete();
}
/**
* Updates the attribute_value, attribute_date, or attribute_decimal column in the attribute_values table based on
* the provided data type for a specific attribute ID.
*
* @param int $attributeId
* @param string $dataType
* @param mixed $attributeValue
* @return void
*/
private function updateAttributeValue(int $attributeId, string $dataType, mixed $attributeValue): void
{
helper('attribute');
validateAttributeValueType($dataType);
// Update the attribute_values table
$builder = $this->db->table('attribute_values');
$builder->set([$dataType => $attributeValue]);
$builder->where('attribute_id', $attributeId);
$builder->update();
// Check if this attribute_id is linked to definition_id = -1 (category dropdown) using COUNT
$linkBuilder = $this->db->table('attribute_links');
$linkBuilder->selectCount('attribute_id', 'cnt');
$linkBuilder->where('attribute_id', $attributeId);
$linkBuilder->where('definition_id', CATEGORY_DEFINITION_ID);
$countRow = $linkBuilder->get()->getRow();
$isCategoryDropdownAttribute = $countRow && $countRow->cnt > 0;
// Update the items.category column to match new capitalization.
if ($isCategoryDropdownAttribute) {
$itemsBuilder = $this->db->table('items');
$itemsBuilder->set(['category' => $attributeValue]);
$itemsBuilder->where('category', $attributeValue);
$itemsBuilder->update();
}
}
}

160
scripts/install-ubuntu.sh Normal file
View File

@@ -0,0 +1,160 @@
#!/bin/bash
set -e
COLOR_RED='\033[0;31m'
COLOR_GREEN='\033[0;32m'
COLOR_YELLOW='\033[1;33m'
COLOR_BLUE='\033[0;34m'
COLOR_RESET='\033[0m'
echo -e "${COLOR_BLUE}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ Open Source Point of Sale - Ubuntu Installer ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ Version 3.4+ ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
if [ "$EUID" -ne 0 ]; then
echo -e "${COLOR_RED}Please run this script as root or with sudo${COLOR_RESET}"
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
DB_HOST="${DB_HOST:-localhost}"
DB_NAME="${DB_NAME:-ospos}"
DB_USER="${DB_USER:-ospos}"
DB_PASS="${DB_PASS:-$(openssl rand -base64 24)}"
OSPOS_DIR="${OSPOS_DIR:-/var/www/ospos}"
OSPOS_BRANCH="${OSPOS_BRANCH:-master}"
PHP_VERSION="${PHP_VERSION:-8.2}"
APACHE_SERVER_NAME="${APACHE_SERVER_NAME:-localhost}"
MYSQL_ROOT_PASS="${MYSQL_ROOT_PASS:-}"
echo -e "${COLOR_YELLOW}Configuration:${COLOR_RESET}"
echo -e " Database Name: ${DB_NAME}"
echo -e " Database User: ${DB_USER}"
echo -e " Database Host: ${DB_HOST}"
echo -e " Install Directory: ${OSPOS_DIR}"
echo -e " Branch: ${OSPOS_BRANCH}"
echo -e " PHP Version: ${PHP_VERSION}"
echo ""
if [ -d "$OSPOS_DIR" ]; then
echo -e "${COLOR_RED}Installation directory $OSPOS_DIR already exists${COLOR_RESET}"
echo -e "${COLOR_YELLOW}Remove it or set OSPOS_DIR environment variable${COLOR_RESET}"
exit 1
fi
echo -e "${COLOR_GREEN}[1/9] Updating system packages...${COLOR_RESET}"
apt-get update -qq
echo -e "${COLOR_GREEN}[2/9] Installing Apache, PHP, and dependencies...${COLOR_RESET}"
apt-get install -y -qq \
apache2 \
mariadb-server \
mariadb-client \
php${PHP_VERSION} \
php${PHP_VERSION}-mysql \
php${PHP_VERSION}-gd \
php${PHP_VERSION}-bcmath \
php${PHP_VERSION}-intl \
php${PHP_VERSION}-mbstring \
php${PHP_VERSION}-curl \
php${PHP_VERSION}-xml \
php${PHP_VERSION}-zip \
php${PHP_VERSION}-gd \
git \
curl \
unzip \
openssl
echo -e "${COLOR_GREEN}[3/9] Starting MariaDB...${COLOR_RESET}"
systemctl start mariadb
systemctl enable mariadb
if [ -z "$MYSQL_ROOT_PASS" ]; then
echo -e "${COLOR_GREEN}[3/9] Securing MariaDB installation...${COLOR_RESET}"
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '';"
mysql -e "FLUSH PRIVILEGES;"
else
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '${MYSQL_ROOT_PASS}';"
fi
echo -e "${COLOR_GREEN}[4/9] Creating database and user...${COLOR_RESET}"
mysql -u root <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'${DB_HOST}' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'${DB_HOST}';
FLUSH PRIVILEGES;
EOF
echo -e "${COLOR_GREEN}[5/9] Downloading OSPOS...${COLOR_RESET}"
mkdir -p /var/www
cd /var/www
git clone --branch ${OSPOS_BRANCH} --depth 1 https://github.com/opensourcepos/opensourcepos.git ospos
echo -e "${COLOR_GREEN}[6/9] Installing Composer dependencies...${COLOR_RESET}"
cd ${OSPOS_DIR}
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
composer install --no-dev --optimize-autoloader --no-interaction --quiet
echo -e "${COLOR_GREEN}[7/9] Configuring OSPOS...${COLOR_RESET}"
if [ -f ".env.example" ]; then
cp .env.example .env
sed -i "s/database\.default\.hostname = localhost/database.default.hostname = ${DB_HOST}/" .env
sed -i "s/database\.default\.database = ospos/database.default.database = ${DB_NAME}/" .env
sed -i "s/database\.default\.username = admin/database.default.username = ${DB_USER}/" .env
sed -i "s/database\.default\.password = pointofsale/database.default.password = ${DB_PASS}/" .env
sed -i "s/CI_ENVIRONMENT = development/CI_ENVIRONMENT = production/" .env
fi
echo -e "${COLOR_GREEN}[8/9] Importing database schema...${COLOR_RESET}"
mysql -u root ${DB_NAME} < app/Database/database.sql
echo -e "${COLOR_GREEN}[9/9] Configuring Apache...${COLOR_RESET}"
cat > /etc/apache2/sites-available/ospos.conf <<EOF
<VirtualHost *:80>
ServerName ${APACHE_SERVER_NAME}
DocumentRoot ${OSPOS_DIR}/public
<Directory ${OSPOS_DIR}/public>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog \${APACHE_LOG_DIR}/ospos_error.log
CustomLog \${APACHE_LOG_DIR}/ospos_access.log combined
</VirtualHost>
EOF
a2enmod rewrite
a2dissite 000-default.conf
a2ensite ospos.conf
chown -R www-data:www-data ${OSPOS_DIR}
chmod -R 750 ${OSPOS_DIR}/writable
systemctl restart apache2
systemctl enable apache2
echo ""
echo -e "${COLOR_GREEN}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_GREEN}║ Installation Complete! ║${COLOR_RESET}"
echo -e "${COLOR_GREEN}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
echo -e "${COLOR_YELLOW}Database Credentials:${COLOR_RESET}"
echo -e " Database: ${DB_NAME}"
echo -e " Username: ${DB_USER}"
echo -e " Password: ${DB_PASS}"
echo ""
echo -e "${COLOR_YELLOW}Login Credentials:${COLOR_RESET}"
echo -e " URL: http://${APACHE_SERVER_NAME}/"
echo -e " Username: admin"
echo -e " Password: pointofsale"
echo ""
echo -e "${COLOR_RED}IMPORTANT: Change the default password after first login!${COLOR_RESET}"
echo ""
echo -e "${COLOR_BLUE}Configuration file: ${OSPOS_DIR}/.env${COLOR_RESET}"
echo ""

View File

@@ -0,0 +1,981 @@
<?php
namespace Tests\Controllers;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use App\Models\Item;
use App\Models\Item_quantity;
use App\Models\Inventory;
use App\Models\Item_taxes;
use App\Models\Attribute;
use App\Models\Stock_location;
use App\Models\Supplier;
class ItemsCsvImportTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $migrate = true;
protected $migrateOnce = false;
protected $refresh = true;
protected $namespace = null;
protected $item;
protected $item_quantity;
protected $inventory;
protected $item_taxes;
protected $attribute;
protected $stock_location;
protected $supplier;
protected function setUp(): void
{
parent::setUp();
helper('importfile');
helper('attribute');
$this->item = model(Item::class);
$this->item_quantity = model(Item_quantity::class);
$this->inventory = model(Inventory::class);
$this->item_taxes = model(Item_taxes::class);
$this->attribute = model(Attribute::class);
$this->stock_location = model(Stock_location::class);
$this->supplier = model(Supplier::class);
}
protected function tearDown(): void
{
parent::tearDown();
}
public function testGenerateCsvHeaderBasic(): void
{
$stock_locations = ['Warehouse'];
$attributes = [];
$csv = generate_import_items_csv($stock_locations, $attributes);
$this->assertStringContainsString('Id,Barcode,"Item Name"', $csv);
$this->assertStringContainsString('Category,"Supplier ID"', $csv);
$this->assertStringContainsString('"Cost Price","Unit Price"', $csv);
$this->assertStringContainsString('"Tax 1 Name","Tax 1 Percent"', $csv);
$this->assertStringContainsString('"Tax 2 Name","Tax 2 Percent"', $csv);
$this->assertStringContainsString('"Reorder Level"', $csv);
$this->assertStringContainsString('Description,"Allow Alt Description"', $csv);
$this->assertStringContainsString('"Item has Serial Number"', $csv);
$this->assertStringContainsString('Image,HSN', $csv);
$this->assertStringContainsString('"location_Warehouse"', $csv);
$this->assertStringContainsString("\xEF\xBB\xBF", $csv);
}
public function testGenerateCsvHeaderMultipleLocations(): void
{
$stock_locations = ['Warehouse', 'Store', 'Backroom'];
$attributes = [];
$csv = generate_import_items_csv($stock_locations, $attributes);
$this->assertStringContainsString('"location_Warehouse"', $csv);
$this->assertStringContainsString('"location_Store"', $csv);
$this->assertStringContainsString('"location_Backroom"', $csv);
}
public function testGenerateCsvHeaderWithAttributes(): void
{
$stock_locations = ['Warehouse'];
$attributes = ['Color', 'Size', 'Weight'];
$csv = generate_import_items_csv($stock_locations, $attributes);
$this->assertStringContainsString('"attribute_Color"', $csv);
$this->assertStringContainsString('"attribute_Size"', $csv);
$this->assertStringContainsString('"attribute_Weight"', $csv);
}
public function testGenerateStockLocationHeaders(): void
{
$locations = ['Warehouse', 'Store'];
$headers = generate_stock_location_headers($locations);
$this->assertEquals(',"location_Warehouse","location_Store"', $headers);
}
public function testGenerateAttributeHeaders(): void
{
$attributes = ['Color', 'Size'];
$headers = generate_attribute_headers($attributes);
$this->assertEquals(',"attribute_Color","attribute_Size"', $headers);
}
public function testGenerateAttributeHeadersRemovesNegativeOneIndex(): void
{
$attributes = [-1 => 'None', 'Color' => 'Color'];
unset($attributes[-1]);
$headers = generate_attribute_headers($attributes);
$this->assertStringContainsString('"attribute_Color"', $headers);
}
public function testGetCsvFileBasic(): void
{
$csv_content = "Id,Barcode,\"Item Name\",Category,\"Supplier ID\",\"Cost Price\",\"Unit Price\",\"Tax 1 Name\",\"Tax 1 Percent\",\"Tax 2 Name\",\"Tax 2 Percent\",\"Reorder Level\",Description,\"Allow Alt Description\",\"Item has Serial Number\",Image,HSN\n";
$csv_content .= ",ITEM001,Test Item,Electronics,1,10.00,15.00,,,,,5,Test Description,0,0,,HSN001\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_test_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(1, $rows);
$this->assertEquals('', $rows[0]['Id']);
$this->assertEquals('ITEM001', $rows[0]['Barcode']);
$this->assertEquals('Test Item', $rows[0]['Item Name']);
$this->assertEquals('Electronics', $rows[0]['Category']);
unlink($temp_file);
}
public function testGetCsvFileWithBom(): void
{
$bom = pack('CCC', 0xef, 0xbb, 0xbf);
$csv_content = $bom . "Id,\"Item Name\",Category\n";
$csv_content .= "1,Test Item,Electronics\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_test_bom_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(1, $rows);
$this->assertEquals('1', $rows[0]['Id']);
$this->assertEquals('Test Item', $rows[0]['Item Name']);
unlink($temp_file);
}
public function testGetCsvFileMultipleRows(): void
{
$csv_content = "Id,\"Item Name\",Category\n";
$csv_content .= "1,Item One,Cat A\n";
$csv_content .= "2,Item Two,Cat B\n";
$csv_content .= "3,Item Three,Cat C\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_test_multi_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(3, $rows);
$this->assertEquals('Item One', $rows[0]['Item Name']);
$this->assertEquals('Item Two', $rows[1]['Item Name']);
$this->assertEquals('Item Three', $rows[2]['Item Name']);
unlink($temp_file);
}
public function testBomExists(): void
{
$bom = pack('CCC', 0xef, 0xbb, 0xbf);
$content_with_bom = $bom . "test content";
$temp_file = tempnam(sys_get_temp_dir(), 'bom_test_');
file_put_contents($temp_file, $content_with_bom);
$handle = fopen($temp_file, 'r');
$result = bom_exists($handle);
fclose($handle);
$this->assertTrue($result);
unlink($temp_file);
}
public function testBomNotExists(): void
{
$content_without_bom = "test content without BOM";
$temp_file = tempnam(sys_get_temp_dir(), 'no_bom_test_');
file_put_contents($temp_file, $content_without_bom);
$handle = fopen($temp_file, 'r');
$result = bom_exists($handle);
fclose($handle);
$this->assertFalse($result);
unlink($temp_file);
}
public function testImportItemBasicFields(): void
{
$item_data = [
'item_id' => null,
'name' => 'CSV Imported Item',
'description' => 'Description from CSV',
'category' => 'Electronics',
'cost_price' => 10.50,
'unit_price' => 25.99,
'reorder_level' => 5,
'supplier_id' => null,
'item_number' => 'CSV-ITEM-001',
'allow_alt_description' => 0,
'is_serialized' => 0,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$this->assertIsInt($item_id);
$this->assertGreaterThan(0, $item_id);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('CSV Imported Item', $saved_item->name);
$this->assertEquals('Description from CSV', $saved_item->description);
$this->assertEquals('Electronics', $saved_item->category);
$this->assertEquals(10.50, (float)$saved_item->cost_price);
$this->assertEquals(25.99, (float)$saved_item->unit_price);
}
public function testImportItemWithQuantity(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Quantity',
'category' => 'Test Category',
'cost_price' => 5.00,
'unit_price' => 10.00,
'reorder_level' => 2,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$location_id = 1;
$quantity = 100;
$item_quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => $quantity
];
$result = $this->item_quantity->save_value($item_quantity_data, $item_id, $location_id);
$this->assertTrue($result);
$saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id);
$this->assertEquals($quantity, $saved_quantity->quantity);
}
public function testImportItemCreatesInventoryRecord(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Inventory',
'category' => 'Test',
'cost_price' => 5.00,
'unit_price' => 10.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$inventory_data = [
'trans_inventory' => 50,
'trans_items' => $item_id,
'trans_location' => 1,
'trans_comment' => 'CSV Import',
'trans_user' => 1
];
$trans_id = $this->inventory->insert($inventory_data);
$this->assertIsInt($trans_id);
$this->assertGreaterThan(0, $trans_id);
$inventory_records = $this->inventory->get_inventory_data_for_item($item_id, 1);
$this->assertGreaterThanOrEqual(1, $inventory_records->getNumRows());
}
public function testImportItemWithTaxes(): void
{
$item_data = [
'item_id' => null,
'name' => 'Taxable Item',
'category' => 'Test',
'cost_price' => 100.00,
'unit_price' => 150.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$taxes_data = [
['name' => 'VAT', 'percent' => 20],
['name' => 'GST', 'percent' => 10]
];
$result = $this->item_taxes->save_value($taxes_data, $item_id);
$this->assertTrue($result);
$saved_taxes = $this->item_taxes->get_info($item_id);
$tax_names = array_column($saved_taxes, 'name');
$this->assertContains('VAT', $tax_names);
$this->assertContains('GST', $tax_names);
}
public function testImportMultipleItemsFromSimulatedCsv(): void
{
$csv_data = [
[
'Id' => '',
'Barcode' => 'ITEM-A',
'Item Name' => 'First Item',
'Category' => 'Category A',
'Supplier ID' => '',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'Tax 1 Name' => '',
'Tax 1 Percent' => '',
'Tax 2 Name' => '',
'Tax 2 Percent' => '',
'Reorder Level' => '5',
'Description' => 'First item description',
'Allow Alt Description' => '0',
'Item has Serial Number' => '0',
'Image' => '',
'HSN' => '',
'location_Warehouse' => '100'
],
[
'Id' => '',
'Barcode' => 'ITEM-B',
'Item Name' => 'Second Item',
'Category' => 'Category B',
'Supplier ID' => '',
'Cost Price' => '15.00',
'Unit Price' => '30.00',
'Tax 1 Name' => '',
'Tax 1 Percent' => '',
'Tax 2 Name' => '',
'Tax 2 Percent' => '',
'Reorder Level' => '10',
'Description' => 'Second item description',
'Allow Alt Description' => '0',
'Item has Serial Number' => '0',
'Image' => '',
'HSN' => '',
'location_Warehouse' => '50'
]
];
$imported_item_ids = [];
foreach ($csv_data as $row) {
$item_data = [
'item_id' => (int)$row['Id'] ?: null,
'name' => $row['Item Name'],
'description' => $row['Description'],
'category' => $row['Category'],
'cost_price' => (float)$row['Cost Price'],
'unit_price' => (float)$row['Unit Price'],
'reorder_level' => (int)$row['Reorder Level'],
'item_number' => $row['Barcode'] ?: null,
'allow_alt_description' => empty($row['Allow Alt Description']) ? '0' : '1',
'is_serialized' => empty($row['Item has Serial Number']) ? '0' : '1',
'deleted' => false
];
$item_id = $this->item->save_value($item_data);
$imported_item_ids[] = $item_id;
}
$this->assertCount(2, $imported_item_ids);
$item1 = $this->item->get_info($imported_item_ids[0]);
$this->assertEquals('First Item', $item1->name);
$this->assertEquals(10.00, (float)$item1->cost_price);
$item2 = $this->item->get_info($imported_item_ids[1]);
$this->assertEquals('Second Item', $item2->name);
$this->assertEquals(15.00, (float)$item2->cost_price);
}
public function testImportUpdateExistingItem(): void
{
$original_data = [
'item_id' => null,
'name' => 'Original Name',
'category' => 'Original Category',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($original_data);
$updated_data = [
'item_id' => $item_id,
'name' => 'Updated Name',
'category' => 'Updated Category',
'cost_price' => 15.00,
'unit_price' => 30.00,
'description' => 'New description',
'reorder_level' => 10,
'deleted' => 0
];
$this->item->save_value($updated_data);
$updated_item = $this->item->get_info($item_id);
$this->assertEquals('Updated Name', $updated_item->name);
$this->assertEquals('Updated Category', $updated_item->category);
$this->assertEquals(15.00, (float)$updated_item->cost_price);
$this->assertEquals(30.00, (float)$updated_item->unit_price);
}
public function testImportItemWithAttributeText(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Attribute',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$definition_data = [
'definition_name' => 'Color',
'definition_type' => TEXT,
'definition_flags' => 0,
'deleted' => 0
];
$definition_id = $this->attribute->saveDefinition($definition_data);
$attribute_value = 'Red';
$attribute_id = $this->attribute->saveAttributeValue(
$attribute_value,
$definition_id,
$item_id,
false,
TEXT
);
$this->assertNotFalse($attribute_id);
$saved_value = $this->attribute->getAttributeValue($item_id, $definition_id);
$this->assertEquals('Red', $saved_value->attribute_value);
}
public function testImportItemWithAttributeDropdown(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With Dropdown',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$definition_data = [
'definition_name' => 'Size',
'definition_type' => DROPDOWN,
'definition_flags' => 0,
'deleted' => 0
];
$definition_id = $this->attribute->saveDefinition($definition_data);
$dropdown_values = ['Small', 'Medium', 'Large'];
foreach ($dropdown_values as $i => $value) {
$this->db->table('attribute_values')->insert([
'attribute_value' => $value,
'definition_id' => $definition_id,
'definition_type' => DROPDOWN,
'attribute_group' => $i,
'deleted' => 0
]);
}
$attribute_value = 'Medium';
$attribute_id = $this->attribute->saveAttributeValue(
$attribute_value,
$definition_id,
$item_id,
false,
DROPDOWN
);
$this->assertNotFalse($attribute_id);
$saved_value = $this->attribute->getAttributeValue($item_id, $definition_id);
$this->assertEquals('Medium', $saved_value->attribute_value);
}
public function testImportItemQuantityZero(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Zero Quantity',
'category' => 'Test',
'cost_price' => 5.00,
'unit_price' => 10.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$location_id = 1;
$item_quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => 0
];
$result = $this->item_quantity->save_value($item_quantity_data, $item_id, $location_id);
$this->assertTrue($result);
$saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id);
$this->assertEquals(0, (int)$saved_quantity->quantity);
}
public function testImportItemWithNegativeReorderLevel(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Negative Reorder',
'category' => 'Test',
'cost_price' => 5.00,
'unit_price' => 10.00,
'reorder_level' => -1,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals(-1, (int)$saved_item->reorder_level);
}
public function testImportItemWithHighPrecisionPrices(): void
{
$item_data = [
'item_id' => null,
'name' => 'High Precision Item',
'category' => 'Test',
'cost_price' => 10.123456,
'unit_price' => 25.876543,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$saved_item = $this->item->get_info($item_id);
$cost_diff = abs(10.123456 - (float)$saved_item->cost_price);
$price_diff = abs(25.876543 - (float)$saved_item->unit_price);
$this->assertLessThan(0.001, $cost_diff, 'Cost price should maintain precision');
$this->assertLessThan(0.001, $price_diff, 'Unit price should maintain precision');
}
public function testImportItemWithHsnCode(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item With HSN',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'hsn_code' => '8471',
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('8471', $saved_item->hsn_code);
}
public function testImportItemQuantityMultipleLocations(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Multi Location',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$quantities = [
['location_id' => 1, 'quantity' => 100],
['location_id' => 2, 'quantity' => 50],
['location_id' => 3, 'quantity' => 25]
];
foreach ($quantities as $q) {
$result = $this->item_quantity->save_value(
['item_id' => $item_id, 'location_id' => $q['location_id'], 'quantity' => $q['quantity']],
$item_id,
$q['location_id']
);
$this->assertTrue($result);
}
foreach ($quantities as $q) {
$saved = $this->item_quantity->get_item_quantity($item_id, $q['location_id']);
$this->assertEquals($q['quantity'], (int)$saved->quantity, "Quantity at location {$q['location_id']} should match");
}
}
public function testCsvImportQuantityValidationNumeric(): void
{
$csv_data = [
'Id' => '',
'Barcode' => 'VALID-ITEM',
'Item Name' => 'Valid Item',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_Warehouse' => '100'
];
$this->assertTrue(is_numeric($csv_data['location_Warehouse']));
$this->assertTrue(is_numeric($csv_data['Cost Price']));
$this->assertTrue(is_numeric($csv_data['Unit Price']));
}
public function testCsvImportEmptyBarcodeAllowed(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Without Barcode',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'item_number' => null,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$this->assertIsInt($item_id);
$this->assertGreaterThan(0, $item_id);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('Item Without Barcode', $saved_item->name);
}
public function testCsvImportItemExistsCheck(): void
{
$item_data = [
'item_id' => null,
'name' => 'Existing Item',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$exists = $this->item->exists($item_id);
$this->assertTrue($exists);
$not_exists = $this->item->exists(999999);
$this->assertFalse($not_exists);
}
public function testFullCsvImportFlowSimulated(): void
{
$csv_row = [
'Id' => '',
'Barcode' => 'FULL-TEST-001',
'Item Name' => 'Complete Test Item',
'Category' => 'Electronics',
'Supplier ID' => '',
'Cost Price' => '50.00',
'Unit Price' => '100.00',
'Tax 1 Name' => 'VAT',
'Tax 1 Percent' => '20',
'Tax 2 Name' => '',
'Tax 2 Percent' => '',
'Reorder Level' => '10',
'Description' => 'A complete test item for CSV import',
'Allow Alt Description' => '1',
'Item has Serial Number' => '0',
'Image' => '',
'HSN' => '84713020'
];
$item_data = [
'item_id' => (int)$csv_row['Id'] ?: null,
'name' => $csv_row['Item Name'],
'description' => $csv_row['Description'],
'category' => $csv_row['Category'],
'cost_price' => (float)$csv_row['Cost Price'],
'unit_price' => (float)$csv_row['Unit Price'],
'reorder_level' => (int)$csv_row['Reorder Level'],
'item_number' => $csv_row['Barcode'] ?: null,
'allow_alt_description' => empty($csv_row['Allow Alt Description']) ? '0' : '1',
'is_serialized' => empty($csv_row['Item has Serial Number']) ? '0' : '1',
'hsn_code' => $csv_row['HSN'],
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$taxes_data = [];
if (is_numeric($csv_row['Tax 1 Percent']) && $csv_row['Tax 1 Name'] !== '') {
$taxes_data[] = ['name' => $csv_row['Tax 1 Name'], 'percent' => $csv_row['Tax 1 Percent']];
}
if (is_numeric($csv_row['Tax 2 Percent']) && $csv_row['Tax 2 Name'] !== '') {
$taxes_data[] = ['name' => $csv_row['Tax 2 Name'], 'percent' => $csv_row['Tax 2 Percent']];
}
if (!empty($taxes_data)) {
$this->item_taxes->save_value($taxes_data, $item_id);
}
$location_id = 1;
$quantity = 75;
$quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => $quantity
];
$this->item_quantity->save_value($quantity_data, $item_id, $location_id);
$inventory_data = [
'trans_inventory' => $quantity,
'trans_items' => $item_id,
'trans_location' => $location_id,
'trans_comment' => 'CSV import quantity',
'trans_user' => 1
];
$this->inventory->insert($inventory_data);
$saved_item = $this->item->get_info($item_id);
$this->assertEquals('Complete Test Item', $saved_item->name);
$this->assertEquals('Electronics', $saved_item->category);
$this->assertEquals(50.00, (float)$saved_item->cost_price);
$this->assertEquals(100.00, (float)$saved_item->unit_price);
$this->assertEquals('84713020', $saved_item->hsn_code);
$saved_quantity = $this->item_quantity->get_item_quantity($item_id, $location_id);
$this->assertEquals($quantity, (int)$saved_quantity->quantity);
$saved_taxes = $this->item_taxes->get_info($item_id);
$this->assertCount(1, $saved_taxes);
$this->assertEquals('VAT', $saved_taxes[0]['name']);
$this->assertEquals(20, (float)$saved_taxes[0]['percent']);
$inventory_records = $this->inventory->get_inventory_data_for_item($item_id, $location_id);
$this->assertGreaterThanOrEqual(1, $inventory_records->getNumRows());
}
public function testImportCsvInvalidStockLocationColumn(): void
{
$csv_headers = ['Id', 'Item Name', 'Category', 'Cost Price', 'Unit Price', 'location_NonExistentLocation'];
$csv_row = [
'Id' => '',
'Item Name' => 'Test Item Invalid Location',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_NonExistentLocation' => '100'
];
$allowed_locations = [1 => 'Warehouse'];
$location_columns_in_csv = [];
foreach (array_keys($csv_row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns_in_csv[$key] = substr($key, 9);
}
}
$invalid_locations = [];
foreach ($location_columns_in_csv as $column => $location_name) {
if (!in_array($location_name, $allowed_locations)) {
$invalid_locations[] = $location_name;
}
}
$this->assertNotEmpty($invalid_locations, 'Should detect invalid location in CSV');
$this->assertContains('NonExistentLocation', $invalid_locations);
}
public function testImportCsvValidStockLocationColumn(): void
{
$csv_row = [
'Id' => '',
'Item Name' => 'Test Item Valid Location',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_Warehouse' => '100'
];
$allowed_locations = [1 => 'Warehouse'];
$location_columns_in_csv = [];
foreach (array_keys($csv_row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns_in_csv[$key] = substr($key, 9);
}
}
$invalid_locations = [];
foreach ($location_columns_in_csv as $column => $location_name) {
if (!in_array($location_name, $allowed_locations)) {
$invalid_locations[] = $location_name;
}
}
$this->assertEmpty($invalid_locations, 'Should have no invalid locations');
}
public function testImportCsvMixedValidAndInvalidLocations(): void
{
$csv_row = [
'Id' => '',
'Item Name' => 'Test Item Mixed Locations',
'Category' => 'Test',
'Cost Price' => '10.00',
'Unit Price' => '20.00',
'location_Warehouse' => '100',
'location_InvalidLocation' => '50'
];
$allowed_locations = [1 => 'Warehouse', 2 => 'Store'];
$location_columns_in_csv = [];
foreach (array_keys($csv_row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns_in_csv[$key] = substr($key, 9);
}
}
$invalid_locations = [];
foreach ($location_columns_in_csv as $column => $location_name) {
if (!in_array($location_name, $allowed_locations)) {
$invalid_locations[] = $location_name;
}
}
$this->assertCount(1, $invalid_locations, 'Should have exactly one invalid location');
$this->assertContains('InvalidLocation', $invalid_locations);
}
public function testValidateCsvStockLocations(): void
{
$csv_content = "Id,\"Item Name\",Category,\"Cost Price\",\"Unit Price\",\"location_Warehouse\",\"location_FakeLocation\"\n";
$csv_content .= ",Test Item,Test,10.00,20.00,100,50\n";
$temp_file = tempnam(sys_get_temp_dir(), 'csv_location_test_');
file_put_contents($temp_file, $csv_content);
$rows = get_csv_file($temp_file);
$this->assertCount(1, $rows);
$row = $rows[0];
$this->assertArrayHasKey('location_Warehouse', $row);
$this->assertArrayHasKey('location_FakeLocation', $row);
unlink($temp_file);
}
public function testImportItemQuantityOnlyForValidLocations(): void
{
$item_data = [
'item_id' => null,
'name' => 'Item Location Test',
'category' => 'Test',
'cost_price' => 10.00,
'unit_price' => 20.00,
'deleted' => 0
];
$item_id = $this->item->save_value($item_data);
$allowed_locations = [1 => 'Warehouse', 2 => 'Store'];
$csv_row_simulated = [
'location_Warehouse' => '100',
'location_Store' => '50',
'location_NonExistent' => '25'
];
foreach ($allowed_locations as $location_id => $location_name) {
$column_name = "location_$location_name";
if (isset($csv_row_simulated[$column_name]) || $csv_row_simulated[$column_name] === '0') {
$quantity_data = [
'item_id' => $item_id,
'location_id' => $location_id,
'quantity' => (int)$csv_row_simulated[$column_name]
];
$this->item_quantity->save_value($quantity_data, $item_id, $location_id);
}
}
$warehouse_qty = $this->item_quantity->get_item_quantity($item_id, 1);
$this->assertEquals(100, (int)$warehouse_qty->quantity);
$store_qty = $this->item_quantity->get_item_quantity($item_id, 2);
$this->assertEquals(50, (int)$store_qty->quantity);
$result = $this->item_quantity->exists($item_id, 999);
$this->assertFalse($result, 'Should not have quantity for non-existent location');
}
public function testDetectCsvLocationColumns(): void
{
$row = [
'Id' => '',
'Item Name' => 'Test',
'location_Warehouse' => '100',
'location_Store' => '50',
'attribute_Color' => 'Red'
];
$location_columns = [];
foreach (array_keys($row) as $key) {
if (str_starts_with($key, 'location_')) {
$location_columns[$key] = substr($key, 9);
}
}
$this->assertCount(2, $location_columns);
$this->assertArrayHasKey('location_Warehouse', $location_columns);
$this->assertArrayHasKey('location_Store', $location_columns);
$this->assertEquals('Warehouse', $location_columns['location_Warehouse']);
$this->assertEquals('Store', $location_columns['location_Store']);
}
public function testValidateLocationNamesCaseSensitivity(): void
{
$allowed_locations = [1 => 'Warehouse', 2 => 'Store'];
$csv_location_name = 'warehouse';
$is_valid = in_array($csv_location_name, $allowed_locations);
$this->assertFalse($is_valid, 'Location names should be case-sensitive');
$csv_location_name = 'Warehouse';
$is_valid = in_array($csv_location_name, $allowed_locations);
$this->assertTrue($is_valid, 'Valid location name should pass validation');
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Tests\Helpers;
use CodeIgniter\Test\CIUnitTestCase;
/**
* Test suite for attribute_helper functions
*
* Tests for PR #4384 attribute helper utilities
*/
class AttributeHelperTest extends CIUnitTestCase
{
protected function setUp(): void
{
parent::setUp();
helper('attribute');
}
/**
* Test getAttributeDataType returns correct column names
*
* @return void
*/
public function testGetAttributeDataTypeForText(): void
{
$this->assertEquals('attribute_value', getAttributeDataType('TEXT'));
}
/**
* Test getAttributeDataType for DECIMAL type
*
* @return void
*/
public function testGetAttributeDataTypeForDecimal(): void
{
$this->assertEquals('attribute_decimal', getAttributeDataType('DECIMAL'));
}
/**
* Test getAttributeDataType for DATE type
*
* @return void
*/
public function testGetAttributeDataTypeForDate(): void
{
$this->assertEquals('attribute_date', getAttributeDataType('DATE'));
}
/**
* Test getAttributeDataType for DROPDOWN type
*
* @return void
*/
public function testGetAttributeDataTypeForDropdown(): void
{
// Note: DROPDOWN is a special case that uses attribute_value
// This test verifies the expected behavior
$this->assertEquals('attribute_value', getAttributeDataType('DROPDOWN'));
}
/**
* Test getAttributeDataType for invalid type returns fallback
*
* @return void
*/
public function testGetAttributeDataTypeForInvalidType(): void
{
// Invalid types should return 'attribute_value' as fallback
$this->assertEquals('attribute_value', getAttributeDataType('INVALID_TYPE'));
}
/**
* Test getAttributeDataType for checkbox type
*
* @return void
*/
public function testGetAttributeDataTypeForCheckbox(): void
{
// CHECKBOX values are stored as '0' or '1' in attribute_value
$this->assertEquals('attribute_value', getAttributeDataType('CHECKBOX'));
}
/**
* Test that validateAttributeValueType throws exception for invalid type
*
* @return void
*/
public function testValidateAttributeValueTypeInvalid(): void
{
$this->expectException(\InvalidArgumentException::class);
validateAttributeValueType('INVALID_TYPE');
}
/**
* Test that validateAttributeValueType does not throw for valid types
*
* @return void
*/
public function testValidateAttributeValueTypeValidText(): void
{
// Should not throw exception
validateAttributeValueType('attribute_value');
}
/**
* Test that validateAttributeValueType does not throw for decimal type
*
* @return void
*/
public function testValidateAttributeValueTypeValidDecimal(): void
{
// Should not throw exception
validateAttributeValueType('attribute_decimal');
}
/**
* Test that validateAttributeValueType does not throw for date type
*
* @return void
*/
public function testValidateAttributeValueTypeValidDate(): void
{
// Should not throw exception
validateAttributeValueType('attribute_date');
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Tests\Helpers;
use CodeIgniter\Test\CIUnitTestCase;
/**
* Test suite for importfile_helper functions
*
* Tests for PR #4384 CSV import attribute deletion capability with _DELETE_ magic word
*/
class ImportFileHelperTest extends CIUnitTestCase
{
protected function setUp(): void
{
parent::setUp();
helper('importfile');
}
/**
* Test _DELETE_ magic word case-insensitive comparison
*
* The PR uses strcasecmp for case-insensitive comparison of _DELETE_
*
* @return void
*/
public function testDeleteMagicWordCaseInsensitive(): void
{
// Test that strcasecmp identifies _DELETE_ regardless of case
$this->assertEquals(0, strcasecmp('_DELETE_', '_DELETE_'),
'Exact match should return 0');
$this->assertEquals(0, strcasecmp('_DELETE_', '_delete_'),
'Lowercase should match');
$this->assertEquals(0, strcasecmp('_DELETE_', '_Delete_'),
'Mixed case should match');
// Test that non-matching strings return non-zero
$this->assertNotEquals(0, strcasecmp('_DELETE_', 'DELETE'),
'Without underscore should not match');
$this->assertNotEquals(0, strcasecmp('_DELETE_', 'test'),
'Random text should not match');
}
/**
* Test that _DELETE_ does not match similar-looking strings
*
* @return void
*/
public function testDeleteMagicWordNotConfusedWithSimilar(): void
{
// These should NOT match
$this->assertNotEquals(0, strcasecmp('_DELETE_', '__DELETE__'),
'Double underscore should not match');
$this->assertNotEquals(0, strcasecmp('_DELETE_', 'DELETE_'),
'Without underscore should not match');
$this->assertNotEquals(0, strcasecmp('_DELETE_', '_DELETE '),
'With trailing space should not match');
$this->assertNotEquals(0, strcasecmp('_DELETE_', ' _DELETE_'),
'With leading space should not match');
}
/**
* Test empty string does not match _DELETE_
*
* @return void
*/
public function testEmptyStringNotDelete(): void
{
$this->assertNotEquals(0, strcasecmp('_DELETE_', ''),
'Empty string should not match _DELETE_');
}
/**
* Test null safety with strcasecmp
*
* @return void
*/
public function testDeleteMagicWordNullSafety(): void
{
// strcasecmp with null would cause a warning
// This test documents the need for null checking in the controller
$testString = '_DELETE_';
$this->assertIsString($testString, 'Test string should not be null');
// In the actual code, empty() checks would be done before strcasecmp
$this->assertTrue(!empty($testString), 'Empty check should pass');
}
}

View File

@@ -0,0 +1,823 @@
<?php
namespace Tests\Models;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\Fabricator;
use App\Models\Attribute;
use App\Models\Item;
use App\Helpers\attribute_helper;
use Config\OSPOS;
/**
* Test suite for Attribute model
*
* Tests for PR #4384: Case-sensitive attribute updates and CSV Import attribute deletion capability
*/
class AttributeTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $migrate = true;
protected $migrateOnce = false;
protected $refresh = true;
protected $namespace = null;
/**
* @var Attribute
*/
protected $attribute;
/**
* @var Item
*/
protected $item;
protected function setUp(): void
{
parent::setUp();
$this->attribute = model(Attribute::class);
$this->item = model(Item::class);
helper('attribute');
}
protected function tearDown(): void
{
parent::tearDown();
}
/**
* Test that case-sensitive attribute value updates work correctly
*
* @return void
*/
public function testCaseSensitiveAttributeValueUpdate(): void
{
// Create a text definition
$definitionData = [
'definition_name' => 'Color',
'definition_type' => TEXT,
'definition_flags' => 0,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Create an item
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST001',
'description' => 'Test item description',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
// Save initial attribute value with uppercase
$attributeValue = 'RED';
$attributeId1 = $this->attribute->saveAttributeValue(
$attributeValue,
$definitionId,
$itemId,
false,
TEXT
);
// Update with lowercase
$attributeValueLower = 'red';
$attributeId2 = $this->attribute->saveAttributeValue(
$attributeValueLower,
$definitionId,
$itemId,
$attributeId1,
TEXT
);
// Verify the value was updated to lowercase
$result = $this->attribute->getAttributeValue($itemId, $definitionId);
$this->assertEquals('red', strtolower($result->attribute_value),
'Attribute value should be updated from RED to red');
// The attribute_value table should have been updated, not duplicated
$builder = $this->db->table('attribute_values');
$builder->where('attribute_id', $attributeId1);
$query = $builder->get();
$rows = $query->getResult();
$this->assertCount(1, $rows, 'Should only have one attribute_value row');
}
/**
* Test that attribute link deletion works correctly
*
* @return void
*/
public function testDeleteAttributeLinks(): void
{
// Create an item
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST002',
'description' => 'Test item description',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
// Create a definition
$definitionData = [
'definition_name' => 'Size',
'definition_type' => TEXT,
'definition_flags' => 0,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Save an attribute link
$attributeValue = 'Medium';
$attributeId = $this->attribute->saveAttributeValue(
$attributeValue,
$definitionId,
$itemId,
false,
TEXT
);
// Verify the link exists
$this->attribute->saveAttributeLink($itemId, $definitionId, $attributeId);
$builder = $this->db->table('attribute_links');
$builder->where('item_id', $itemId);
$builder->where('definition_id', $definitionId);
$query = $builder->get();
$result = $query->getResult();
$this->assertCount(1, $result, 'Attribute link should exist before deletion');
// Delete the attribute link
$deleted = $this->attribute->deleteAttributeLinks($itemId, $definitionId);
// Verify the link is deleted
$this->assertTrue($deleted, 'deleteAttributeLinks should return true');
$builder = $this->db->table('attribute_links');
$builder->where('item_id', $itemId);
$builder->where('definition_id', $definitionId);
$query = $builder->get();
$result = $query->getResult();
$this->assertCount(0, $result, 'Attribute link should be deleted');
}
/**
* Test that category dropdown can be enabled (bug fix from PR)
*
* @return void
*/
public function testCategoryDropdownCanBeEnabled(): void
{
// Create a dropdown definition for category
$definitionData = [
'definition_name' => 'category_dropdown',
'definition_type' => DROPDOWN,
'definition_flags' => 0,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// The bug was that definition_flags == 1 check prevented dropdowns
// Now it should use truthy check
$builder = $this->db->table('attribute_definitions');
$builder->where('definition_id', $definitionId);
$query = $builder->get();
$result = $query->getRow();
$this->assertEquals(DROPDOWN, $result->definition_type, 'Definition type should be DROPDOWN');
$this->assertEquals(0, $result->definition_flags, 'Definition flags should be 0');
}
/**
* Test DROPDOWN attribute value saving
*
* @return void
*/
public function testDropdownAttributeValueSave(): void
{
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST003',
'description' => 'Test item description',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
// Create a dropdown definition
$definitionData = [
'definition_name' => 'Material',
'definition_type' => DROPDOWN,
'definition_flags' => 0,
'definition_unit' => null,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Add dropdown values
$dropdownValues = ['Cotton', 'Polyester', 'Wool'];
foreach ($dropdownValues as $i => $value) {
$valueData = [
'attribute_value' => $value,
'definition_id' => $definitionId,
'definition_type' => DROPDOWN,
'attribute_group' => $i,
'deleted' => 0
];
$this->db->table('attribute_values')->insert($valueData);
}
// Save attribute with dropdown value
$attributeValue = 'Cotton';
$attributeId = $this->attribute->saveAttributeValue(
$attributeValue,
$definitionId,
$itemId,
false,
DROPDOWN
);
// Verify the dropdown value was saved
$result = $this->attribute->getAttributeValue($itemId, $definitionId);
$this->assertEquals('Cotton', $result->attribute_value);
// Verify the attribute link was created
$builder = $this->db->table('attribute_links');
$builder->where('item_id', $itemId);
$builder->where('definition_id', $definitionId);
$query = $builder->get();
$linkResult = $query->getRow();
$this->assertNotNull($linkResult->attribute_id, 'Attribute link should be created for dropdown');
}
/**
* Test DATE attribute value saving
*
* @return void
*/
public function testDateAttributeValueSave(): void
{
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST004',
'description' => 'Test item',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
$definitionData = [
'definition_name' => 'Manufacture Date',
'definition_type' => DATE,
'definition_flags' => 0,
'definition_unit' => null,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Save date attribute
$dateValue = date('Y-m-d');
$attributeId = $this->attribute->saveAttributeValue(
$dateValue,
$definitionId,
$itemId,
false,
DATE
);
// Verify the date was saved
$result = $this->attribute->getAttributeValue($itemId, $definitionId);
$this->assertEquals($dateValue, $result->attribute_date);
}
/**
* Test DATE attribute value case update
*
* @return void
*/
public function testDateAttributeValueCaseUpdate(): void
{
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST005',
'description' => 'Test item',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
$definitionData = [
'definition_name' => 'Expiration Date',
'definition_type' => DATE,
'definition_flags' => 0,
'definition_unit' => null,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Save initial date
$dateValue = date('Y-m-d', strtotime('2025-01-01'));
$attributeId1 = $this->attribute->saveAttributeValue(
$dateValue,
$definitionId,
$itemId,
false,
DATE
);
// Date format doesn't have case, but this test verifies the logic path
$result = $this->attribute->getAttributeValue($itemId, $definitionId);
$this->assertEquals($dateValue, $result->attribute_date);
}
/**
* Test DECIMAL attribute value saving
*
* @return void
*/
public function testDecimalAttributeValueSave(): void
{
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST006',
'description' => 'Test item',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
$definitionData = [
'definition_name' => 'Weight',
'definition_type' => DECIMAL,
'definition_flags' => 0,
'definition_unit' => 'kg',
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Save decimal attribute
$decimalValue = '2.5';
$attributeId = $this->attribute->saveAttributeValue(
$decimalValue,
$definitionId,
$itemId,
false,
DECIMAL
);
// Verify the decimal was saved
$result = $this->attribute->getAttributeValue($itemId, $definitionId);
$this->assertEquals(2.5, (float)$result->attribute_decimal);
}
/**
* Test CHECKBOX attribute value saving
*
* @return void
*/
public function testCheckboxAttributeValueSave(): void
{
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST007',
'description' => 'Test item',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
$definitionData = [
'definition_name' => 'Available',
'definition_type' => CHECKBOX,
'definition_flags' => 0,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Save checkbox attribute (checked)
$attributeId1 = $this->attribute->saveAttributeValue(
'true',
$definitionId,
$itemId,
false,
CHECKBOX
);
$result = $this->attribute->getAttributeValue($itemId, $definitionId);
$this->assertEquals('1', $result->attribute_value);
// Update to unchecked
$attributeId2 = $this->attribute->saveAttributeValue(
'false',
$definitionId,
$itemId,
$attributeId1,
CHECKBOX
);
$result = $this->attribute->getAttributeValue($itemId, $definitionId);
$this->assertEquals('0', $result->attribute_value);
}
/**
* Test category dropdown with CATEGORY_DEFINITION_ID
*
* @return void
*/
public function testCategoryDropdownWithConstant(): void
{
// Use the CATEGORY_DEFINITION_ID constant instead of -1
$definitionData = [
'definition_id' => CATEGORY_DEFINITION_ID,
'definition_name' => 'category_dropdown',
'definition_type' => DROPDOWN,
'definition_flags' => 0,
'deleted' => 0
];
$this->assertEquals(CATEGORY_DEFINITION_ID, -1, 'CATEGORY_DEFINITION_ID constant should equal -1');
$definitionId = $this->attribute->saveDefinition($definitionData, CATEGORY_DEFINITION_ID);
$this->assertEquals(CATEGORY_DEFINITION_ID, $definitionId,
'Category definition ID should remain CATEGORY_DEFINITION_ID');
}
/**
* Test that attribute links with sale_id or receiving_id are not deleted
*
* @return void
*/
public function testDeleteAttributeLinksPreservesSalesAndReceivings(): void
{
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST008',
'description' => 'Test item',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
$definitionData = [
'definition_name' => 'Color',
'definition_type' => TEXT,
'definition_flags' => 0,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Create attribute value
$attributeValue = 'Blue';
$attributeId = $this->attribute->saveAttributeValue(
$attributeValue,
$definitionId,
$itemId,
false,
TEXT
);
// Create attribute link WITHOUT sale_id/receiving_id
$this->attribute->saveAttributeLink($itemId, $definitionId, $attributeId);
// Create attribute link WITH sale_id (simulation)
$builder = $this->db->table('attribute_links');
$builder->insert([
'attribute_id' => $attributeId,
'item_id' => $itemId,
'definition_id' => $definitionId,
'sale_id' => 1, // Has a sale reference
'receiving_id' => null
]);
// Delete attribute links
$this->attribute->deleteAttributeLinks($itemId, $definitionId);
// Verify link WITH sale_id was NOT deleted
$builder = $this->db->table('attribute_links');
$builder->where('item_id', $itemId);
$builder->where('definition_id', $definitionId);
$builder->where('attribute_id', $attributeId);
$query = $builder->get();
$result = $query->getResult();
$this->assertCount(1, $result, 'Link with sale_id should not be deleted');
}
/**
* Test orphaned value deletion works correctly
*
* @return void
*/
public function testDeleteOrphanedValues(): void
{
$definitionData = [
'definition_name' => 'Temp Attribute',
'definition_type' => TEXT,
'definition_flags' => 0,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Create an orphaned attribute value (no links)
$builder = $this->db->table('attribute_values');
$builder->insert([
'attribute_value' => 'Orphan Value',
'definition_id' => $definitionId,
'definition_type' => TEXT,
'attribute_group' => 0,
'deleted' => 0
]);
// Delete orphaned values
$this->attribute->deleteOrphanedValues();
// Verify orphan was deleted
$builder = $this->db->table('attribute_values');
$builder->where('attribute_value', 'Orphan Value');
$builder->where('definition_id', $definitionId);
$query = $builder->get();
$result = $query->getResult();
$this->assertCount(0, $result, 'Orphaned value should be deleted');
}
/**
* Test Unicode case comparison for attribute values
*
* @return void
*/
public function testUnicodeCaseComparison(): void
{
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST009',
'description' => 'Test item',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
$definitionData = [
'definition_name' => 'Name',
'definition_type' => TEXT,
'definition_flags' => 0,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Test with Unicode characters that have case
$unicodeValue = 'ÄÄÖÜß'; // German umlauts
$attributeId1 = $this->attribute->saveAttributeValue(
$unicodeValue,
$definitionId,
$itemId,
false,
TEXT
);
// Update with lowercase
$unicodeLower = 'äöüß';
$attributeId2 = $this->attribute->saveAttributeValue(
$unicodeLower,
$definitionId,
$itemId,
$attributeId1,
TEXT
);
$result = $this->attribute->getAttributeValue($itemId, $definitionId);
// The value should be updated due to case difference
$this->assertNotEquals($unicodeValue, $result->attribute_value,
'Unicode case should be detected and updated');
}
/**
* Test getAttributeValueByAttributeId method
*
* @return void
*/
public function testGetAttributeValueByAttributeId(): void
{
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST010',
'description' => 'Test item',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
$definitionData = [
'definition_name' => 'Quality',
'definition_type' => TEXT,
'definition_flags' => 0,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
$attributeValue = 'Premium';
$attributeId = $this->attribute->saveAttributeValue(
$attributeValue,
$definitionId,
$itemId,
false,
TEXT
);
// Test getting value by attribute ID
$result = $this->attribute->getAttributeValueByAttributeId($attributeId, TEXT);
$this->assertNotNull($result);
$this->assertEquals('Premium', $result);
}
/**
* Test attribute link with null attribute_id
*
* @return void
*/
public function testAttributeLinkWithNullAttributeId(): void
{
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => 0,
'supplier_id' => null,
'item_number' => 'TEST011',
'description' => 'Test item',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
$definitionData = [
'definition_name' => 'Brand',
'definition_type' => TEXT,
'definition_flags' => 0,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData);
// Save attribute link with null attribute_id (should be inserted as null)
$saved = $this->attribute->saveAttributeLink($itemId, $definitionId, null);
$this->assertTrue($saved, 'saveAttributeLink should succeed with null attribute_id');
// Verify it was saved as null
$builder = $this->db->table('attribute_links');
$builder->where('item_id', $itemId);
$builder->where('definition_id', $definitionId);
$query = $builder->get();
$result = $query->getRow();
$this->assertNull($result->attribute_id, 'attribute_id should be null');
}
/**
* Test category dropdown updates item category
*
* @return void
*/
public function testCategoryDropdownUpdatesItemCategory(): void
{
// Create a category
$categoryId = 1; // Assuming category with ID 1 exists
// Create item with category
$itemData = [
'item_id' => null,
'name' => 'Test Item',
'category' => $categoryId,
'supplier_id' => null,
'item_number' => 'TEST012',
'description' => 'Test item',
'cost_price' => 10.00,
'unit_price' => 15.00,
'reorder_level' => 0,
'receiving_quantity' => 1,
'allow_alt_description' => NEW_ENTRY,
'is_serialized' => NEW_ENTRY,
'deleted' => 0
];
$itemId = $this->item->saveValue($itemData)['item_id'];
// Create category dropdown definition
$definitionData = [
'definition_id' => CATEGORY_DEFINITION_ID,
'definition_name' => 'category_dropdown',
'definition_type' => DROPDOWN,
'definition_flags' => 0,
'deleted' => 0
];
$definitionId = $this->attribute->saveDefinition($definitionData, CATEGORY_DEFINITION_ID);
// Add dropdown value matching category name
$builder = $this->db->table('attribute_values');
$builder->insert([
'attribute_value' => 'Electronics',
'definition_id' => CATEGORY_DEFINITION_ID,
'definition_type' => DROPDOWN,
'attribute_group' => 0,
'deleted' => 0
]);
// Verify the definition was created with CATEGORY_DEFINITION_ID
$this->assertEquals(CATEGORY_DEFINITION_ID, $definitionId,
'Category dropdown should use CATEGORY_DEFINITION_ID constant');
}
}

View File

@@ -10,6 +10,12 @@
<testsuite name="Helpers">
<directory>helpers</directory>
</testsuite>
<testsuite name="Models">
<directory>Models</directory>
</testsuite>
<testsuite name="Controllers">
<directory>Controllers</directory>
</testsuite>
</testsuites>
</phpunit>