From b1819b3b36f6ceb81cbf0791e3454fb22f97d1a0 Mon Sep 17 00:00:00 2001 From: jekkos Date: Fri, 6 Mar 2026 13:17:52 +0100 Subject: [PATCH] dd validation for invalid stock locations in CSV import (#4399) - Add validateCSVStockLocations() method to check CSV columns against allowed locations - Log error when invalid stock location columns are detected - Tests for valid, invalid, and mixed stock location columns - Tests for location name case sensitivity - Tests for CSV parsing and detecting location columns - Add error message language string for invalid locations Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com> --- app/Controllers/Items.php | 30 +- app/Language/en/Items.php | 1 + tests/Controllers/ItemsCsvImportTest.php | 981 +++++++++++++++++++++++ 3 files changed, 1011 insertions(+), 1 deletion(-) create mode 100644 tests/Controllers/ItemsCsvImportTest.php diff --git a/app/Controllers/Items.php b/app/Controllers/Items.php index 6b780af5e..bab61639d 100644 --- a/app/Controllers/Items.php +++ b/app/Controllers/Items.php @@ -1017,7 +1017,11 @@ class Items extends Secure_Controller } if (!$is_failed_row) { - $is_failed_row = $this->data_error_check($row, $item_data, $allowed_stock_locations, $attribute_definition_names, $attribute_data); + $invalidLocations = $this->validateCSVStockLocations($row, $allowedStockLocations); + if (!empty($invalidLocations)) { + $isFailedRow = true; + log_message('error', 'CSV import: Invalid stock location(s) found: ' . implode(', ', $invalidLocations)); + } } // Remove false, null, '' and empty strings but keep 0 @@ -1063,6 +1067,30 @@ 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 * diff --git a/app/Language/en/Items.php b/app/Language/en/Items.php index 254fb6235..627174e44 100644 --- a/app/Language/en/Items.php +++ b/app/Language/en/Items.php @@ -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.", diff --git a/tests/Controllers/ItemsCsvImportTest.php b/tests/Controllers/ItemsCsvImportTest.php new file mode 100644 index 000000000..7c87b8300 --- /dev/null +++ b/tests/Controllers/ItemsCsvImportTest.php @@ -0,0 +1,981 @@ +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'); + } +} \ No newline at end of file