mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-05-25 08:44:42 -04:00
Compare commits
28 Commits
fix/pr-430
...
feature/ub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86e150ad96 | ||
|
|
8f4055c711 | ||
|
|
3c25fd77e2 | ||
|
|
3f7ea18f18 | ||
|
|
36bf130bdd | ||
|
|
088ad47c99 | ||
|
|
808840b2e9 | ||
|
|
2ed74c5c0e | ||
|
|
c935fc7a2a | ||
|
|
89012054b4 | ||
|
|
89572aa289 | ||
|
|
2b56d56072 | ||
|
|
2fc9fc09a4 | ||
|
|
932b612c9e | ||
|
|
ab6e8ee083 | ||
|
|
79427481b3 | ||
|
|
b23351a45c | ||
|
|
bee0c8e364 | ||
|
|
849439c71e | ||
|
|
25680f05db | ||
|
|
a11fb099e2 | ||
|
|
aee5f31cf5 | ||
|
|
643b0ac499 | ||
|
|
3e844f2f89 | ||
|
|
2acdec431f | ||
|
|
f245f585da | ||
|
|
e48ab45094 | ||
|
|
46e31b1c16 |
87
.env
87
.env
@@ -1,87 +0,0 @@
|
||||
#--------------------------------------------------------------------
|
||||
# ENVIRONMENT
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
CI_ENVIRONMENT = production
|
||||
CI_DEBUG = false
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# APP
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
app.appTimezone = 'UTC'
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# DATABASE
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
database.default.hostname = 'localhost'
|
||||
database.default.database = 'ospos'
|
||||
database.default.username = 'admin'
|
||||
database.default.password = 'pointofsale'
|
||||
database.default.DBDriver = 'MySQLi'
|
||||
database.default.DBPrefix = 'ospos_'
|
||||
database.default.port = 3306
|
||||
|
||||
database.development.hostname = 'localhost'
|
||||
database.development.database = 'ospos'
|
||||
database.development.username = 'admin'
|
||||
database.development.password = 'pointofsale'
|
||||
database.development.DBDriver = 'MySQLi'
|
||||
database.development.DBPrefix = 'ospos_'
|
||||
database.development.port = 3306
|
||||
|
||||
database.tests.hostname = 'localhost'
|
||||
database.tests.database = 'ospos'
|
||||
database.tests.username = 'admin'
|
||||
database.tests.password = 'pointofsale'
|
||||
database.tests.DBDriver = 'MySQLi'
|
||||
database.tests.DBPrefix = 'ospos_'
|
||||
database.tests.charset = utf8mb4
|
||||
database.tests.DBCollat = utf8mb4_general_ci
|
||||
database.tests.port = 3306
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# EMAIL
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
email.SMTPHost = ''
|
||||
email.SMTPUser = ''
|
||||
email.SMTPPass = ''
|
||||
email.SMTPPort =
|
||||
email.SMTPTimeout = 5
|
||||
email.SMTPCrypto = 'tls'
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# ENCRYPTION
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
encryption.key = ''
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# HONEYPOT
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
honeypot.hidden = true
|
||||
honeypot.label = 'Fill This Field'
|
||||
honeypot.name = 'honeypot'
|
||||
honeypot.template = '<label>{label}</label><input type="text" name="{name}" value="">'
|
||||
honeypot.container = '<div style="display:none">{template}</div>'
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# LOGGER
|
||||
# - 0 = Disables logging, Error logging TURNED OFF
|
||||
# - 1 = Emergency Messages - System is unusable
|
||||
# - 2 = Alert Messages - Action Must Be Taken Immediately
|
||||
# - 3 = Critical Messages - Application component unavailable, unexpected exception.
|
||||
# - 4 = Runtime Errors - Don't need immediate action, but should be monitored.
|
||||
# - 5 = Warnings - Exceptional occurrences that are not errors.
|
||||
# - 6 = Notices - Normal but significant events.
|
||||
# - 7 = Info - Interesting events, like user logging in, etc.
|
||||
# - 8 = Debug - Detailed debug information.
|
||||
# - 9 = All Messages
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
logger.threshold = 0
|
||||
app.db_log_enabled = false
|
||||
app.db_log_only_long = false
|
||||
116
.github/workflows/unit-tests.yml
vendored
Normal file
116
.github/workflows/unit-tests.yml
vendored
Normal 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ public/license/*
|
||||
!public/license/.gitkeep
|
||||
app/Config/email.php
|
||||
npm-debug.log*
|
||||
.vscode
|
||||
|
||||
# Docker
|
||||
!docker/.env
|
||||
|
||||
@@ -15,7 +15,7 @@ script:
|
||||
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||
- docker run --rm -u $(id -u) -v $(pwd):/app opensourcepos/composer:ci4 composer install
|
||||
- version=$(grep application_version app/Config/App.php | sed "s/.*=\s'\(.*\)';/\1/g")
|
||||
- sed -i 's/production/development/g' .env
|
||||
- cp .env.example .env && sed -i 's/production/development/g' .env
|
||||
- sed -i "s/commit_sha1 = 'dev'/commit_sha1 = '$rev'/g" app/Config/OSPOS.php
|
||||
- echo "$version-$branch-$rev"
|
||||
- npm version "$version-$branch-$rev" --force || true
|
||||
|
||||
38
INSTALL.md
38
INSTALL.md
@@ -14,7 +14,7 @@ First of all, if you're seeing the message `system folder missing` after launchi
|
||||
2. Create/locate a new MySQL database to install Open Source Point of Sale into.
|
||||
3. Execute the file `app/Database/database.sql` to create the tables needed.
|
||||
4. Unzip and upload Open Source Point of Sale files to the web-server.
|
||||
5. Open `.env` file and modify credentials to connect to your database if needed.
|
||||
5. Open `.env` file and modify credentials to connect to your database if needed. (First copy .env.example to .env and update)
|
||||
7. Go to your install `public` dir via the browser.
|
||||
8. Log in using
|
||||
- Username: admin
|
||||
@@ -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.
|
||||
|
||||
@@ -117,7 +117,7 @@ class App extends BaseConfig
|
||||
| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!!
|
||||
|
|
||||
*/
|
||||
public string $permittedURIChars = 'a-z 0-9~%.:_\-';
|
||||
public string $permittedURIChars = 'a-z 0-9~%.:_\-=';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -70,7 +70,7 @@ class Filters extends BaseFilters
|
||||
public array $globals = [
|
||||
'before' => [
|
||||
'honeypot',
|
||||
// 'csrf' => ['except' => 'login'], // TODO: Temporarily disable CSRF until we get everything sorted
|
||||
'csrf' => ['except' => 'login'],
|
||||
'invalidchars',
|
||||
],
|
||||
'after' => [
|
||||
|
||||
@@ -15,7 +15,7 @@ class Security extends BaseConfig
|
||||
*
|
||||
* @var string 'cookie' or 'session'
|
||||
*/
|
||||
public string $csrfProtection = 'cookie';
|
||||
public string $csrfProtection = 'session';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
@@ -71,7 +71,7 @@ class Security extends BaseConfig
|
||||
*
|
||||
* Regenerate CSRF Token on every submission.
|
||||
*/
|
||||
public bool $regenerate = true;
|
||||
public bool $regenerate = false;
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseService;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use Config\Services as AppServices;
|
||||
use Locale;
|
||||
use HTMLPurifier;
|
||||
use HTMLPurifier_Config;
|
||||
use CodeIgniter\Config\BaseService;
|
||||
use Config\Services as AppServices;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
|
||||
/**
|
||||
* Services Configuration 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')));
|
||||
|
||||
@@ -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')]);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ class Giftcards extends Secure_Controller
|
||||
'record_time' => date('Y-m-d H:i:s'),
|
||||
'giftcard_number' => $giftcard_number,
|
||||
'value' => parse_decimals($this->request->getPost('giftcard_amount')),
|
||||
'person_id' => $this->request->getPost('person_id') == '' ? null : $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT)
|
||||
'person_id' => empty($this->request->getPost('person_id')) ? null : $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT)
|
||||
];
|
||||
|
||||
if ($this->giftcard->save_value($giftcard_data, $giftcard_id)) {
|
||||
@@ -160,9 +160,12 @@ class Giftcards extends Secure_Controller
|
||||
*/
|
||||
public function postCheckNumberGiftcard(): void
|
||||
{
|
||||
$giftcard_amount = parse_decimals($this->request->getPost('giftcard_amount'));
|
||||
$parsed_value = filter_var($giftcard_amount, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION);
|
||||
echo json_encode(['success' => $parsed_value !== false && $parsed_value > 0 && $giftcard_amount !== false, 'giftcard_amount' => to_currency_no_money($parsed_value)]);
|
||||
$existing_id = $this->request->getPost('giftcard_id', FILTER_SANITIZE_NUMBER_INT);
|
||||
$giftcard_number = $this->request->getPost('giftcard_number', FILTER_SANITIZE_NUMBER_INT);
|
||||
$giftcard_id = $this->giftcard->get_giftcard_id($giftcard_number);
|
||||
$success = ($giftcard_id == (int) $existing_id || !$giftcard_id );
|
||||
|
||||
echo $success ? 'true' : 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -165,7 +165,7 @@ class Item_kits extends Secure_Controller
|
||||
$item_kit_data = [
|
||||
'name' => $this->request->getPost('name'),
|
||||
'item_kit_number' => $this->request->getPost('item_kit_number'),
|
||||
'item_id' => $this->request->getPost('kit_item_id') ? intval($this->request->getPost('kit_item_id')) : null,
|
||||
'item_id' => $this->request->getPost('kit_item_id'),
|
||||
'kit_discount' => parse_decimals($this->request->getPost('kit_discount')),
|
||||
'kit_discount_type' => $this->request->getPost('kit_discount_type') === null ? PERCENT : intval($this->request->getPost('kit_discount_type')),
|
||||
'price_option' => $this->request->getPost('price_option') === null ? PRICE_ALL : intval($this->request->getPost('price_option')),
|
||||
|
||||
@@ -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;
|
||||
@@ -137,26 +135,6 @@ class Items extends Secure_Controller
|
||||
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to serve images with proper headers
|
||||
* @param string $imagePath
|
||||
* @return void
|
||||
*/
|
||||
private function serveImage(string $imagePath): void
|
||||
{
|
||||
if (!file_exists($imagePath)) {
|
||||
$this->response->setStatusCode(404);
|
||||
$this->response->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$mimeType = mime_content_type($imagePath);
|
||||
$this->response->setContentType($mimeType);
|
||||
$this->response->setHeader('Content-Length', filesize($imagePath));
|
||||
$this->response->setBody(file_get_contents($imagePath));
|
||||
$this->response->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX function. Processes thumbnail of image. Called via tabular_helper
|
||||
* @param string $pic_filename
|
||||
@@ -167,54 +145,27 @@ class Items extends Secure_Controller
|
||||
{
|
||||
helper('file');
|
||||
|
||||
$fileExtension = pathinfo($pic_filename, PATHINFO_EXTENSION);
|
||||
$uploadPath = FCPATH . 'uploads/item_pics/';
|
||||
|
||||
$images = empty($fileExtension)
|
||||
? glob($uploadPath . $pic_filename . '.*')
|
||||
: glob($uploadPath . $pic_filename);
|
||||
$file_extension = pathinfo($pic_filename, PATHINFO_EXTENSION);
|
||||
$images = glob("./uploads/item_pics/$pic_filename");
|
||||
$base_path = './uploads/item_pics/' . pathinfo($pic_filename, PATHINFO_FILENAME);
|
||||
|
||||
if (sizeof($images) > 0) {
|
||||
$imagePath = $images[0];
|
||||
$actualExtension = pathinfo($imagePath, PATHINFO_EXTENSION);
|
||||
$basePath = $uploadPath . pathinfo($pic_filename, PATHINFO_FILENAME);
|
||||
$thumbPath = $basePath . "_thumb.$actualExtension";
|
||||
$image_path = $images[0];
|
||||
$thumb_path = $base_path . "_thumb.$file_extension";
|
||||
|
||||
// Try to create thumbnail if it doesn't exist
|
||||
if (!file_exists($thumbPath)) {
|
||||
try {
|
||||
$image = Services::image('gd2');
|
||||
$image->withFile($imagePath)
|
||||
->resize(52, 32, true, 'height')
|
||||
->save($thumbPath);
|
||||
} catch (Exception $e) {
|
||||
// If thumbnail creation fails, serve original image
|
||||
log_message('error', 'Thumbnail creation failed: ' . $e->getMessage());
|
||||
$this->serveImage($imagePath);
|
||||
return;
|
||||
}
|
||||
if (sizeof($images) < 2 && !file_exists($thumb_path)) {
|
||||
$image = Services::image('gd2');
|
||||
$image->withFile($image_path)
|
||||
->resize(52, 32, true, 'height')
|
||||
->save($thumb_path);
|
||||
}
|
||||
|
||||
// Serve thumbnail if it exists, otherwise serve original
|
||||
if (file_exists($thumbPath)) {
|
||||
$this->serveImage($thumbPath);
|
||||
} else {
|
||||
$this->serveImage($imagePath);
|
||||
}
|
||||
} else {
|
||||
// No image found, serve default
|
||||
$defaultImage = FCPATH . 'public/images/no-img.png';
|
||||
if (file_exists($defaultImage)) {
|
||||
$this->serveImage($defaultImage);
|
||||
} else {
|
||||
// Return 404 if no default image
|
||||
$this->response->setStatusCode(404);
|
||||
$this->response->send();
|
||||
}
|
||||
$this->response->setContentType(mime_content_type($thumb_path));
|
||||
$this->response->setBody(file_get_contents($thumb_path));
|
||||
$this->response->send();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gives search suggestions based on what is being searched for
|
||||
* @noinspection PhpUnused
|
||||
@@ -541,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;
|
||||
@@ -577,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;
|
||||
@@ -966,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();
|
||||
@@ -986,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')]);
|
||||
@@ -1000,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'],
|
||||
@@ -1037,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)]);
|
||||
@@ -1087,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')]);
|
||||
}
|
||||
@@ -1101,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;
|
||||
@@ -1162,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;
|
||||
@@ -1197,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;
|
||||
}
|
||||
@@ -1233,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1357,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1727,7 +1727,7 @@ class Reports extends Secure_Controller
|
||||
];
|
||||
}
|
||||
|
||||
$supplier_info = $this->supplier->get_info($supplier_id);
|
||||
$supplier_info = $this->supplier->get_info((int) $supplier_id);
|
||||
$data = [
|
||||
'title' => $supplier_info->company_name . ' (' . $supplier_info->first_name . ' ' . $supplier_info->last_name . ') ' . lang('Reports.report'),
|
||||
'subtitle' => $this->_get_subtitle_report(['start_date' => $start_date, 'end_date' => $end_date]),
|
||||
|
||||
@@ -399,7 +399,7 @@ class Sales extends Secure_Controller
|
||||
$cur_giftcard_customer = $giftcard->get_giftcard_customer($giftcard_num);
|
||||
$customer_id = $this->sale_lib->get_customer();
|
||||
|
||||
if (isset($cur_giftcard_customer) && $cur_giftcard_customer != $customer_id) {
|
||||
if (isset($cur_giftcard_customer) && $cur_giftcard_customer != $customer_id && $cur_giftcard_customer != null) {
|
||||
$data['error'] = lang('Giftcards.cannot_use', [$giftcard_num]);
|
||||
} elseif (($cur_giftcard_value - $current_payments_with_giftcard) <= 0 && $this->sale_lib->get_mode() === 'sale') {
|
||||
$data['error'] = lang('Giftcards.remaining_balance', [$giftcard_num, $cur_giftcard_value]);
|
||||
@@ -417,7 +417,6 @@ class Sales extends Secure_Controller
|
||||
$customer_id = $this->sale_lib->get_customer();
|
||||
$package_id = $this->customer->get_info($customer_id)->package_id;
|
||||
if (!empty($package_id)) {
|
||||
$package_name = $this->customer_rewards->get_name($package_id); // TODO: this variable is never used.
|
||||
$points = $this->customer->get_info($customer_id)->points;
|
||||
$points = ($points == null ? 0 : $points);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration_NullableTaxCategoryId extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
helper('migration');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.2_missing_config_keys.sql');
|
||||
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.2_nullable_tax_category_id.sql');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
33
app/Helpers/attribute_helper.php
Normal file
33
app/Helpers/attribute_helper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -108,14 +108,3 @@ function remove_backup(): void
|
||||
}
|
||||
log_message('info', "File $backup_path has been removed");
|
||||
}
|
||||
|
||||
function purifyHtml($data)
|
||||
{
|
||||
if (is_array($data)) {
|
||||
return array_map('purifyHtml', $data);
|
||||
} elseif (is_string($data)) {
|
||||
return Services::HtmlPurifier()->purify($data);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ function transform_headers(array $headers, bool $readonly = false, bool $editabl
|
||||
'field' => key($element),
|
||||
'title' => current($element),
|
||||
'switchable' => $element['switchable'] ?? !preg_match('(^$| )', current($element)),
|
||||
'escape' => !preg_match("/(edit|phone_number|email|messages|item_pic|customer_name|note)/", key($element)) && !(isset($element['escape']) && !$element['escape']),
|
||||
'escape' => !preg_match("/(edit|email|messages|item_pic|customer_name|note)/", key($element)) && !(isset($element['escape']) && !$element['escape']),
|
||||
'sortable' => $element['sortable'] ?? current($element) != '',
|
||||
'checkbox' => $element['checkbox'] ?? false,
|
||||
'class' => isset($element['checkbox']) || preg_match('(^$| )', current($element)) ? 'print_hide' : '',
|
||||
@@ -461,36 +461,18 @@ function get_item_data_row(object $item): array
|
||||
|
||||
$controller = get_controller();
|
||||
|
||||
|
||||
$image = '';
|
||||
if (!empty($item->pic_filename)) {
|
||||
$uploadPath = FCPATH . 'uploads/item_pics/';
|
||||
$ext = pathinfo($item->pic_filename, PATHINFO_EXTENSION);
|
||||
|
||||
// If no extension in filename, search for any file with that name
|
||||
if (empty($ext)) {
|
||||
$pattern = $uploadPath . $item->pic_filename . '.*';
|
||||
} else {
|
||||
$pattern = $uploadPath . $item->pic_filename;
|
||||
$image = null;
|
||||
if (!empty($item->pic_filename)) {
|
||||
$ext = pathinfo($item->pic_filename, PATHINFO_EXTENSION);
|
||||
|
||||
$images = $ext == ''
|
||||
? glob("./uploads/item_pics/$item->pic_filename.*")
|
||||
: glob("./uploads/item_pics/$item->pic_filename");
|
||||
|
||||
if (sizeof($images) > 0) {
|
||||
$image .= '<a class="rollover" href="' . base_url($images[0]) . '"><img alt="Image thumbnail" src="' . site_url('items/PicThumb/' . pathinfo($images[0], PATHINFO_BASENAME)) . '"></a>';
|
||||
}
|
||||
}
|
||||
|
||||
$images = glob($pattern);
|
||||
|
||||
if (!empty($images)) {
|
||||
$relPath = 'uploads/item_pics/' . basename($images[0]);
|
||||
|
||||
// Use direct image path instead of getPicThumb
|
||||
$image = '<a class="rollover" href="' . base_url($relPath) . '">
|
||||
<img src="' . base_url($relPath) . '"
|
||||
onerror="this.src=\''.base_url('public/images/no-img.png').'\';this.onerror=null;"
|
||||
style="max-width:40px;max-height:40px; object-fit: cover;">
|
||||
</a>';
|
||||
} else {
|
||||
$image = '<img src="'.base_url('public/images/no-img.png').'" style="max-width:40px;max-height:40px;">';
|
||||
}
|
||||
} else {
|
||||
$image = '<img src="'.base_url('public/images/no-img.png').'" style="max-width:40px;max-height:40px;">';
|
||||
}
|
||||
|
||||
if ($config['multi_pack_enabled']) {
|
||||
$item->name .= NAME_SEPARATOR . $item->pack_name;
|
||||
|
||||
@@ -146,4 +146,5 @@ return [
|
||||
"used" => "Points Used",
|
||||
"work_orders" => "Work Orders",
|
||||
"zero_and_less" => "Zero and Less",
|
||||
"toggle_cost_and_profit" => "Toggle Cost & Profit",
|
||||
];
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -146,4 +146,5 @@ return [
|
||||
"used" => "Points Used",
|
||||
"work_orders" => "Work Orders",
|
||||
"zero_and_less" => "Zero and less",
|
||||
"toggle_cost_and_profit" => "Toggle Cost & Profit",
|
||||
];
|
||||
|
||||
@@ -341,8 +341,7 @@ class Receiving_lib
|
||||
'price' => $price,
|
||||
'receiving_quantity' => $receivingQuantity,
|
||||
'receiving_quantity_choices' => $receivingQuantityChoices,
|
||||
'total' => $this->get_item_total($quantity, $price, $discount, $discountType, $receivingQuantity),
|
||||
'pic_filename' => $itemInfo->pic_filename,
|
||||
'total' => $this->get_item_total($quantity, $price, $discount, $discountType, $receivingQuantity)
|
||||
]
|
||||
];
|
||||
|
||||
|
||||
@@ -1097,8 +1097,7 @@ class Sale_lib
|
||||
'stock_type' => $stock_type,
|
||||
'item_type' => $item_type,
|
||||
'hsn_code' => $item_info->hsn_code,
|
||||
'tax_category_id' => $item_info->tax_category_id,
|
||||
'pic_filename' => $item_info->pic_filename,
|
||||
'tax_category_id' => $item_info->tax_category_id
|
||||
]
|
||||
];
|
||||
|
||||
|
||||
@@ -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 = $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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ class Giftcard extends Model
|
||||
* @param string $giftcard_number Gift card number
|
||||
* @return int The customer_id of the gift card if it exists, 0 otherwise.
|
||||
*/
|
||||
public function get_giftcard_customer(string $giftcard_number): int
|
||||
public function get_giftcard_customer(string $giftcard_number): int|null
|
||||
{
|
||||
if (!$this->exists($this->get_giftcard_id($giftcard_number))) {
|
||||
return 0;
|
||||
|
||||
@@ -103,7 +103,7 @@ class Sale extends Model
|
||||
/**
|
||||
* Get number of rows for the takings (sales/manage) view
|
||||
*/
|
||||
public function get_found_rows(string $search, array $filters): int
|
||||
public function get_found_rows(?string $search, array $filters): int
|
||||
{
|
||||
return $this->search($search, $filters, 0, 0, 'sales.sale_time', 'desc', true);
|
||||
}
|
||||
@@ -111,7 +111,7 @@ class Sale extends Model
|
||||
/**
|
||||
* Get the sales data for the takings (sales/manage) view
|
||||
*/
|
||||
public function search(string $search, array $filters, ?int $rows = 0, ?int $limit_from = 0, ?string $sort = 'sales.sale_time', ?string $order = 'desc', ?bool $count_only = false)
|
||||
public function search(?string $search, array $filters, ?int $rows = 0, ?int $limit_from = 0, ?string $sort = 'sales.sale_time', ?string $order = 'desc', ?bool $count_only = false)
|
||||
{
|
||||
// Set default values
|
||||
if ($rows == null) $rows = 0;
|
||||
@@ -209,7 +209,7 @@ class Sale extends Model
|
||||
/**
|
||||
* Get the payment summary for the takings (sales/manage) view
|
||||
*/
|
||||
public function get_payments_summary(string $search, array $filters): array
|
||||
public function get_payments_summary(?string $search, array $filters): array
|
||||
{
|
||||
$config = config(OSPOS::class)->settings;
|
||||
|
||||
@@ -311,7 +311,7 @@ class Sale extends Model
|
||||
/**
|
||||
* Gets search suggestions
|
||||
*/
|
||||
public function get_search_suggestions(string $search, int $limit = 25): array // TODO: $limit is never used.
|
||||
public function get_search_suggestions(?string $search, int $limit = 25): array // TODO: $limit is never used.
|
||||
{
|
||||
$suggestions = [];
|
||||
|
||||
@@ -396,7 +396,7 @@ class Sale extends Model
|
||||
/**
|
||||
* Checks if valid receipt
|
||||
*/
|
||||
public function is_valid_receipt(string &$receipt_sale_id): bool // TODO: like the others, maybe this should be an array rather than a delimited string... either that or the parameter name needs to be changed. $receipt_sale_id implies that it's an int.
|
||||
public function is_valid_receipt(string|null &$receipt_sale_id): bool // TODO: like the others, maybe this should be an array rather than a delimited string... either that or the parameter name needs to be changed. $receipt_sale_id implies that it's an int.
|
||||
{
|
||||
$config = config(OSPOS::class)->settings;
|
||||
|
||||
@@ -1449,7 +1449,7 @@ class Sale extends Model
|
||||
* @param BaseBuilder $builder
|
||||
* @return void
|
||||
*/
|
||||
private function add_filters_to_query(string $search, array $filters, BaseBuilder $builder): void
|
||||
private function add_filters_to_query(?string $search, array $filters, BaseBuilder $builder): void
|
||||
{
|
||||
if (!empty($search)) { // TODO: this is duplicated code. We should think about refactoring out a method
|
||||
if ($filters['is_valid_receipt']) {
|
||||
|
||||
@@ -37,8 +37,7 @@ class Token_invoice_sequence extends Token
|
||||
{
|
||||
if (empty($this->value)) {
|
||||
return $this->appconfig->acquire_next_invoice_sequence($save);
|
||||
} else {
|
||||
return $this->value;
|
||||
}
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ class Token_work_order_sequence extends Token
|
||||
*/
|
||||
public function get_value(bool $save = true): string
|
||||
{
|
||||
if ($this->value !== '') {
|
||||
return $this->value;
|
||||
}
|
||||
return $this->appconfig->acquire_next_work_order_sequence($save);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
<?= view('partial/datepicker_locale', ['config' => '{ minView: 2, format: "' . dateformat_bootstrap($config['dateformat'] . '"}')]) ?>
|
||||
<?= view('partial/datepicker_locale', ['format' => dateformat_bootstrap($config['dateformat'])]) ?>
|
||||
|
||||
var enable_delete = function() {
|
||||
$('.remove_attribute_btn').click(function() {
|
||||
|
||||
@@ -116,29 +116,22 @@
|
||||
|
||||
rules: {
|
||||
<?php if ($config['giftcard_number'] == 'series') { ?>
|
||||
person_name: {
|
||||
required: true
|
||||
},
|
||||
giftcard_number: {
|
||||
required: true
|
||||
required: true,
|
||||
number: true,
|
||||
remote: {
|
||||
url: "<?= esc("$controller_name/checkNumberGiftcard") ?>",
|
||||
type: 'POST',
|
||||
data: {
|
||||
'giftcard_number': function() { return $('#giftcard_number').val() },
|
||||
'giftcard_id': '<?= esc($giftcard_id) ?>'
|
||||
}
|
||||
}
|
||||
},
|
||||
<?php } ?>
|
||||
giftcard_amount: {
|
||||
required: true,
|
||||
remote: {
|
||||
url: "<?= esc("$controller_name/checkNumberGiftcard") ?>",
|
||||
type: 'POST',
|
||||
data: {
|
||||
'amount': $('#giftcard_amount').val()
|
||||
},
|
||||
dataFilter: function(data) {
|
||||
var response = JSON.parse(data);
|
||||
if (response.success) {
|
||||
$('#giftcard_amount').val(response.giftcard_amount);
|
||||
}
|
||||
return response.success;
|
||||
}
|
||||
}
|
||||
remote: "<?= esc("$controller_name/checkNumeric") ?>"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -146,7 +139,8 @@
|
||||
<?php if ($config['giftcard_number'] == 'series') { ?>
|
||||
giftcard_number: {
|
||||
required: "<?= lang('Giftcards.number_required') ?>",
|
||||
number: "<?= lang('Giftcards.number') ?>"
|
||||
number: "<?= lang('Giftcards.number') ?>",
|
||||
remote: "<?= lang('Giftcards.number_required') ?>"
|
||||
},
|
||||
<?php } ?>
|
||||
giftcard_amount: {
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
$('#item_kit_items').append('<tr>' +
|
||||
'<td><a href="#" onclick="return delete_item_kit_row(this);"><span class="glyphicon glyphicon-trash"></span></a></td>' +
|
||||
'<td><input class="quantity form-control input-sm" id="item_seq_' + ui.item.value + '" name="item_kit_seq[' + ui.item.value + ']" value="0"></td>' +
|
||||
'<td>' + ui.item.label + '</td>' +
|
||||
'<td>' + DOMPurify.sanitize(ui.item.label) + '</td>' +
|
||||
'<td><input class="quantity form-control input-sm" id="item_qty_' + ui.item.value + '" name="item_kit_qty[' + ui.item.value + ']" value="1"></td>' +
|
||||
'</tr>');
|
||||
}
|
||||
@@ -238,7 +238,7 @@
|
||||
var fill_value = function(event, ui) {
|
||||
event.preventDefault();
|
||||
$("input[name='kit_item_id']").val(ui.item.value);
|
||||
$("input[name='item_name']").val(ui.item.label);
|
||||
$("input[name='item_name']").val(DOMPurify.sanitize(ui.item.label));
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -63,8 +63,7 @@ use App\Models\Employee;
|
||||
onLoadSuccess: function(response) {
|
||||
$('a.rollover').imgPreview({
|
||||
imgCSS: {
|
||||
width: 200,
|
||||
|
||||
width: 200
|
||||
},
|
||||
distanceFromCursor: {
|
||||
top: 10,
|
||||
@@ -125,7 +124,7 @@ use App\Models\Employee;
|
||||
</div>
|
||||
|
||||
<div id="table_holder">
|
||||
<table id="table" style="padding:10px;"></table>
|
||||
<table id="table"></table>
|
||||
</div>
|
||||
|
||||
<?= view('partial/footer') ?>
|
||||
|
||||
@@ -5,7 +5,7 @@ $config = config(OSPOS::class)->settings; ?>
|
||||
|
||||
var pickerconfig = function(config) {
|
||||
return $.extend({
|
||||
format: "<?= dateformat_bootstrap($config['dateformat']) . ' ' . dateformat_bootstrap($config['timeformat'])?>",
|
||||
format: "<?= $this->data["format"] ?? dateformat_bootstrap($config['dateformat']) . ' ' . dateformat_bootstrap($config['timeformat'])?>",
|
||||
<?php
|
||||
$t = $config['timeformat'];
|
||||
$m = $t[strlen($t) - 1];
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
const notify = $.notify;
|
||||
|
||||
$.notify = function(content, options) {
|
||||
const sanitizedContent = DOMPurify.sanitize(content);
|
||||
return notify(sanitizedContent, options);
|
||||
const message = typeof content === "object" ? content.message : content;
|
||||
const sanitizedMessage = DOMPurify.sanitize(message);
|
||||
return notify(sanitizedMessage, options);
|
||||
};
|
||||
|
||||
$.notifyDefaults({
|
||||
@@ -31,10 +32,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
var cookie_name = "<?= esc(config('Cookie')->prefix, 'js') . esc(config('Security')->cookieName, 'js') ?>";
|
||||
|
||||
var csrf_token = function() {
|
||||
return Cookies.get(cookie_name);
|
||||
return "<?= csrf_hash() ?>";
|
||||
};
|
||||
|
||||
var csrf_form_base = function() {
|
||||
|
||||
98
app/Views/partial/visibility_js.php
Normal file
98
app/Views/partial/visibility_js.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php // used in reports ?>
|
||||
// Utility functions for safe localStorage access
|
||||
function safeSetItem(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (e) {
|
||||
console.error(`Failed to set item in localStorage: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeGetItem(key) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (e) {
|
||||
console.error(`Failed to get item from localStorage: ${e.message}`);
|
||||
return null; // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
function safeRemoveItem(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (e) {
|
||||
console.error(`Failed to remove item from localStorage: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved column visibility from localStorage
|
||||
var savedVisibility = JSON.parse(safeGetItem('columnVisibility')) || { cost: false, profit: false };
|
||||
var visibleColumns = savedVisibility;
|
||||
|
||||
// Function to save column visibility to localStorage
|
||||
function saveColumnVisibility(visibility) {
|
||||
safeSetItem('columnVisibility', JSON.stringify(visibility));
|
||||
}
|
||||
|
||||
// Apply column visibility on table initialization
|
||||
function applyColumnVisibility(columns) {
|
||||
return columns.map(function (col) {
|
||||
if (visibleColumns[col.field] !== undefined) {
|
||||
col.visible = visibleColumns[col.field]; // Apply visibility from localStorage
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}
|
||||
|
||||
// Event listener for column visibility toggle
|
||||
$('#table').on('column-switch.bs.table', function (e, field, checked) {
|
||||
visibleColumns[field] = checked; // Save the visibility of this column
|
||||
saveColumnVisibility(visibleColumns); // Store it in localStorage
|
||||
});
|
||||
|
||||
// Ensure that saved column visibility is applied immediately after table load
|
||||
$('#table').bootstrapTable('refreshOptions', {
|
||||
columns: $('#table').bootstrapTable('getOptions').columns // Force refresh to apply column visibility
|
||||
});
|
||||
|
||||
// Initialize visibility settings from localStorage
|
||||
var summaryVisibility = JSON.parse(safeGetItem('summaryVisibility')) || { cost: false, profit: false };
|
||||
|
||||
// Function to apply visibility for cost and profit rows
|
||||
function applySummaryVisibility() {
|
||||
var rows = $('#report_summary .summary_row');
|
||||
var costRow = rows.eq(rows.length - 2); // Second-to-last row
|
||||
var profitRow = rows.eq(rows.length - 1); // Last row
|
||||
|
||||
if (summaryVisibility.cost === false) {
|
||||
costRow.hide(); // Hide the cost row
|
||||
} else {
|
||||
costRow.show(); // Show the cost row
|
||||
}
|
||||
|
||||
if (summaryVisibility.profit === false) {
|
||||
profitRow.hide(); // Hide the profit row
|
||||
} else {
|
||||
profitRow.show(); // Show the profit row
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle visibility when the button is clicked
|
||||
$('#toggleCostProfitButton').click(function () {
|
||||
summaryVisibility.cost = !summaryVisibility.cost;
|
||||
summaryVisibility.profit = !summaryVisibility.profit;
|
||||
|
||||
safeSetItem('summaryVisibility', JSON.stringify(summaryVisibility));
|
||||
applySummaryVisibility();
|
||||
});
|
||||
|
||||
// Apply saved visibility state on page load
|
||||
applySummaryVisibility();
|
||||
|
||||
// Initialize dialog (if editable)
|
||||
var init_dialog = function () {
|
||||
<?php if (isset($editable)): ?>
|
||||
table_support.submit_handler('<?php echo site_url("reports/get_detailed_{$editable}_row") ?>');
|
||||
dialog_support.init("a.modal-dlg");
|
||||
<?php endif; ?>
|
||||
};
|
||||
@@ -27,9 +27,6 @@ if (isset($error_message)) {
|
||||
echo view('partial/print_receipt', ['print_after_sale', $print_after_sale, 'selected_printer' => 'receipt_printer']) ?>
|
||||
|
||||
<div class="print_hide" id="control_buttons" style="text-align: right;">
|
||||
<button type="button" class="btn btn-warning btn-sm receipt-avatar-toggle-btn" id="toggle_avatar_button">
|
||||
<span class="glyphicon glyphicon-picture"> </span><span id="avatar_toggle_text">Hide Avatar</span>
|
||||
</button>
|
||||
<a href="javascript:printdoc();">
|
||||
<div class="btn btn-info btn-sm" id="show_print_button"><?= '<span class="glyphicon glyphicon-print"> </span>' . lang('Common.print') ?></div>
|
||||
</a>
|
||||
@@ -67,49 +64,41 @@ echo view('partial/print_receipt', ['print_after_sale', $print_after_sale, 'sele
|
||||
|
||||
<table id="receipt_items">
|
||||
<tr>
|
||||
<th class="receipt-avatar-column" style="width: 15%;"><?= lang('Items.image') ?></th>
|
||||
<th style="width: 35%;"><?= lang('Items.item') ?></th>
|
||||
<th style="width: 40%;"><?= lang('Items.item') ?></th>
|
||||
<th style="width: 20%;"><?= lang('Common.price') ?></th>
|
||||
<th style="width: 15%;"><?= lang('Sales.quantity') ?></th>
|
||||
<th style="width: 20%;"><?= lang('Sales.quantity') ?></th>
|
||||
<th style="width: 15%; text-align: right;"><?= lang('Sales.total') ?></th>
|
||||
</tr>
|
||||
|
||||
<?php foreach (array_reverse($cart, true) as $line => $item) { ?>
|
||||
<tr>
|
||||
<td class="receipt-avatar-column">
|
||||
|
||||
<?php if (!empty($item['pic_filename'])): ?>
|
||||
<img src="<?= base_url('uploads/item_pics/' . esc($item['pic_filename'], 'url')) ?>" alt="avatar" style="height:40px;max-width:40px;">
|
||||
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= esc($item['name'] . ' ' . $item['attribute_values']) ?></td>
|
||||
<td><?= to_currency($item['price']) ?></td>
|
||||
<td><?= to_quantity_decimals($item['quantity']) . ' ' . ($show_stock_locations ? ' [' . esc($item['stock_name']) . ']' : '') ?> x <?= $item['receiving_quantity'] != 0 ? to_quantity_decimals($item['receiving_quantity']) : 1 ?></td>
|
||||
<td><div class="total-value"><?= to_currency($item['total']) ?></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><?= esc($item['serialnumber']) ?></td>
|
||||
<td><?= esc($item['serialnumber']) ?></td>
|
||||
</tr>
|
||||
<?php if ($item['discount'] > 0) { ?>
|
||||
<tr>
|
||||
<?php if ($item['discount_type'] == FIXED) { ?>
|
||||
<td colspan="4" class="discount"><?= to_currency($item['discount']) . ' ' . lang('Sales.discount') ?></td>
|
||||
<td colspan="3" class="discount"><?= to_currency($item['discount']) . ' ' . lang('Sales.discount') ?></td>
|
||||
<?php } elseif ($item['discount_type'] == PERCENT) { ?>
|
||||
<td colspan="4" class="discount"><?= to_decimals($item['discount']) . ' ' . lang('Sales.discount_included') ?></td>
|
||||
<td colspan="3" class="discount"><?= to_decimals($item['discount']) . ' ' . lang('Sales.discount_included') ?></td>
|
||||
<?php } ?>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
<?php } ?>
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: right; border-top: 2px solid #000000;"><?= lang('Sales.total') ?></td>
|
||||
<td colspan="3" style="text-align: right; border-top: 2px solid #000000;"><?= lang('Sales.total') ?></td>
|
||||
<td style="border-top: 2px solid #000000;">
|
||||
<div class="total-value"><?= to_currency($total) ?></div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php if ($mode != 'requisition') { ?>
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: right;"><?= lang('Sales.payment') ?></td>
|
||||
<td colspan="3" style="text-align: right;"><?= lang('Sales.payment') ?></td>
|
||||
<td>
|
||||
<div class="total-value"><?= esc($payment_type) ?></div>
|
||||
</td>
|
||||
@@ -117,14 +106,14 @@ echo view('partial/print_receipt', ['print_after_sale', $print_after_sale, 'sele
|
||||
|
||||
<?php if (isset($amount_change)) { ?>
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: right;"><?= lang('Sales.amount_tendered') ?></td>
|
||||
<td colspan="3" style="text-align: right;"><?= lang('Sales.amount_tendered') ?></td>
|
||||
<td>
|
||||
<div class="total-value"><?= to_currency($amount_tendered) ?></div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: right;"><?= lang('Sales.change_due') ?></td>
|
||||
<td colspan="3" style="text-align: right;"><?= lang('Sales.change_due') ?></td>
|
||||
<td>
|
||||
<div class="total-value"><?= $amount_change ?></div>
|
||||
</td>
|
||||
@@ -143,41 +132,4 @@ echo view('partial/print_receipt', ['print_after_sale', $print_after_sale, 'sele
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
// Avatar toggle functionality
|
||||
const STORAGE_KEY = 'avatarColumnVisible';
|
||||
|
||||
// Get saved state from localStorage, default to visible (true)
|
||||
let isAvatarVisible = localStorage.getItem(STORAGE_KEY) !== 'false';
|
||||
|
||||
// Apply initial state (also handle header visibility)
|
||||
updateAvatarVisibility(isAvatarVisible);
|
||||
|
||||
// Handle toggle button click
|
||||
$('#toggle_avatar_button').click(function(e) {
|
||||
e.preventDefault();
|
||||
isAvatarVisible = !isAvatarVisible;
|
||||
updateAvatarVisibility(isAvatarVisible);
|
||||
localStorage.setItem(STORAGE_KEY, isAvatarVisible);
|
||||
});
|
||||
|
||||
function updateAvatarVisibility(visible) {
|
||||
const $avatarElements = $('.receipt-avatar-column');
|
||||
const $toggleButton = $('#toggle_avatar_button');
|
||||
const $toggleText = $('#avatar_toggle_text');
|
||||
|
||||
if (visible) {
|
||||
$avatarElements.removeClass('hidden');
|
||||
$toggleButton.removeClass('active');
|
||||
$toggleText.text('Hide Avatar');
|
||||
} else {
|
||||
$avatarElements.addClass('hidden');
|
||||
$toggleButton.addClass('active');
|
||||
$toggleText.text('Show Avatar');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?= view('partial/footer') ?>
|
||||
|
||||
@@ -96,11 +96,6 @@ if (isset($success)) {
|
||||
<span class="glyphicon glyphicon-tag"> </span><?= lang('Sales.new_item') ?>
|
||||
</button>
|
||||
</li>
|
||||
<li class="pull-right">
|
||||
<button type="button" class="btn btn-warning btn-sm receipt-avatar-toggle-btn" id="toggle_avatar_button">
|
||||
<span class="glyphicon glyphicon-picture"> </span><span id="avatar_toggle_text">Hide Avatar</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -111,15 +106,14 @@ if (isset($success)) {
|
||||
<table class="sales_table_100" id="register">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 6%;" class="avatar-header"><?= lang('Items.image') ?></th>
|
||||
<th style="width: 5%;"><?= lang('Common.delete') ?></th>
|
||||
<th style="width: 12%;"><?= lang('Sales.item_number') ?></th>
|
||||
<th style="width: 20%;"><?= lang(ucfirst($controller_name) . '.item_name') ?></th>
|
||||
<th style="width: 15%;"><?= lang('Sales.item_number') ?></th>
|
||||
<th style="width: 23%;"><?= lang(ucfirst($controller_name) . '.item_name') ?></th>
|
||||
<th style="width: 10%;"><?= lang(ucfirst($controller_name) . '.cost') ?></th>
|
||||
<th style="width: 8%;"><?= lang(ucfirst($controller_name) . '.quantity') ?></th>
|
||||
<th style="width: 10%;"><?= lang(ucfirst($controller_name) . '.ship_pack') ?></th>
|
||||
<th style="width: 12%;"><?= lang(ucfirst($controller_name) . '.discount') ?></th>
|
||||
<th style="width: 12%;"><?= lang(ucfirst($controller_name) . '.total') ?></th>
|
||||
<th style="width: 14%;"><?= lang(ucfirst($controller_name) . '.discount') ?></th>
|
||||
<th style="width: 10%;"><?= lang(ucfirst($controller_name) . '.total') ?></th>
|
||||
<th style="width: 5%;"><?= lang(ucfirst($controller_name) . '.update') ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -127,7 +121,7 @@ if (isset($success)) {
|
||||
<tbody id="cart_contents">
|
||||
<?php if (count($cart) == 0) { ?>
|
||||
<tr>
|
||||
<td colspan="10">
|
||||
<td colspan="9">
|
||||
<div class="alert alert-dismissible alert-info"><?= lang('Sales.no_items_in_cart') ?></div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -139,13 +133,6 @@ if (isset($success)) {
|
||||
<?= form_open("$controller_name/editItem/$line", ['class' => 'form-horizontal', 'id' => "cart_$line"]) ?>
|
||||
|
||||
<tr>
|
||||
<td class="avatar-column">
|
||||
|
||||
<?php if (!empty($item['pic_filename'])): ?>
|
||||
<img src="<?= base_url('uploads/item_pics/' . esc($item['pic_filename'], 'url')) ?>" alt="avatar" style="height:40px;max-width:40px;">
|
||||
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= anchor("$controller_name/deleteItem/$line", '<span class="glyphicon glyphicon-trash"></span>') ?></td>
|
||||
<td><?= esc($item['item_number']) ?></td>
|
||||
<td style="text-align: center;">
|
||||
@@ -220,7 +207,7 @@ if (isset($success)) {
|
||||
?>
|
||||
<td style="color: #2F4F4F;"><?= lang('Sales.description_abbrv') . ':' ?></td>
|
||||
<?php } ?>
|
||||
<td colspan="3" style="text-align: left;">
|
||||
<td colspan="2" style="text-align: left;">
|
||||
<?php
|
||||
if ($item['allow_alt_description'] == 1) { // TODO: ===?
|
||||
echo form_input([
|
||||
@@ -551,25 +538,6 @@ if (isset($success)) {
|
||||
$('#cart_' + $(this).attr('data-line')).submit();
|
||||
});
|
||||
|
||||
// Avatar toggle functionality
|
||||
const isAvatarVisible = localStorage.getItem('avatarColumnVisible') !== 'false';
|
||||
if (!isAvatarVisible) {
|
||||
$('.avatar-column').hide();
|
||||
$('.avatar-header').hide();
|
||||
$('#avatar_toggle_text').text('Show Avatar');
|
||||
}
|
||||
|
||||
$('#toggle_avatar_button').click(function(e) {
|
||||
e.preventDefault();
|
||||
const isVisible = $('.avatar-column').is(':visible');
|
||||
$('.avatar-column').toggle();
|
||||
$('.avatar-header').toggle();
|
||||
localStorage.setItem('avatarColumnVisible', !isVisible);
|
||||
$(this).toggleClass('active');
|
||||
var $toggleText = $('#avatar_toggle_text');
|
||||
$toggleText.text(isVisible ? 'Show Avatar' : 'Hide Avatar');
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@
|
||||
|
||||
<div class="ct-chart ct-golden-section" id="chart1"></div>
|
||||
|
||||
<div id="toolbar">
|
||||
<div class="pull-left form-inline" role="toolbar">
|
||||
<!-- Toggle Button -->
|
||||
<button id="toggleCostProfitButton" class="btn btn-default btn-sm print_hide">
|
||||
<?php echo lang('Reports.toggle_cost_and_profit'); ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= view($chart_type) ?>
|
||||
|
||||
<div id="chart_report_summary">
|
||||
@@ -27,4 +36,6 @@
|
||||
<?php } ?>
|
||||
</div>
|
||||
|
||||
<script src="<?= base_url('js/hide_cost_profit.js') ?>"></script>
|
||||
|
||||
<?= view('partial/footer') ?>
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
|
||||
<div id="page_subtitle"><?= esc($subtitle) ?></div>
|
||||
|
||||
<div id="toolbar">
|
||||
<div class="pull-left form-inline" role="toolbar">
|
||||
<button id="toggleCostProfitButton" class="btn btn-default btn-sm print_hide">
|
||||
<?php echo lang('Reports.toggle_cost_and_profit'); ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="table_holder">
|
||||
<table id="table"></table>
|
||||
</div>
|
||||
@@ -27,26 +35,29 @@
|
||||
<?php
|
||||
foreach ($summary_data as $name => $value) {
|
||||
if ($name == "total_quantity") {
|
||||
?>
|
||||
?>
|
||||
<div class="summary_row"><?= lang("Reports.$name") . ": $value" ?></div>
|
||||
<?php } else { ?>
|
||||
<div class="summary_row"><?= lang("Reports.$name") . ': ' . to_currency($value) ?></div>
|
||||
<?php
|
||||
<?php
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$(document).ready(function () {
|
||||
<?= view('partial/bootstrap_tables_locale') ?>
|
||||
<?= view('partial/visibility_js') ?>
|
||||
|
||||
$('#table')
|
||||
.addClass("table-striped")
|
||||
.addClass("table-bordered")
|
||||
.bootstrapTable({
|
||||
columns: <?= transform_headers(esc($headers), true, false) ?>,
|
||||
columns: applyColumnVisibility(<?= transform_headers(esc($headers), true, false) ?>),
|
||||
stickyHeader: true,
|
||||
stickyHeaderOffsetLeft: $('#table').offset().left + 'px',
|
||||
stickyHeaderOffsetRight: $('#table').offset().right + 'px',
|
||||
pageSize: <?= $config['lines_per_page'] ?>,
|
||||
sortable: true,
|
||||
showExport: true,
|
||||
|
||||
@@ -16,6 +16,15 @@
|
||||
|
||||
<div id="page_subtitle"><?= esc($subtitle) ?></div>
|
||||
|
||||
<div id="toolbar">
|
||||
<div class="pull-left form-inline" role="toolbar">
|
||||
<!-- Toggle Button -->
|
||||
<button id="toggleCostProfitButton" class="btn btn-default btn-sm print_hide">
|
||||
<?php echo lang('Reports.toggle_cost_and_profit'); ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="table_holder">
|
||||
<table id="table"></table>
|
||||
</div>
|
||||
@@ -27,14 +36,16 @@
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$(document).ready(function () {
|
||||
<?= view('partial/bootstrap_tables_locale') ?>
|
||||
|
||||
var details_data = <?= json_encode(esc($details_data)) ?>;
|
||||
<?php if ($config['customer_reward_enable'] && !empty($details_data_rewards)) { ?>
|
||||
var details_data_rewards = <?= json_encode(esc($details_data_rewards)) ?>;
|
||||
<?php } ?>
|
||||
var init_dialog = function() {
|
||||
<?= view('partial/visibility_js') ?>
|
||||
|
||||
var init_dialog = function () {
|
||||
<?php if (isset($editable)) { ?>
|
||||
table_support.submit_handler('<?= esc(site_url("reports/get_detailed_$editable" . '_row')) ?>');
|
||||
dialog_support.init("a.modal-dlg");
|
||||
@@ -45,8 +56,10 @@
|
||||
.addClass("table-striped")
|
||||
.addClass("table-bordered")
|
||||
.bootstrapTable({
|
||||
columns: <?= transform_headers(esc($headers['summary']), true) ?>,
|
||||
columns: applyColumnVisibility(<?= transform_headers(esc($headers['summary']), true) ?>),
|
||||
stickyHeader: true,
|
||||
stickyHeaderOffsetLeft: $('#table').offset().left + 'px',
|
||||
stickyHeaderOffsetRight: $('#table').offset().right + 'px',
|
||||
pageSize: <?= $config['lines_per_page'] ?>,
|
||||
pagination: true,
|
||||
sortable: true,
|
||||
@@ -62,19 +75,21 @@
|
||||
escape: true,
|
||||
search: true,
|
||||
onPageChange: init_dialog,
|
||||
onPostBody: function() {
|
||||
onPostBody: function () {
|
||||
dialog_support.init("a.modal-dlg");
|
||||
},
|
||||
onExpandRow: function(index, row, $detail) {
|
||||
onExpandRow: function (index, row, $detail) {
|
||||
$detail.html('<table></table>').find("table").bootstrapTable({
|
||||
columns: <?= transform_headers_readonly(esc($headers['details'])) ?>,
|
||||
data: details_data[(!isNaN(row.id) && row.id) || $(row[0] || row.id).text().replace(/(POS|RECV)\s*/g, '')]
|
||||
data: details_data[(!isNaN(row.id) && row.id) || $(row[0] || row.id).text().replace(
|
||||
/(POS|RECV)\s*/g, '')]
|
||||
});
|
||||
|
||||
<?php if ($config['customer_reward_enable'] && !empty($details_data_rewards)) { ?>
|
||||
$detail.append('<table></table>').find("table").bootstrapTable({
|
||||
columns: <?= transform_headers_readonly(esc($headers['details_rewards'])) ?>,
|
||||
data: details_data_rewards[(!isNaN(row.id) && row.id) || $(row[0] || row.id).text().replace(/(POS|RECV)\s*/g, '')]
|
||||
data: details_data_rewards[(!isNaN(row.id) && row.id) || $(row[0] || row.id).text().replace(
|
||||
/(POS|RECV)\s*/g, '')]
|
||||
});
|
||||
<?php } ?>
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ if (isset($error_message)) {
|
||||
<?php if ($include_hsn): ?>
|
||||
<td style="text-align: center;"><?= esc($item['hsn_code']) ?></td>
|
||||
<?php endif; ?>
|
||||
<td class="item-name"><?= ($item['is_serialized'] || $item['allow_alt_description']) && !empty($item['description']) ? $item['description'] : $item['name'] . ' ' . $item['attribute_values'] ?></td>
|
||||
<td class="item-name"><?= ($item['is_serialized'] || $item['allow_alt_description']) && !empty($item['description']) ? esc($item['description']) : esc($item['name'] . ' ' . $item['attribute_values']) ?></td>
|
||||
<td style="text-align: center;"><?= to_quantity_decimals($item['quantity']) ?></td>
|
||||
<td><?= to_currency($item['price']) ?></td>
|
||||
<td style="height: center;"><?= ($item['discount_type'] == FIXED) ? to_currency($item['discount']) : to_decimals($item['discount']) . '%' ?></td>
|
||||
@@ -155,7 +155,7 @@ if (isset($error_message)) {
|
||||
<?php if ($item['is_serialized']) { ?>
|
||||
<tr class="item-row">
|
||||
<td class="item-description" colspan="<?= $invoice_columns - 1 ?>"></td>
|
||||
<td style="text-align: center;"><?= $item['serialnumber'] // TODO: serialnumber does not match variable naming conventions for this project ?></td>
|
||||
<td style="text-align: center;"><?= esc($item['serialnumber']) // TODO: serialnumber does not match variable naming conventions for this project ?></td>
|
||||
</tr>
|
||||
<?php
|
||||
}
|
||||
@@ -176,7 +176,7 @@ if (isset($error_message)) {
|
||||
<?php foreach ($taxes as $tax_group_index => $tax) { ?>
|
||||
<tr>
|
||||
<td colspan="<?= $invoice_columns - 3 ?>" class="blank"> </td>
|
||||
<td colspan="2" class="total-line"><?= (float)$tax['tax_rate'] . '% ' . $tax['tax_group'] ?></td>
|
||||
<td colspan="2" class="total-line"><?= (float)$tax['tax_rate'] . '% ' . esc($tax['tax_group']) ?></td>
|
||||
<td class="total-value" id="taxes"><?= to_currency_tax($tax['sale_tax_amount']) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
@@ -197,7 +197,7 @@ if (isset($error_message)) {
|
||||
?>
|
||||
<tr>
|
||||
<td colspan="<?= $invoice_columns - 3 ?>" class="blank"> </td>
|
||||
<td colspan="2" class="total-line"><?= $splitpayment[0] ?></td>
|
||||
<td colspan="2" class="total-line"><?= esc($splitpayment[0]) ?></td>
|
||||
<td class="total-value" id="paid"><?= to_currency($payment['payment_amount'] * -1) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
<?php foreach ($taxes as $tax_group_index => $tax) { ?>
|
||||
<tr>
|
||||
<td colspan="<?= $invoice_columns - 3 ?>" class="blank"> </td>
|
||||
<td colspan="2" class="total-line"><?= (float)$tax['tax_rate'] . '% ' . $tax['tax_group'] ?></td>
|
||||
<td colspan="2" class="total-line"><?= (float)$tax['tax_rate'] . '% ' . esc($tax['tax_group']) ?></td>
|
||||
<td id="taxes" class="total-value"><?= to_currency_tax($tax['sale_tax_amount']) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
@@ -148,7 +148,7 @@
|
||||
?>
|
||||
<tr>
|
||||
<td colspan="<?= $invoice_columns - 3 ?>" class="blank"> </td>
|
||||
<td colspan="2" class="total-line"><?= $splitpayment[0] ?></td>
|
||||
<td colspan="2" class="total-line"><?= esc($splitpayment[0]) ?></td>
|
||||
<td class="total-value"><?= to_currency(-$payment['payment_amount']) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
@@ -174,9 +174,9 @@
|
||||
<div id="sale_return_policy">
|
||||
<h5>
|
||||
<span><?= nl2br($config['payment_message']) ?></span>
|
||||
<span><?= lang('Sales.comments') . ': ' . (empty($comments) ? $config['invoice_default_comments'] : $comments) ?></span>
|
||||
<span><?= lang('Sales.comments') . ': ' . (empty($comments) ? esc($config['invoice_default_comments']) : esc($comments)) ?></span>
|
||||
</h5>
|
||||
<?= nl2br($config['return_policy']) ?>
|
||||
<?= nl2br(esc($config['return_policy'])) ?>
|
||||
</div>
|
||||
<div id="barcode">
|
||||
<img alt=<?= '$sale_id' ?> src="data:image/svg+xml;base64,<?= base64_encode($barcode) ?>"><br>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="meta-head"><?= lang('Common.date') ?></td>
|
||||
<td><?= $transaction_date ?></td>
|
||||
<td><?= esc($transaction_date) ?></td>
|
||||
</tr>
|
||||
<?php if ($amount_due > 0) { ?>
|
||||
<tr>
|
||||
@@ -128,7 +128,7 @@
|
||||
<?php foreach ($taxes as $tax_group_index => $tax) { ?>
|
||||
<tr>
|
||||
<td colspan="<?= $quote_columns - 3 ?>" class="blank"> </td>
|
||||
<td colspan="2" class="total-line"><?= (float)$tax['tax_rate'] . '% ' . $tax['tax_group'] ?></td>
|
||||
<td colspan="2" class="total-line"><?= (float)$tax['tax_rate'] . '% ' . esc($tax['tax_group']) ?></td>
|
||||
<td id="taxes" class="total-value"><?= to_currency_tax($tax['sale_tax_amount']) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
@@ -144,7 +144,7 @@
|
||||
<div id="sale_return_policy">
|
||||
<h5>
|
||||
<span><?= nl2br(esc($config['payment_message'])) ?></span>
|
||||
<span><?= lang('Sales.comments') . ': ' . (empty($comments) ? $config['quote_default_comments'] : esc($comments)) ?></span>
|
||||
<span><?= lang('Sales.comments') . ': ' . (empty($comments) ? esc($config['quote_default_comments']) : esc($comments)) ?></span>
|
||||
</h5>
|
||||
<?= nl2br(esc($config['return_policy'])) ?>
|
||||
</div>
|
||||
|
||||
@@ -42,49 +42,9 @@ if (isset($error_message)) {
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
// Avatar toggle functionality
|
||||
const STORAGE_KEY = 'avatarColumnVisible';
|
||||
|
||||
// Get saved state from localStorage, default to visible (true)
|
||||
let isAvatarVisible = localStorage.getItem(STORAGE_KEY) !== 'false';
|
||||
|
||||
// Apply initial state (also handle header visibility)
|
||||
updateAvatarVisibility(isAvatarVisible);
|
||||
|
||||
// Handle toggle button click
|
||||
$('#toggle_avatar_button').click(function(e) {
|
||||
e.preventDefault();
|
||||
isAvatarVisible = !isAvatarVisible;
|
||||
updateAvatarVisibility(isAvatarVisible);
|
||||
localStorage.setItem(STORAGE_KEY, isAvatarVisible);
|
||||
});
|
||||
|
||||
function updateAvatarVisibility(visible) {
|
||||
const $avatarElements = $('.receipt-avatar-column');
|
||||
const $toggleButton = $('#toggle_avatar_button');
|
||||
const $toggleText = $('#avatar_toggle_text');
|
||||
|
||||
if (visible) {
|
||||
$avatarElements.removeClass('hidden');
|
||||
$toggleButton.removeClass('active');
|
||||
$toggleText.text('Hide Avatar');
|
||||
} else {
|
||||
$avatarElements.addClass('hidden');
|
||||
$toggleButton.addClass('active');
|
||||
$toggleText.text('Show Avatar');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?= view('partial/print_receipt', ['print_after_sale' => $print_after_sale, 'selected_printer' => 'receipt_printer']) ?>
|
||||
|
||||
<div class="print_hide" id="control_buttons" style="text-align: right;">
|
||||
<button type="button" class="btn btn-warning btn-sm receipt-avatar-toggle-btn" id="toggle_avatar_button">
|
||||
<span class="glyphicon glyphicon-picture"> </span><span id="avatar_toggle_text">Hide Avatar</span>
|
||||
</button>
|
||||
<a href="javascript:printdoc();">
|
||||
<div class="btn btn-info btn-sm" id="show_print_button"><?= '<span class="glyphicon glyphicon-print"> </span>' . lang('Common.print') ?></div>
|
||||
</a>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<?php } ?>
|
||||
|
||||
<?php if ($config['receipt_show_company_name']) { ?>
|
||||
<div id="company_name"><?= $config['company'] ?></div>
|
||||
<div id="company_name"><?= nl2br(esc($config['company'])) ?></div>
|
||||
<?php } ?>
|
||||
|
||||
<div id="company_address"><?= nl2br(esc($config['address'])) ?></div>
|
||||
@@ -50,22 +50,13 @@
|
||||
</div>
|
||||
|
||||
<table id="receipt_items">
|
||||
<?php
|
||||
// Calculate colspan for totals based on visible columns
|
||||
// Avatar(1) + Description(1) + Price(1) + Quantity(1) = 4 columns before Total
|
||||
// When avatar is hidden via CSS, we still need to account for it in colspan
|
||||
$item_colspan = 4; // avatar + description + price + quantity
|
||||
// Summary rows need to account for tax indicator column when enabled
|
||||
$summary_colspan = $item_colspan + ($config['receipt_show_tax_ind'] ? 1 : 0);
|
||||
?>
|
||||
<tr>
|
||||
<th class="receipt-avatar-column" style="width: 10%;"><?= lang('Items.image') ?></th>
|
||||
<th style="width: 40%;"><?= lang('Sales.description_abbrv') ?></th>
|
||||
<th style="width: 15%;"><?= lang('Sales.price') ?></th>
|
||||
<th style="width: 15%;"><?= lang('Sales.quantity') ?></th>
|
||||
<th style="width: 20%;"><?= lang('Sales.price') ?></th>
|
||||
<th style="width: 20%;"><?= lang('Sales.quantity') ?></th>
|
||||
<th style="width: 20%;" class="total-value"><?= lang('Sales.total') ?></th>
|
||||
<?php if ($config['receipt_show_tax_ind']) { ?>
|
||||
<th style="width: 10%;"></th>
|
||||
<th style="width: 20%;"></th>
|
||||
<?php } ?>
|
||||
</tr>
|
||||
<?php
|
||||
@@ -73,23 +64,17 @@
|
||||
if ($item['print_option'] == PRINT_YES) {
|
||||
?>
|
||||
<tr>
|
||||
<td class="receipt-avatar-column">
|
||||
<?php if (!empty($item['pic_filename'])): ?>
|
||||
<img src="<?= base_url('uploads/item_pics/' . esc($item['pic_filename'], 'url')) ?>" alt="avatar" style="height:40px;max-width:40px;">
|
||||
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= esc(ucfirst($item['name'] . ' ' . $item['attribute_values'])) ?></td>
|
||||
<td><?= to_currency($item['price']) ?></td>
|
||||
<td><?= to_quantity_decimals($item['quantity']) ?></td>
|
||||
<td class="total-value"><?= to_currency($item[($config['receipt_show_total_discount'] ? 'total' : 'discounted_total')]) ?></td>
|
||||
<?php if ($config['receipt_show_tax_ind']) { ?>
|
||||
<td><?= $item['taxed_flag'] ?></td>
|
||||
<td><?= esc($item['taxed_flag']) ?></td>
|
||||
<?php } ?>
|
||||
</tr>
|
||||
<tr>
|
||||
<?php if ($config['receipt_show_description']) { ?>
|
||||
<td colspan="<?= $item_colspan - 1 ?>"><?= esc($item['description']) ?></td>
|
||||
<td colspan="2"><?= esc($item['description']) ?></td>
|
||||
<?php } ?>
|
||||
|
||||
<?php if ($config['receipt_show_serialnumber']) { ?>
|
||||
@@ -99,9 +84,9 @@
|
||||
<?php if ($item['discount'] > 0) { ?>
|
||||
<tr>
|
||||
<?php if ($item['discount_type'] == FIXED) { ?>
|
||||
<td colspan="<?= $item_colspan ?>" class="discount"><?= to_currency($item['discount']) . " " . lang('Sales.discount') ?></td>
|
||||
<td colspan="3" class="discount"><?= to_currency($item['discount']) . " " . lang('Sales.discount') ?></td>
|
||||
<?php } elseif ($item['discount_type'] == PERCENT) { ?>
|
||||
<td colspan="<?= $item_colspan ?>" class="discount"><?= to_decimals($item['discount']) . " " . lang('Sales.discount_included') ?></td>
|
||||
<td colspan="3" class="discount"><?= to_decimals($item['discount']) . " " . lang('Sales.discount_included') ?></td>
|
||||
<?php } ?>
|
||||
<td class="total-value"><?= to_currency($item['discounted_total']) ?></td>
|
||||
</tr>
|
||||
@@ -113,23 +98,23 @@
|
||||
|
||||
<?php if ($config['receipt_show_total_discount'] && $discount > 0) { ?>
|
||||
<tr>
|
||||
<td colspan="<?= $summary_colspan ?>" style="text-align: right;"><?= lang('Sales.sub_total') ?></td>
|
||||
<td style="text-align: right; border-top: 2px solid black;"><?= to_currency($prediscount_subtotal) ?></td>
|
||||
<td colspan="3" style="text-align: right; border-top: 2px solid #000000;"><?= lang('Sales.sub_total') ?></td>
|
||||
<td style="text-align: right; border-top:2px solid #000000;"><?= to_currency($prediscount_subtotal) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="<?= $summary_colspan ?>" class="total-value"><?= lang('Sales.customer_discount') ?>:</td>
|
||||
<td colspan="3" class="total-value"><?= lang('Sales.customer_discount') ?>:</td>
|
||||
<td class="total-value"><?= to_currency($discount * -1) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
|
||||
<?php if ($config['receipt_show_taxes']) { ?>
|
||||
<tr>
|
||||
<td colspan="<?= $summary_colspan ?>" style="text-align: right;"><?= lang('Sales.sub_total') ?></td>
|
||||
<td style="text-align: right;"><?= to_currency($subtotal) ?></td>
|
||||
<td colspan="3" style="text-align: right; border-top: 2px solid #000000;"><?= lang('Sales.sub_total') ?></td>
|
||||
<td style="text-align: right; border-top: 2px solid #000000;"><?= to_currency($subtotal) ?></td>
|
||||
</tr>
|
||||
<?php foreach ($taxes as $tax_group_index => $tax) { ?>
|
||||
<tr>
|
||||
<td colspan="<?= $summary_colspan ?>" class="total-value"><?= (float)$tax['tax_rate'] . '% ' . $tax['tax_group'] ?>:</td>
|
||||
<td colspan="3" class="total-value"><?= (float)$tax['tax_rate'] . '% ' . esc($tax['tax_group']) ?>:</td>
|
||||
<td class="total-value"><?= to_currency_tax($tax['sale_tax_amount']) ?></td>
|
||||
</tr>
|
||||
<?php
|
||||
@@ -137,14 +122,16 @@
|
||||
}
|
||||
?>
|
||||
|
||||
<tr></tr>
|
||||
|
||||
<?php $border = (!$config['receipt_show_taxes'] && !($config['receipt_show_total_discount'] && $discount > 0)); ?>
|
||||
<tr>
|
||||
<td colspan="<?= $summary_colspan ?>" style="text-align: right;<?= $border ? ' border-top: 2px solid black;' : '' ?>"><?= lang('Sales.total') ?></td>
|
||||
<td colspan="3" style="text-align: right;<?= $border ? ' border-top: 2px solid black;' : '' ?>"><?= lang('Sales.total') ?></td>
|
||||
<td style="text-align: right;<?= $border ? ' border-top: 2px solid black;' : '' ?>"><?= to_currency($total) ?></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colspan="<?= $summary_colspan + 1 ?>"> </td>
|
||||
<td colspan="4"> </td>
|
||||
</tr>
|
||||
|
||||
<?php
|
||||
@@ -156,27 +143,29 @@
|
||||
$show_giftcard_remainder |= $splitpayment[0] == lang('Sales.giftcard');
|
||||
?>
|
||||
<tr>
|
||||
<td colspan="<?= $summary_colspan ?>" style="text-align: right;"><?= $splitpayment[0] ?> </td>
|
||||
<td colspan="3" style="text-align: right;"><?= esc($splitpayment[0]) ?> </td>
|
||||
<td class="total-value"><?= to_currency($payment['payment_amount'] * -1) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
|
||||
|
||||
<tr>
|
||||
<td colspan="4"> </td>
|
||||
</tr>
|
||||
|
||||
<?php if (isset($cur_giftcard_value) && $show_giftcard_remainder) { ?>
|
||||
<tr>
|
||||
<td colspan="<?= $summary_colspan ?>" style="text-align: right;"><?= lang('Sales.giftcard_balance') ?></td>
|
||||
<td colspan="3" style="text-align: right;"><?= lang('Sales.giftcard_balance') ?></td>
|
||||
<td class="total-value"><?= to_currency($cur_giftcard_value) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
<tr>
|
||||
<td colspan="<?= $summary_colspan ?>" style="text-align: right;"> <?= lang($amount_change >= 0 ? ($only_sale_check ? 'Sales.check_balance' : 'Sales.change_due') : 'Sales.amount_due') ?> </td>
|
||||
<td colspan="3" style="text-align: right;"> <?= lang($amount_change >= 0 ? ($only_sale_check ? 'Sales.check_balance' : 'Sales.change_due') : 'Sales.amount_due') ?> </td>
|
||||
<td class="total-value"><?= to_currency($amount_change) ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div id="sale_return_policy">
|
||||
<?= nl2br($config['return_policy']) ?>
|
||||
<?= nl2br(esc($config['return_policy'])) ?>
|
||||
</div>
|
||||
|
||||
<div id="barcode">
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
$show_giftcard_remainder |= $splitpayment[0] == lang('Sales.giftcard');
|
||||
?>
|
||||
<tr>
|
||||
<td colspan="3" style="text-align: right;"><?= $splitpayment[0] ?> </td>
|
||||
<td colspan="3" style="text-align: right;"><?= esc($splitpayment[0]) ?> </td>
|
||||
<td style="text-align: right;"><?= to_currency($payment['payment_amount'] * -1) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
</tr>
|
||||
<?php foreach ($taxes as $tax_group_index => $tax) { ?>
|
||||
<tr>
|
||||
<td colspan="2" class="total-value"><?= (float)$tax['tax_rate'] . '% ' . $tax['tax_group'] ?>:</td>
|
||||
<td colspan="2" class="total-value"><?= (float)$tax['tax_rate'] . '% ' . esc($tax['tax_group']) ?>:</td>
|
||||
<td class="total-value"><?= to_currency_tax($tax['sale_tax_amount']) ?></td>
|
||||
</tr>
|
||||
<?php
|
||||
|
||||
@@ -89,17 +89,13 @@ helper('url');
|
||||
<?= form_dropdown('stock_location', $stock_locations, $stock_location, ['onchange' => "$('#mode_form').submit();", 'class' => 'selectpicker show-menu-arrow', 'data-style' => 'btn-default btn-sm', 'data-width' => 'fit']) ?>
|
||||
</li>
|
||||
<?php } ?>
|
||||
|
||||
<li class="pull-right">
|
||||
<button class="btn btn-default btn-sm modal-dlg" id="show_suspended_sales_button" data-href="<?= esc("$controller_name/suspended") ?>"
|
||||
title="<?= lang(ucfirst($controller_name) . '.suspended_sales') ?>">
|
||||
<span class="glyphicon glyphicon-align-justify"> </span><?= lang(ucfirst($controller_name) . '.suspended_sales') ?>
|
||||
</button>
|
||||
</li>
|
||||
<li class="pull-right">
|
||||
<button type="button" class="btn btn-warning btn-sm receipt-avatar-toggle-btn" id="toggle_avatar_button">
|
||||
<span class="glyphicon glyphicon-picture"> </span><span id="avatar_toggle_text">Hide Avatar</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<?php
|
||||
$employee = model(Employee::class);
|
||||
@@ -144,13 +140,12 @@ helper('url');
|
||||
<table class="sales_table_100" id="register">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="avatar-header" style="width: 8%;" ><?= lang('Items.image') ?></th>
|
||||
<th style="width: 5%;"><?= lang('Common.delete') ?></th>
|
||||
<th style="width: 12%;"><?= lang(ucfirst($controller_name) . '.item_number') ?></th>
|
||||
<th style="width: 27%;"><?= lang(ucfirst($controller_name) . '.item_name') ?></th>
|
||||
<th style="width: 15%;"><?= lang(ucfirst($controller_name) . '.item_number') ?></th>
|
||||
<th style="width: 30%;"><?= lang(ucfirst($controller_name) . '.item_name') ?></th>
|
||||
<th style="width: 10%;"><?= lang(ucfirst($controller_name) . '.price') ?></th>
|
||||
<th style="width: 10%;"><?= lang(ucfirst($controller_name) . '.quantity') ?></th>
|
||||
<th style="width: 13%;"><?= lang(ucfirst($controller_name) . '.discount') ?></th>
|
||||
<th style="width: 15%;"><?= lang(ucfirst($controller_name) . '.discount') ?></th>
|
||||
<th style="width: 10%;"><?= lang(ucfirst($controller_name) . '.total') ?></th>
|
||||
<th style="width: 5%;"><?= lang(ucfirst($controller_name) . '.update') ?></th>
|
||||
</tr>
|
||||
@@ -159,7 +154,7 @@ helper('url');
|
||||
<tbody id="cart_contents">
|
||||
<?php if (count($cart) == 0) { ?>
|
||||
<tr>
|
||||
<td colspan="9">
|
||||
<td colspan="8">
|
||||
<div class="alert alert-dismissible alert-info"><?= lang(ucfirst($controller_name) . '.no_items_in_cart') ?></div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -169,11 +164,6 @@ helper('url');
|
||||
?>
|
||||
<?= form_open("$controller_name/editItem/$line", ['class' => 'form-horizontal', 'id' => "cart_$line"]) ?>
|
||||
<tr>
|
||||
<td class="avatar-column">
|
||||
<?php if (!empty($item['pic_filename'])): ?>
|
||||
<img src="<?= base_url('uploads/item_pics/' . esc($item['pic_filename'], 'url')) ?>" alt="avatar" style="height:40px;max-width:40px;">
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
echo anchor("$controller_name/deleteItem/$line", '<span class="glyphicon glyphicon-trash"></span>');
|
||||
@@ -575,34 +565,14 @@ helper('url');
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
// Initialize avatar column visibility from localStorage or default to visible
|
||||
const isAvatarVisible = localStorage.getItem('avatarColumnVisible') !== 'false';
|
||||
if (!isAvatarVisible) {
|
||||
$('.avatar-column').hide();
|
||||
$('.avatar-header').hide();
|
||||
$('#avatar_toggle_text').text('Show Avatar');
|
||||
}
|
||||
$(document).ready(function() {
|
||||
const redirect = function() {
|
||||
window.location.href = "<?= site_url('sales'); ?>";
|
||||
};
|
||||
|
||||
// Handle avatar toggle button click
|
||||
$('#toggle_avatar_button').click(function(e) {
|
||||
e.preventDefault();
|
||||
const isVisible = $('.avatar-column').is(':visible');
|
||||
$('.avatar-column').toggle();
|
||||
$('.avatar-header').toggle();
|
||||
localStorage.setItem('avatarColumnVisible', !isVisible);
|
||||
$(this).toggleClass('active');
|
||||
var $toggleText = $('#avatar_toggle_text');
|
||||
$toggleText.text(isVisible ? 'Show Avatar' : 'Hide Avatar');
|
||||
});
|
||||
|
||||
const redirect = function() {
|
||||
window.location.href = "<?= site_url('sales'); ?>";
|
||||
};
|
||||
|
||||
$("#remove_customer_button").click(function() {
|
||||
$.post("<?= site_url('sales/removeCustomer'); ?>", redirect);
|
||||
});
|
||||
$("#remove_customer_button").click(function() {
|
||||
$.post("<?= site_url('sales/removeCustomer'); ?>", redirect);
|
||||
});
|
||||
|
||||
$(".delete_item_button").click(function() {
|
||||
const item_id = $(this).data('item-id');
|
||||
|
||||
@@ -156,7 +156,7 @@ if (isset($error_message)) {
|
||||
<?php foreach ($taxes as $tax_group_index => $tax) { ?>
|
||||
<tr>
|
||||
<td colspan="3" class="blank"> </td>
|
||||
<td colspan="2" class="total-line"><?= (float)$tax['tax_rate'] . '% ' . $tax['tax_group'] ?></td>
|
||||
<td colspan="2" class="total-line"><?= (float)$tax['tax_rate'] . '% ' . esc($tax['tax_group']) ?></td>
|
||||
<td class="total-value" id="taxes"><?= to_currency_tax($tax['sale_tax_amount']) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
@@ -176,7 +176,7 @@ if (isset($error_message)) {
|
||||
?>
|
||||
<tr>
|
||||
<td colspan="3" class="blank"> </td>
|
||||
<td colspan="2" class="total-line"><?= $splitpayment[0] ?></td>
|
||||
<td colspan="2" class="total-line"><?= esc($splitpayment[0]) ?></td>
|
||||
<td class="total-value" id="paid"><?= to_currency($payment['payment_amount']) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
|
||||
@@ -130,7 +130,6 @@ gulp.task('debug-js', function() {
|
||||
'./node_modules/chartist-plugin-axistitle/dist/chartist-plugin-axistitle.js',
|
||||
'./node_modules/chartist-plugin-barlabels/dist/chartist-plugin-barlabels.js',
|
||||
'./node_modules/bootstrap-notify/bootstrap-notify.js',
|
||||
'./node_modules/js-cookie/src/js.cookie.js',
|
||||
'./node_modules/bootstrap-tagsinput-2021/dist/bootstrap-tagsinput.js',
|
||||
'./node_modules/bootstrap-toggle/js/bootstrap-toggle.js',
|
||||
'./node_modules/clipboard/dist/clipboard.js',
|
||||
@@ -176,7 +175,6 @@ gulp.task('prod-js', function() {
|
||||
'./node_modules/tableexport.jquery.plugin/tableExport.min.js'], { allowEmpty: true });
|
||||
|
||||
var opensourcepos2js = gulp.src(['./node_modules/bootstrap-daterangepicker/daterangepicker.js',
|
||||
'./node_modules/js-cookie/src/js.cookie.js',
|
||||
'./public/js/imgpreview.full.jquery.js',
|
||||
'./public/js/manage_tables.js',
|
||||
'./public/js/nominatim.autocomplete.js']).pipe(uglify());
|
||||
@@ -293,7 +291,6 @@ gulp.task('build-database', function() {
|
||||
// Run all required tasks
|
||||
gulp.task('default',
|
||||
gulp.series('clean',
|
||||
'update-licenses',
|
||||
'copy-bootswatch',
|
||||
'copy-bootswatch5',
|
||||
'copy-bootstrap',
|
||||
|
||||
57
package-lock.json
generated
57
package-lock.json
generated
@@ -38,9 +38,8 @@
|
||||
"jquery-form": "^4.3.0",
|
||||
"jquery-ui-dist": "^1.12.1",
|
||||
"jquery-validation": "^1.19.5",
|
||||
"js-cookie": "^2.2.1",
|
||||
"jspdf": "^3.0.2",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"jspdf": "^4.1.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"tableexport.jquery.plugin": "^1.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -65,13 +64,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -1487,9 +1483,9 @@
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
|
||||
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -3724,11 +3720,6 @@
|
||||
"jquery": "^1.7 || ^2.0 || ^3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
|
||||
"integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ=="
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@@ -3737,29 +3728,29 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.2.tgz",
|
||||
"integrity": "sha512-G0fQDJ5fAm6UW78HG6lNXyq09l0PrA1rpNY5i+ly17Zb1fMMFSmS+3lw4cnrAPGyouv2Y0ylujbY2Ieq3DSlKA==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz",
|
||||
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf-autotable": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.2.tgz",
|
||||
"integrity": "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==",
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz",
|
||||
"integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"jspdf": "^2 || ^3"
|
||||
"jspdf": "^2 || ^3 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
@@ -3892,10 +3883,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash._basecopy": {
|
||||
"version": "3.0.1",
|
||||
@@ -4660,11 +4652,6 @@
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz",
|
||||
|
||||
@@ -59,9 +59,8 @@
|
||||
"jquery-form": "^4.3.0",
|
||||
"jquery-ui-dist": "^1.12.1",
|
||||
"jquery-validation": "^1.19.5",
|
||||
"js-cookie": "^2.2.1",
|
||||
"jspdf": "^3.0.2",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"jspdf": "^4.1.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"tableexport.jquery.plugin": "^1.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -31,50 +31,17 @@
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
#receipt_items td {
|
||||
position: relative;
|
||||
padding: 1px 0px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
padding: 3px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
#receipt_items th {
|
||||
padding: 1px 0px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#receipt_items tr {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Remove all spacing between specific columns */
|
||||
#receipt_items td:nth-child(2), /* Description column */
|
||||
#receipt_items th:nth-child(2) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
#receipt_items td:nth-child(3), /* Price column */
|
||||
#receipt_items th:nth-child(3) {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
#receipt_items td:nth-child(4), /* Quantity column */
|
||||
#receipt_items th:nth-child(4) {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
#receipt_items td:nth-child(5), /* Total column */
|
||||
#receipt_items th:nth-child(5) {
|
||||
padding-left: 0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#sale_return_policy {
|
||||
@@ -95,40 +62,3 @@
|
||||
.discount {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Avatar toggle functionality */
|
||||
.receipt-avatar-column {
|
||||
display: table-cell;
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.receipt-avatar-column.hidden {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
/* When avatar column is hidden, redistribute the width */
|
||||
.receipt-avatar-column.hidden ~ th:nth-child(2) {
|
||||
width: 50% !important; /* Description gets more space */
|
||||
}
|
||||
|
||||
.receipt-avatar-column.hidden ~ th:nth-child(3) {
|
||||
width: 17% !important; /* Price */
|
||||
}
|
||||
|
||||
.receipt-avatar-column.hidden ~ th:nth-child(4) {
|
||||
width: 17% !important; /* Quantity */
|
||||
}
|
||||
|
||||
.receipt-avatar-column.hidden ~ th:nth-child(5) {
|
||||
width: 16% !important; /* Total */
|
||||
}
|
||||
|
||||
.receipt-avatar-toggle-btn {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.receipt-avatar-toggle-btn.active {
|
||||
background-color: #d9534f;
|
||||
border-color: #d43f3a;
|
||||
}
|
||||
|
||||
46
public/js/hide_cost_profit.js
Normal file
46
public/js/hide_cost_profit.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* public/js/hide_cost_profit.js
|
||||
* toggle cost and profit in graphical report.
|
||||
*/
|
||||
$(function () {
|
||||
const safeSetItem = (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (e) {
|
||||
console.error("Storage error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const safeGetItem = (key) => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
let summaryVisibility = JSON.parse(safeGetItem("summaryVisibility")) || {
|
||||
cost: false,
|
||||
profit: false,
|
||||
};
|
||||
|
||||
function applySummaryVisibility() {
|
||||
const rows = $("#chart_report_summary .summary_row");
|
||||
if (rows.length < 2) return; // Prevent errors if data is missing
|
||||
|
||||
const costRow = rows.eq(rows.length - 2);
|
||||
const profitRow = rows.eq(rows.length - 1);
|
||||
|
||||
summaryVisibility.cost ? costRow.show() : costRow.hide();
|
||||
summaryVisibility.profit ? profitRow.show() : profitRow.hide();
|
||||
}
|
||||
|
||||
$("#toggleCostProfitButton").on("click", function () {
|
||||
summaryVisibility.cost = !summaryVisibility.cost;
|
||||
summaryVisibility.profit = !summaryVisibility.profit;
|
||||
safeSetItem("summaryVisibility", JSON.stringify(summaryVisibility));
|
||||
applySummaryVisibility();
|
||||
});
|
||||
|
||||
applySummaryVisibility();
|
||||
});
|
||||
160
scripts/install-ubuntu.sh
Normal file
160
scripts/install-ubuntu.sh
Normal 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 ""
|
||||
981
tests/Controllers/ItemsCsvImportTest.php
Normal file
981
tests/Controllers/ItemsCsvImportTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
127
tests/Helpers/AttributeHelperTest.php
Normal file
127
tests/Helpers/AttributeHelperTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
88
tests/Helpers/ImportFileHelperTest.php
Normal file
88
tests/Helpers/ImportFileHelperTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
823
tests/Models/AttributeTest.php
Normal file
823
tests/Models/AttributeTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user