mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-05-25 08:44:42 -04:00
Compare commits
6 Commits
feature/ap
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12146275f4 | ||
|
|
e45af91e2e | ||
|
|
31d25e06dc | ||
|
|
b1819b3b36 | ||
|
|
6705420373 | ||
|
|
d6b767c80a |
295
API.md
295
API.md
@@ -1,295 +0,0 @@
|
||||
# OSPOS REST API Design
|
||||
|
||||
This document describes the proposed REST API for Open Source Point of Sale (OSPOS).
|
||||
|
||||
## Overview
|
||||
|
||||
The OSPOS REST API provides programmatic access to:
|
||||
|
||||
- **Customers** - Full CRUD operations
|
||||
- **Suppliers** - Full CRUD operations
|
||||
- **Items** - Full CRUD operations
|
||||
- **Inventory** - Stock adjustments (update only)
|
||||
- **Sales** - Read-only queries
|
||||
- **Receivings** - Read-only queries
|
||||
|
||||
## Authentication
|
||||
|
||||
All API endpoints require authentication via an API Key passed in the `X-API-Key` header.
|
||||
|
||||
```
|
||||
X-API-Key: your-api-key-here
|
||||
```
|
||||
|
||||
> **Note:** API Key authentication implementation will be added in a subsequent phase. The spec documents the intended authentication mechanism.
|
||||
|
||||
## Base URL
|
||||
|
||||
All API endpoints are relative to `/api/v1`.
|
||||
|
||||
```
|
||||
https://your-domain.com/api/v1/customers
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
List endpoints support pagination using `offset` and `limit` query parameters:
|
||||
|
||||
| Parameter | Type | Default | Maximum | Description |
|
||||
|-----------|---------|---------|---------|------------------------------|
|
||||
| `offset` | integer | 0 | - | Number of records to skip |
|
||||
| `limit` | integer | 25 | 100 | Number of records to return |
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/v1/customers?offset=0&limit=25
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 150,
|
||||
"offset": 0,
|
||||
"limit": 25,
|
||||
"rows": [
|
||||
{ "person_id": 1, "first_name": "John", ... },
|
||||
{ "person_id": 2, "first_name": "Jane", ... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
### Success Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Customer created successfully",
|
||||
"id": 42
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Error description here"
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
| Status Code | Description |
|
||||
|-------------|--------------------------------------------------|
|
||||
| 200 | Success |
|
||||
| 201 | Resource created successfully |
|
||||
| 400 | Bad request / Invalid input |
|
||||
| 401 | Unauthorized / Invalid API key |
|
||||
| 404 | Resource not found |
|
||||
| 409 | Conflict (e.g., duplicate unique field) |
|
||||
| 500 | Internal server error |
|
||||
|
||||
## Endpoints Summary
|
||||
|
||||
### Customers
|
||||
|
||||
| Method | Endpoint | Description | Access |
|
||||
|--------|-------------------------------|--------------------------------|---------|
|
||||
| GET | `/customers` | List customers | Read |
|
||||
| POST | `/customers` | Create customer | Write |
|
||||
| GET | `/customers/{id}` | Get customer by ID | Read |
|
||||
| PUT | `/customers/{id}` | Update customer | Write |
|
||||
| DELETE | `/customers/{id}` | Delete customer (soft delete) | Write |
|
||||
| POST | `/customers/batch-delete` | Delete multiple customers | Write |
|
||||
| GET | `/customers/suggest` | Autocomplete suggestions | Read |
|
||||
|
||||
### Suppliers
|
||||
|
||||
| Method | Endpoint | Description | Access |
|
||||
|--------|-------------------------------|--------------------------------|---------|
|
||||
| GET | `/suppliers` | List suppliers | Read |
|
||||
| POST | `/suppliers` | Create supplier | Write |
|
||||
| GET | `/suppliers/{id}` | Get supplier by ID | Read |
|
||||
| PUT | `/suppliers/{id}` | Update supplier | Write |
|
||||
| DELETE | `/suppliers/{id}` | Delete supplier (soft delete) | Write |
|
||||
| POST | `/suppliers/batch-delete` | Delete multiple suppliers | Write |
|
||||
| GET | `/suppliers/suggest` | Autocomplete suggestions | Read |
|
||||
|
||||
### Items
|
||||
|
||||
| Method | Endpoint | Description | Access |
|
||||
|--------|-------------------------------|--------------------------------|---------|
|
||||
| GET | `/items` | List items | Read |
|
||||
| POST | `/items` | Create item | Write |
|
||||
| GET | `/items/{id}` | Get item by ID | Read |
|
||||
| PUT | `/items/{id}` | Update item | Write |
|
||||
| DELETE | `/items/{id}` | Delete item (soft delete) | Write |
|
||||
| POST | `/items/batch-delete` | Delete multiple items | Write |
|
||||
| POST | `/items/batch-update` | Update multiple items | Write |
|
||||
| GET | `/items/suggest` | Autocomplete suggestions | Read |
|
||||
| GET | `/items/{id}/quantities` | Get stock quantities | Read |
|
||||
|
||||
### Inventory
|
||||
|
||||
| Method | Endpoint | Description | Access |
|
||||
|--------|-------------------------------|--------------------------------|---------|
|
||||
| GET | `/inventory` | List inventory transactions | Read |
|
||||
| POST | `/inventory` | Create inventory adjustment | Write |
|
||||
| POST | `/inventory/bulk` | Bulk inventory adjustments | Write |
|
||||
|
||||
### Sales (Read-Only)
|
||||
|
||||
| Method | Endpoint | Description | Access |
|
||||
|--------|-------------------------------|--------------------------------|---------|
|
||||
| GET | `/sales` | List sales | Read |
|
||||
| GET | `/sales/{id}` | Get sale details | Read |
|
||||
| GET | `/sales/{id}/items` | Get sale items | Read |
|
||||
| GET | `/sales/{id}/payments` | Get sale payments | Read |
|
||||
|
||||
### Receivings (Read-Only)
|
||||
|
||||
| Method | Endpoint | Description | Access |
|
||||
|--------|-------------------------------|--------------------------------|---------|
|
||||
| GET | `/receivings` | List receivings | Read |
|
||||
| GET | `/receivings/{id}` | Get receiving details | Read |
|
||||
| GET | `/receivings/{id}/items` | Get receiving items | Read |
|
||||
|
||||
## Schema Reference
|
||||
|
||||
### Common Fields
|
||||
|
||||
#### Person Fields (base for Customer, Supplier)
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------|-----------|------------------------------|
|
||||
| `first_name` | string | First name (required) |
|
||||
| `last_name` | string | Last name (required) |
|
||||
| `gender` | integer | Gender (0=male, 1=female) |
|
||||
| `phone_number`| string | Phone number |
|
||||
| `email` | string | Email address |
|
||||
| `address_1` | string | Address line 1 |
|
||||
| `address_2` | string | Address line 2 |
|
||||
| `city` | string | City |
|
||||
| `state` | string | State/Province |
|
||||
| `zip` | string | Postal/ZIP code |
|
||||
| `country` | string | Country |
|
||||
| `comments` | string | Additional notes |
|
||||
|
||||
### Customer Fields
|
||||
|
||||
Extends Person fields with:
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------------|-----------|------------------------------------|
|
||||
| `person_id` | integer | Unique identifier (read-only) |
|
||||
| `account_number` | string | Customer account number |
|
||||
| `taxable` | integer | Taxable status (0/1) |
|
||||
| `tax_id` | string | Tax identification number |
|
||||
| `sales_tax_code_id`| integer | Sales tax code ID |
|
||||
| `discount` | decimal | Discount percentage/amount |
|
||||
| `discount_type` | integer | Discount type (0=percent, 1=fixed) |
|
||||
| `company_name` | string | Company name |
|
||||
| `package_id` | integer | Rewards package ID |
|
||||
| `points` | integer | Rewards points balance |
|
||||
| `consent` | integer | Consent status (0/1) |
|
||||
|
||||
### Supplier Fields
|
||||
|
||||
Extends Person fields with:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------------|-----------|-------------------------------------|
|
||||
| `person_id` | integer | Unique identifier (read-only) |
|
||||
| `company_name` | string | Company name |
|
||||
| `account_number`| string | Supplier account number |
|
||||
| `tax_id` | string | Tax identification number |
|
||||
| `agency_name` | string | Agency name |
|
||||
| `category` | integer | Category (0=goods, 1=cost) |
|
||||
|
||||
### Item Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------------------|-----------|----------|--------------------------------------|
|
||||
| `item_id` | integer | auto | Unique identifier (read-only) |
|
||||
| `name` | string | yes | Item name |
|
||||
| `category` | string | yes | Item category |
|
||||
| `supplier_id` | integer | no | Supplier ID |
|
||||
| `item_number` | string | no | Barcode/SKU |
|
||||
| `description` | string | no | Item description |
|
||||
| `cost_price` | decimal | no | Cost price |
|
||||
| `unit_price` | decimal | yes | Selling price |
|
||||
| `reorder_level` | decimal | no | Reorder threshold |
|
||||
| `receiving_quantity` | decimal | no | Receiving quantity (default 1) |
|
||||
| `allow_alt_description`| integer | no | Allow alt description (0/1) |
|
||||
| `is_serialized` | integer | no | Has serial number (0/1) |
|
||||
| `stock_type` | integer | no | Stock type (0=stocked, 1=non-stocked)|
|
||||
| `item_type` | integer | no | Item type (0=standard, 1=kit, 2=temp)|
|
||||
| `tax_category_id` | integer | no | Tax category ID |
|
||||
| `qty_per_pack` | decimal | no | Quantity per pack |
|
||||
| `pack_name` | string | no | Pack name |
|
||||
| `hsn_code` | string | no | HSN code |
|
||||
|
||||
### Inventory Adjustment
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|-----------|----------|--------------------------------------|
|
||||
| `item_id` | integer | yes | Item ID to adjust |
|
||||
| `trans_inventory`| decimal | yes | Quantity change (+ add, - remove) |
|
||||
| `trans_location` | integer | no | Stock location ID |
|
||||
| `trans_comment` | string | no | Reason for adjustment |
|
||||
|
||||
## OpenAPI Specification
|
||||
|
||||
The complete OpenAPI 3.1.0 specification is available at:
|
||||
|
||||
- **YAML format:** `/public/api/openapi.yaml`
|
||||
|
||||
This specification can be used with:
|
||||
- [Swagger UI](https://swagger.io/tools/swagger-ui/) for interactive documentation
|
||||
- [Swagger Codegen](https://swagger.io/tools/swagger-codegen/) to generate client SDKs
|
||||
- [OpenAPI Generator](https://openapi-generator.tech/) for code generation
|
||||
- API testing tools like Postman or Insomnia
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Phase 1: Core Endpoints (Proposed)
|
||||
|
||||
1. Customers API (full CRUD)
|
||||
2. Suppliers API (full CRUD)
|
||||
3. Items API (full CRUD)
|
||||
4. Inventory adjustments API (create only)
|
||||
|
||||
### Phase 2: Read-Only Endpoints (Proposed)
|
||||
|
||||
1. Sales API (read-only)
|
||||
2. Receivings API (read-only)
|
||||
|
||||
### Phase 3: Extended Features (Future)
|
||||
|
||||
1. Batch operations for all endpoints
|
||||
2. Search/filter capabilities
|
||||
3. Authorization/permissions integration
|
||||
4. Rate limiting
|
||||
5. API key management interface
|
||||
|
||||
## Discussion Topics
|
||||
|
||||
The following aspects of the API design are open for discussion:
|
||||
|
||||
1. **Field naming conventions**: Currently following existing database column names. Should we use camelCase for JSON?
|
||||
|
||||
2. **Batch operations**: Current design separates batch-delete and batch-update. Should we consolidate?
|
||||
|
||||
3. **Date formats**: Using ISO 8601 (date-time). Is timezone handling needed?
|
||||
|
||||
4. **Error response structure**: Current format uses `{success, message}`. Should we include error codes?
|
||||
|
||||
5. **Relationship representations**: Should nested resources (e.g., sale items) always be included?
|
||||
|
||||
6. **Inventory adjustments**: Should we support setting absolute quantities vs. relative changes?
|
||||
|
||||
7. **Authorization integration**: How should API access integrate with existing employee permissions?
|
||||
|
||||
8. **Stock locations**: Multiple locations per item - do we need location-specific endpoints?
|
||||
@@ -12,18 +12,10 @@ use CodeIgniter\Filters\InvalidChars;
|
||||
use CodeIgniter\Filters\PageCache;
|
||||
use CodeIgniter\Filters\PerformanceMetrics;
|
||||
use CodeIgniter\Filters\SecureHeaders;
|
||||
use App\Filters\ApiAuth;
|
||||
|
||||
class Filters extends BaseFilters
|
||||
{
|
||||
/**
|
||||
* Configures aliases for Filter classes to
|
||||
* make reading things nicer and simpler.
|
||||
*
|
||||
* @var array<string, class-string|list<class-string>>
|
||||
*
|
||||
* [filter_name => classname]
|
||||
* or [filter_name => [classname1, classname2, ...]]
|
||||
*/
|
||||
public array $aliases = [
|
||||
'csrf' => CSRF::class,
|
||||
'toolbar' => DebugToolbar::class,
|
||||
@@ -34,6 +26,7 @@ class Filters extends BaseFilters
|
||||
'forcehttps' => ForceHTTPS::class,
|
||||
'pagecache' => PageCache::class,
|
||||
'performance' => PerformanceMetrics::class,
|
||||
'apiauth' => ApiAuth::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -70,7 +63,7 @@ class Filters extends BaseFilters
|
||||
public array $globals = [
|
||||
'before' => [
|
||||
'honeypot',
|
||||
'csrf' => ['except' => 'login'],
|
||||
'csrf' => ['except' => ['login', 'api/*']],
|
||||
'invalidchars',
|
||||
],
|
||||
'after' => [
|
||||
|
||||
@@ -39,3 +39,50 @@ $routes->add('reports/specific_customers', 'Reports::specific_customer_input');
|
||||
$routes->add('reports/specific_employees', 'Reports::specific_employee_input');
|
||||
$routes->add('reports/specific_discounts', 'Reports::specific_discount_input');
|
||||
$routes->add('reports/specific_suppliers', 'Reports::specific_supplier_input');
|
||||
|
||||
$routes->group('office/api-keys', ['filter' => 'session'], static function(RouteCollection $routes): void {
|
||||
$routes->get('/', 'ApiKeys::index');
|
||||
$routes->post('generate', 'ApiKeys::generate');
|
||||
$routes->post('revoke/(:num)', 'ApiKeys::revoke/$1');
|
||||
$routes->post('regenerate/(:num)', 'ApiKeys::regenerate/$1');
|
||||
});
|
||||
|
||||
$routes->group('api/v1', ['filter' => 'apiauth'], static function(RouteCollection $routes): void {
|
||||
$routes->get('customers', 'Api\Customers::index');
|
||||
$routes->get('customers/(:num)', 'Api\Customers::show/$1');
|
||||
$routes->post('customers', 'Api\Customers::create');
|
||||
$routes->put('customers/(:num)', 'Api\Customers::update/$1');
|
||||
$routes->delete('customers/(:num)', 'Api\Customers::delete/$1');
|
||||
$routes->post('customers/batch-delete', 'Api\Customers::batchDelete');
|
||||
$routes->get('customers/suggest', 'Api\Customers::suggest');
|
||||
|
||||
$routes->get('suppliers', 'Api\Suppliers::index');
|
||||
$routes->get('suppliers/(:num)', 'Api\Suppliers::show/$1');
|
||||
$routes->post('suppliers', 'Api\Suppliers::create');
|
||||
$routes->put('suppliers/(:num)', 'Api\Suppliers::update/$1');
|
||||
$routes->delete('suppliers/(:num)', 'Api\Suppliers::delete/$1');
|
||||
$routes->post('suppliers/batch-delete', 'Api\Suppliers::batchDelete');
|
||||
$routes->get('suppliers/suggest', 'Api\Suppliers::suggest');
|
||||
|
||||
$routes->get('items', 'Api\Items::index');
|
||||
$routes->get('items/(:num)', 'Api\Items::show/$1');
|
||||
$routes->post('items', 'Api\Items::create');
|
||||
$routes->put('items/(:num)', 'Api\Items::update/$1');
|
||||
$routes->delete('items/(:num)', 'Api\Items::delete/$1');
|
||||
$routes->post('items/batch-delete', 'Api\Items::batchDelete');
|
||||
$routes->get('items/suggest', 'Api\Items::suggest');
|
||||
$routes->get('items/(:num)/quantities', 'Api\Items::quantities/$1');
|
||||
|
||||
$routes->get('inventory', 'Api\Inventory::index');
|
||||
$routes->post('inventory', 'Api\Inventory::create');
|
||||
$routes->post('inventory/bulk', 'Api\Inventory::create');
|
||||
|
||||
$routes->get('sales', 'Api\Sales::index');
|
||||
$routes->get('sales/(:num)', 'Api\Sales::show/$1');
|
||||
$routes->get('sales/(:num)/items', 'Api\Sales::items/$1');
|
||||
$routes->get('sales/(:num)/payments', 'Api\Sales::payments/$1');
|
||||
|
||||
$routes->get('receivings', 'Api\Receivings::index');
|
||||
$routes->get('receivings/(:num)', 'Api\Receivings::show/$1');
|
||||
$routes->get('receivings/(:num)/items', 'Api\Receivings::items/$1');
|
||||
});
|
||||
|
||||
129
app/Controllers/Api/BaseController.php
Normal file
129
app/Controllers/Api/BaseController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Models\Employee;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
|
||||
class BaseController extends ResourceController
|
||||
{
|
||||
protected Employee $employee;
|
||||
protected int $employeeId = 0;
|
||||
protected $format = 'json';
|
||||
|
||||
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
|
||||
$this->employee = model(Employee::class);
|
||||
$this->employeeId = $request->employeeId ?? 0;
|
||||
}
|
||||
|
||||
protected function hasPermission(string $moduleId): bool
|
||||
{
|
||||
return $this->employee->has_grant($moduleId, $this->employeeId);
|
||||
}
|
||||
|
||||
protected function respondSuccess(array $data = [], int $code = 200, string $message = 'Success'): ResponseInterface
|
||||
{
|
||||
$response = ['success' => true];
|
||||
|
||||
if ($message) {
|
||||
$response['message'] = $message;
|
||||
}
|
||||
|
||||
$response = array_merge($response, $data);
|
||||
|
||||
return $this->respond($response, $code);
|
||||
}
|
||||
|
||||
protected function respondCreated(array $data = [], string $message = 'Resource created'): ResponseInterface
|
||||
{
|
||||
return $this->respondSuccess($data, 201, $message);
|
||||
}
|
||||
|
||||
protected function respondError(string $message, int $code = 400): ResponseInterface
|
||||
{
|
||||
return $this->respond([
|
||||
'success' => false,
|
||||
'message' => $message
|
||||
], $code);
|
||||
}
|
||||
|
||||
protected function respondNotFound(string $message = 'Resource not found'): ResponseInterface
|
||||
{
|
||||
return $this->respondError($message, 404);
|
||||
}
|
||||
|
||||
protected function respondUnauthorized(string $message = 'Unauthorized'): ResponseInterface
|
||||
{
|
||||
return $this->respondError($message, 403);
|
||||
}
|
||||
|
||||
protected function respondValidationError(array $errors): ResponseInterface
|
||||
{
|
||||
return $this->respond([
|
||||
'success' => false,
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $errors
|
||||
], 422);
|
||||
}
|
||||
|
||||
protected function getPagination(): array
|
||||
{
|
||||
$offset = (int) ($this->request->getGet('offset') ?? 0);
|
||||
$limit = (int) ($this->request->getGet('limit') ?? 25);
|
||||
$limit = min(max($limit, 1), 100);
|
||||
$offset = max($offset, 0);
|
||||
|
||||
return ['offset' => $offset, 'limit' => $limit];
|
||||
}
|
||||
|
||||
protected function getSort(array $allowedFields, string $default = 'id', string $defaultOrder = 'asc'): array
|
||||
{
|
||||
$sort = $this->request->getGet('sort') ?? $default;
|
||||
$order = strtolower($this->request->getGet('order') ?? $defaultOrder);
|
||||
|
||||
if (!in_array($sort, $allowedFields)) {
|
||||
$sort = $default;
|
||||
}
|
||||
|
||||
if (!in_array($order, ['asc', 'desc'])) {
|
||||
$order = $defaultOrder;
|
||||
}
|
||||
|
||||
return ['sort' => $sort, 'order' => $order];
|
||||
}
|
||||
|
||||
protected function toCamelCase(array $data): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$camelKey = lcfirst(str_replace('_', '', ucwords($key, '_')));
|
||||
$result[$camelKey] = $value;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function toSnakeCase(array $data): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
|
||||
$result[$snakeKey] = $value;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function transformItem(object|array $item, array $additional = []): array
|
||||
{
|
||||
$item = is_object($item) ? (array) $item : $item;
|
||||
return $this->toCamelCase(array_merge($item, $additional));
|
||||
}
|
||||
|
||||
protected function transformCollection(array $items): array
|
||||
{
|
||||
return array_map([$this, 'transformItem'], $items);
|
||||
}
|
||||
}
|
||||
251
app/Controllers/Api/Customers.php
Normal file
251
app/Controllers/Api/Customers.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Person;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class Customers extends BaseController
|
||||
{
|
||||
protected Customer $customerModel;
|
||||
protected Person $personModel;
|
||||
|
||||
protected array $allowedSortFields = ['person_id', 'last_name', 'first_name', 'email', 'company_name'];
|
||||
|
||||
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
$this->customerModel = model(Customer::class);
|
||||
$this->personModel = model(Person::class);
|
||||
}
|
||||
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$search = $this->request->getGet('search');
|
||||
$pagination = $this->getPagination();
|
||||
$sort = $this->getSort($this->allowedSortFields, 'last_name');
|
||||
|
||||
$builder = $this->customerModel->builder();
|
||||
$builder->select('customers.*, people.*');
|
||||
$builder->join('people', 'people.person_id = customers.person_id');
|
||||
$builder->where('customers.deleted', 0);
|
||||
|
||||
if ($search) {
|
||||
$builder->groupStart();
|
||||
$builder->like('people.first_name', $search);
|
||||
$builder->orLike('people.last_name', $search);
|
||||
$builder->orLike('people.email', $search);
|
||||
$builder->orLike('customers.account_number', $search);
|
||||
$builder->orLike('customers.company_name', $search);
|
||||
$builder->groupEnd();
|
||||
}
|
||||
|
||||
$total = $builder->countAllResults(false);
|
||||
|
||||
$dbSort = $this->mapSortField($sort['sort']);
|
||||
$builder->orderBy($dbSort, $sort['order']);
|
||||
$builder->limit($pagination['limit'], $pagination['offset']);
|
||||
|
||||
$customers = $builder->get()->getResultArray();
|
||||
|
||||
return $this->respondSuccess([
|
||||
'total' => $total,
|
||||
'offset' => $pagination['offset'],
|
||||
'limit' => $pagination['limit'],
|
||||
'rows' => $this->transformCollection($customers)
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$customer = $this->customerModel->get_info($id);
|
||||
|
||||
if (empty($customer) || $customer->deleted) {
|
||||
return $this->respondNotFound('Customer not found');
|
||||
}
|
||||
|
||||
$person = (array) $this->personModel->get_info($id);
|
||||
$customer = (array) $customer;
|
||||
$data = array_merge($person, $customer);
|
||||
|
||||
return $this->respondSuccess($this->transformItem($data));
|
||||
}
|
||||
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getPost();
|
||||
}
|
||||
|
||||
$data = $this->toSnakeCase($data);
|
||||
|
||||
$rules = [
|
||||
'first_name' => 'required|max_length[255]',
|
||||
'last_name' => 'required|max_length[255]',
|
||||
];
|
||||
|
||||
$snakeData = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
|
||||
$snakeData[$snakeKey] = $value;
|
||||
}
|
||||
|
||||
$personData = array_intersect_key($snakeData, array_flip([
|
||||
'first_name', 'last_name', 'gender', 'phone_number', 'email',
|
||||
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
|
||||
]));
|
||||
|
||||
$customerData = array_intersect_key($snakeData, array_flip([
|
||||
'account_number', 'taxable', 'tax_id', 'sales_tax_code_id',
|
||||
'discount', 'discount_type', 'company_name', 'package_id', 'consent'
|
||||
]));
|
||||
$customerData['employee_id'] = $this->employeeId;
|
||||
|
||||
$personId = false;
|
||||
$success = $this->personModel->save_value($personData);
|
||||
|
||||
if ($success && isset($personData['person_id'])) {
|
||||
$personId = $personData['person_id'];
|
||||
$customerData['person_id'] = $personId;
|
||||
$success = $this->customerModel->save_value($customerData);
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
return $this->respondCreated(['id' => $personId], 'Customer created successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to create customer');
|
||||
}
|
||||
|
||||
public function update($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$customer = $this->customerModel->get_info($id);
|
||||
|
||||
if (empty($customer) || $customer->deleted) {
|
||||
return $this->respondNotFound('Customer not found');
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getRawInput();
|
||||
}
|
||||
|
||||
$snakeData = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
|
||||
$snakeData[$snakeKey] = $value;
|
||||
}
|
||||
|
||||
$personData = array_intersect_key($snakeData, array_flip([
|
||||
'first_name', 'last_name', 'gender', 'phone_number', 'email',
|
||||
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
|
||||
]));
|
||||
|
||||
$customerData = array_intersect_key($snakeData, array_flip([
|
||||
'account_number', 'taxable', 'tax_id', 'sales_tax_code_id',
|
||||
'discount', 'discount_type', 'company_name', 'package_id', 'consent'
|
||||
]));
|
||||
|
||||
if (!empty($personData)) {
|
||||
$this->personModel->save_value($personData, $id);
|
||||
}
|
||||
|
||||
if (!empty($customerData)) {
|
||||
$this->customerModel->save_value($customerData, $id);
|
||||
}
|
||||
|
||||
return $this->respondSuccess([], 200, 'Customer updated successfully');
|
||||
}
|
||||
|
||||
public function delete($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$customer = $this->customerModel->get_info($id);
|
||||
|
||||
if (empty($customer) || $customer->deleted) {
|
||||
return $this->respondNotFound('Customer not found');
|
||||
}
|
||||
|
||||
$success = $this->customerModel->delete($id);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Customer deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete customer');
|
||||
}
|
||||
|
||||
public function batchDelete(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
$ids = $data['ids'] ?? [];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->respondError('No customer IDs provided');
|
||||
}
|
||||
|
||||
$success = $this->customerModel->delete_list($ids);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Customers deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete customers');
|
||||
}
|
||||
|
||||
public function suggest(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('customers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$term = $this->request->getGet('term');
|
||||
$limit = (int) ($this->request->getGet('limit') ?? 25);
|
||||
|
||||
if (empty($term)) {
|
||||
return $this->respondSuccess(['suggestions' => []]);
|
||||
}
|
||||
|
||||
$suggestions = $this->customerModel->get_search_suggestions($term, $limit);
|
||||
|
||||
return $this->respondSuccess(['suggestions' => $suggestions]);
|
||||
}
|
||||
|
||||
private function mapSortField(string $field): string
|
||||
{
|
||||
$map = [
|
||||
'personId' => 'people.person_id',
|
||||
'lastName' => 'people.last_name',
|
||||
'firstName' => 'people.first_name',
|
||||
'email' => 'people.email',
|
||||
'companyName' => 'customers.company_name'
|
||||
];
|
||||
|
||||
return $map[$field] ?? 'people.last_name';
|
||||
}
|
||||
}
|
||||
212
app/Controllers/Api/Inventory.php
Normal file
212
app/Controllers/Api/Inventory.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Models\Inventory as InventoryModel;
|
||||
use App\Models\Item;
|
||||
use App\Models\Item_quantity;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class Inventory extends BaseController
|
||||
{
|
||||
protected InventoryModel $inventory;
|
||||
protected Item $item;
|
||||
protected Item_quantity $itemQuantity;
|
||||
|
||||
protected array $allowedSortFields = ['trans_id', 'trans_date', 'trans_items'];
|
||||
|
||||
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
$this->inventory = model(InventoryModel::class);
|
||||
$this->item = model(Item::class);
|
||||
$this->itemQuantity = model(Item_quantity::class);
|
||||
}
|
||||
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$itemId = $this->request->getGet('itemId');
|
||||
$locationId = $this->request->getGet('locationId');
|
||||
$pagination = $this->getPagination();
|
||||
$sort = $this->getSort($this->allowedSortFields, 'trans_date');
|
||||
|
||||
$builder = $this->inventory->builder();
|
||||
|
||||
if ($itemId) {
|
||||
$builder->where('trans_items', $itemId);
|
||||
}
|
||||
|
||||
if ($locationId) {
|
||||
$builder->where('trans_location', $locationId);
|
||||
}
|
||||
|
||||
$total = $builder->countAllResults(false);
|
||||
|
||||
$builder->orderBy($sort['sort'], $sort['order']);
|
||||
$builder->limit($pagination['limit'], $pagination['offset']);
|
||||
|
||||
$transactions = $builder->get()->getResultArray();
|
||||
|
||||
return $this->respondSuccess([
|
||||
'total' => $total,
|
||||
'offset' => $pagination['offset'],
|
||||
'limit' => $pagination['limit'],
|
||||
'rows' => $this->transformCollection($transactions)
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
|
||||
if (isset($data['adjustments']) && is_array($data['adjustments'])) {
|
||||
return $this->bulkAdjust($data['adjustments']);
|
||||
}
|
||||
|
||||
return $this->singleAdjust($data);
|
||||
}
|
||||
|
||||
private function singleAdjust(array $data): ResponseInterface
|
||||
{
|
||||
if (empty($data['itemId'])) {
|
||||
return $this->respondError('itemId is required');
|
||||
}
|
||||
|
||||
if (!isset($data['quantity'])) {
|
||||
return $this->respondError('quantity is required');
|
||||
}
|
||||
|
||||
$mode = $data['mode'] ?? 'adjust';
|
||||
|
||||
if (!in_array($mode, ['adjust', 'set'])) {
|
||||
return $this->respondError('mode must be "adjust" or "set"');
|
||||
}
|
||||
|
||||
$item = $this->item->find($data['itemId']);
|
||||
if (!$item || $item->deleted) {
|
||||
return $this->respondNotFound('Item not found');
|
||||
}
|
||||
|
||||
$locationId = $data['locationId'] ?? 1;
|
||||
$comment = $data['comment'] ?? 'API inventory adjustment';
|
||||
$quantity = (float) $data['quantity'];
|
||||
|
||||
if ($mode === 'set') {
|
||||
$currentQty = $this->itemQuantity->get_item_quantity($data['itemId'], $locationId);
|
||||
$currentQty = $currentQty ? (float) $currentQty->quantity : 0;
|
||||
$adjustment = $quantity - $currentQty;
|
||||
|
||||
if ($adjustment == 0) {
|
||||
return $this->respondSuccess([
|
||||
'itemId' => (int) $data['itemId'],
|
||||
'locationId' => (int) $locationId,
|
||||
'newQuantity' => $quantity,
|
||||
'mode' => $mode
|
||||
], 200, 'Quantity already at requested level');
|
||||
}
|
||||
} else {
|
||||
$adjustment = $quantity;
|
||||
}
|
||||
|
||||
$invData = [
|
||||
'trans_date' => date('Y-m-d H:i:s'),
|
||||
'trans_items' => $data['itemId'],
|
||||
'trans_user' => $this->employeeId,
|
||||
'trans_location' => $locationId,
|
||||
'trans_comment' => $comment,
|
||||
'trans_inventory' => $adjustment
|
||||
];
|
||||
|
||||
$this->inventory->insert($invData);
|
||||
$this->itemQuantity->change_quantity($data['itemId'], $locationId, $adjustment);
|
||||
|
||||
$newQty = $this->itemQuantity->get_item_quantity($data['itemId'], $locationId);
|
||||
|
||||
return $this->respondSuccess([
|
||||
'itemId' => (int) $data['itemId'],
|
||||
'locationId' => (int) $locationId,
|
||||
'adjustment' => $adjustment,
|
||||
'newQuantity' => $newQty ? (float) $newQty->quantity : 0,
|
||||
'mode' => $mode
|
||||
], 200, 'Inventory adjusted successfully');
|
||||
}
|
||||
|
||||
private function bulkAdjust(array $adjustments): ResponseInterface
|
||||
{
|
||||
$results = [];
|
||||
$processed = 0;
|
||||
$errors = [];
|
||||
|
||||
$this->inventory->db->transStart();
|
||||
|
||||
foreach ($adjustments as $adjustment) {
|
||||
$itemId = $adjustment['itemId'] ?? $adjustment['item_id'] ?? null;
|
||||
|
||||
if (!$itemId) {
|
||||
$errors[] = ['itemId' => null, 'success' => false, 'error' => 'itemId is required'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = $this->item->find($itemId);
|
||||
if (!$item || $item->deleted) {
|
||||
$errors[] = ['itemId' => $itemId, 'success' => false, 'error' => 'Item not found'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$mode = $adjustment['mode'] ?? 'adjust';
|
||||
$locationId = $adjustment['locationId'] ?? $adjustment['location_id'] ?? 1;
|
||||
$quantity = (float) ($adjustment['quantity'] ?? 0);
|
||||
$comment = $adjustment['comment'] ?? 'Bulk API inventory adjustment';
|
||||
|
||||
if ($mode === 'set') {
|
||||
$currentQty = $this->itemQuantity->get_item_quantity($itemId, $locationId);
|
||||
$currentQty = $currentQty ? (float) $currentQty->quantity : 0;
|
||||
$adjustmentQty = $quantity - $currentQty;
|
||||
} else {
|
||||
$adjustmentQty = $quantity;
|
||||
}
|
||||
|
||||
$invData = [
|
||||
'trans_date' => date('Y-m-d H:i:s'),
|
||||
'trans_items' => $itemId,
|
||||
'trans_user' => $this->employeeId,
|
||||
'trans_location' => $locationId,
|
||||
'trans_comment' => $comment,
|
||||
'trans_inventory' => $adjustmentQty
|
||||
];
|
||||
|
||||
$this->inventory->insert($invData);
|
||||
$this->itemQuantity->change_quantity($itemId, $locationId, $adjustmentQty);
|
||||
|
||||
$results[] = ['itemId' => $itemId, 'success' => true];
|
||||
$processed++;
|
||||
}
|
||||
|
||||
$this->inventory->db->transComplete();
|
||||
|
||||
$response = [
|
||||
'processed' => $processed,
|
||||
'total' => count($adjustments),
|
||||
'results' => $results
|
||||
];
|
||||
|
||||
if (!empty($errors)) {
|
||||
$response['errors'] = $errors;
|
||||
$response['success'] = false;
|
||||
$response['message'] = 'Some adjustments failed';
|
||||
} else {
|
||||
$response['success'] = true;
|
||||
$response['message'] = 'All adjustments processed successfully';
|
||||
}
|
||||
|
||||
return $this->respondSuccess($response);
|
||||
}
|
||||
}
|
||||
237
app/Controllers/Api/Items.php
Normal file
237
app/Controllers/Api/Items.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Models\Item;
|
||||
use App\Models\Item_quantity;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class Items extends BaseController
|
||||
{
|
||||
protected Item $itemModel;
|
||||
protected Item_quantity $itemQuantityModel;
|
||||
|
||||
protected array $allowedSortFields = ['item_id', 'name', 'category', 'cost_price', 'unit_price'];
|
||||
|
||||
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
$this->itemModel = model(Item::class);
|
||||
$this->itemQuantityModel = model(Item_quantity::class);
|
||||
}
|
||||
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$search = $this->request->getGet('search');
|
||||
$pagination = $this->getPagination();
|
||||
$sort = $this->getSort($this->allowedSortFields, 'name');
|
||||
$stockLocation = $this->request->getGet('stockLocation');
|
||||
|
||||
$builder = $this->itemModel->builder();
|
||||
$builder->where('deleted', 0);
|
||||
|
||||
if ($search) {
|
||||
$builder->groupStart();
|
||||
$builder->like('name', $search);
|
||||
$builder->orLike('item_number', $search);
|
||||
$builder->orLike('category', $search);
|
||||
$builder->orLike('description', $search);
|
||||
$builder->groupEnd();
|
||||
}
|
||||
|
||||
$total = $builder->countAllResults(false);
|
||||
|
||||
$dbSort = $this->mapSortField($sort['sort']);
|
||||
$builder->orderBy($dbSort, $sort['order']);
|
||||
$builder->limit($pagination['limit'], $pagination['offset']);
|
||||
|
||||
$items = $builder->get()->getResultArray();
|
||||
|
||||
return $this->respondSuccess([
|
||||
'total' => $total,
|
||||
'offset' => $pagination['offset'],
|
||||
'limit' => $pagination['limit'],
|
||||
'rows' => $this->transformCollection($items)
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$item = $this->itemModel->find($id);
|
||||
|
||||
if (!$item || $item->deleted) {
|
||||
return $this->respondNotFound('Item not found');
|
||||
}
|
||||
|
||||
return $this->respondSuccess($this->transformItem($item));
|
||||
}
|
||||
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getPost();
|
||||
}
|
||||
|
||||
$snakeData = $this->toSnakeCase($data);
|
||||
|
||||
if (!empty($snakeData['item_number'])) {
|
||||
if ($this->itemModel->item_number_exists($snakeData['item_number'])) {
|
||||
return $this->respondError('Item number already exists', 409);
|
||||
}
|
||||
}
|
||||
|
||||
$itemId = $this->itemModel->save_value($snakeData);
|
||||
|
||||
if ($itemId) {
|
||||
return $this->respondCreated(['id' => $itemId], 'Item created successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to create item');
|
||||
}
|
||||
|
||||
public function update($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$item = $this->itemModel->find($id);
|
||||
|
||||
if (!$item || $item->deleted) {
|
||||
return $this->respondNotFound('Item not found');
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getRawInput();
|
||||
}
|
||||
|
||||
$snakeData = $this->toSnakeCase($data);
|
||||
$snakeData['item_id'] = $id;
|
||||
|
||||
$success = $this->itemModel->save_value($snakeData);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Item updated successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to update item');
|
||||
}
|
||||
|
||||
public function delete($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$item = $this->itemModel->find($id);
|
||||
|
||||
if (!$item || $item->deleted) {
|
||||
return $this->respondNotFound('Item not found');
|
||||
}
|
||||
|
||||
$success = $this->itemModel->delete($id);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Item deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete item');
|
||||
}
|
||||
|
||||
public function batchDelete(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
$ids = $data['ids'] ?? [];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->respondError('No item IDs provided');
|
||||
}
|
||||
|
||||
$success = $this->itemModel->delete_list($ids);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Items deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete items');
|
||||
}
|
||||
|
||||
public function quantities($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$item = $this->itemModel->find($id);
|
||||
|
||||
if (!$item || $item->deleted) {
|
||||
return $this->respondNotFound('Item not found');
|
||||
}
|
||||
|
||||
$locations = model('App\Models\Stock_location')->get_all();
|
||||
$quantities = [];
|
||||
|
||||
foreach ($locations as $location) {
|
||||
$qty = $this->itemQuantityModel->get_item_quantity($id, $location->location_id);
|
||||
$quantities[] = [
|
||||
'locationId' => (int) $location->location_id,
|
||||
'locationName' => $location->location_name,
|
||||
'quantity' => $qty ? (float) $qty->quantity : 0
|
||||
];
|
||||
}
|
||||
|
||||
return $this->respondSuccess([
|
||||
'itemId' => (int) $id,
|
||||
'quantities' => $quantities
|
||||
]);
|
||||
}
|
||||
|
||||
public function suggest(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('items')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$term = $this->request->getGet('term');
|
||||
$limit = (int) ($this->request->getGet('limit') ?? 25);
|
||||
|
||||
if (empty($term)) {
|
||||
return $this->respondSuccess(['suggestions' => []]);
|
||||
}
|
||||
|
||||
$suggestions = $this->itemModel->get_search_suggestions($term, $limit);
|
||||
|
||||
return $this->respondSuccess(['suggestions' => $suggestions]);
|
||||
}
|
||||
|
||||
private function mapSortField(string $field): string
|
||||
{
|
||||
$map = [
|
||||
'itemId' => 'item_id',
|
||||
'name' => 'name',
|
||||
'category' => 'category',
|
||||
'costPrice' => 'cost_price',
|
||||
'unitPrice' => 'unit_price'
|
||||
];
|
||||
|
||||
return $map[$field] ?? 'name';
|
||||
}
|
||||
}
|
||||
239
app/Controllers/Api/Suppliers.php
Normal file
239
app/Controllers/Api/Suppliers.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Api;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Person;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
class Suppliers extends BaseController
|
||||
{
|
||||
protected Supplier $supplierModel;
|
||||
protected Person $personModel;
|
||||
|
||||
protected array $allowedSortFields = ['person_id', 'last_name', 'company_name'];
|
||||
|
||||
public function initController(\CodeIgniter\HTTP\RequestInterface $request, \CodeIgniter\HTTP\ResponseInterface $response, \Psr\Log\LoggerInterface $logger): void
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
$this->supplierModel = model(Supplier::class);
|
||||
$this->personModel = model(Person::class);
|
||||
}
|
||||
|
||||
public function index(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$search = $this->request->getGet('search');
|
||||
$pagination = $this->getPagination();
|
||||
$sort = $this->getSort($this->allowedSortFields, 'companyName');
|
||||
|
||||
$builder = $this->supplierModel->builder();
|
||||
$builder->select('suppliers.*, people.*');
|
||||
$builder->join('people', 'people.person_id = suppliers.person_id');
|
||||
$builder->where('suppliers.deleted', 0);
|
||||
|
||||
if ($search) {
|
||||
$builder->groupStart();
|
||||
$builder->like('people.first_name', $search);
|
||||
$builder->orLike('people.last_name', $search);
|
||||
$builder->orLike('people.email', $search);
|
||||
$builder->orLike('suppliers.account_number', $search);
|
||||
$builder->orLike('suppliers.company_name', $search);
|
||||
$builder->groupEnd();
|
||||
}
|
||||
|
||||
$total = $builder->countAllResults(false);
|
||||
|
||||
$dbSort = $this->mapSortField($sort['sort']);
|
||||
$builder->orderBy($dbSort, $sort['order']);
|
||||
$builder->limit($pagination['limit'], $pagination['offset']);
|
||||
|
||||
$suppliers = $builder->get()->getResultArray();
|
||||
|
||||
return $this->respondSuccess([
|
||||
'total' => $total,
|
||||
'offset' => $pagination['offset'],
|
||||
'limit' => $pagination['limit'],
|
||||
'rows' => $this->transformCollection($suppliers)
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$supplier = $this->supplierModel->get_info($id);
|
||||
|
||||
if (empty($supplier) || $supplier->deleted) {
|
||||
return $this->respondNotFound('Supplier not found');
|
||||
}
|
||||
|
||||
$person = (array) $this->personModel->get_info($id);
|
||||
$supplier = (array) $supplier;
|
||||
$data = array_merge($person, $supplier);
|
||||
|
||||
return $this->respondSuccess($this->transformItem($data));
|
||||
}
|
||||
|
||||
public function create(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getPost();
|
||||
}
|
||||
|
||||
$snakeData = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
|
||||
$snakeData[$snakeKey] = $value;
|
||||
}
|
||||
|
||||
$personData = array_intersect_key($snakeData, array_flip([
|
||||
'first_name', 'last_name', 'gender', 'phone_number', 'email',
|
||||
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
|
||||
]));
|
||||
|
||||
$supplierData = array_intersect_key($snakeData, array_flip([
|
||||
'company_name', 'account_number', 'tax_id', 'agency_name', 'category'
|
||||
]));
|
||||
|
||||
$personId = false;
|
||||
$success = $this->personModel->save_value($personData);
|
||||
|
||||
if ($success && isset($personData['person_id'])) {
|
||||
$personId = $personData['person_id'];
|
||||
$supplierData['person_id'] = $personId;
|
||||
$success = $this->supplierModel->save_value($supplierData);
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
return $this->respondCreated(['id' => $personId], 'Supplier created successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to create supplier');
|
||||
}
|
||||
|
||||
public function update($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$supplier = $this->supplierModel->get_info($id);
|
||||
|
||||
if (empty($supplier) || $supplier->deleted) {
|
||||
return $this->respondNotFound('Supplier not found');
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
if (empty($data)) {
|
||||
$data = $this->request->getRawInput();
|
||||
}
|
||||
|
||||
$snakeData = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
|
||||
$snakeData[$snakeKey] = $value;
|
||||
}
|
||||
|
||||
$personData = array_intersect_key($snakeData, array_flip([
|
||||
'first_name', 'last_name', 'gender', 'phone_number', 'email',
|
||||
'address_1', 'address_2', 'city', 'state', 'zip', 'country', 'comments'
|
||||
]));
|
||||
|
||||
$supplierData = array_intersect_key($snakeData, array_flip([
|
||||
'company_name', 'account_number', 'tax_id', 'agency_name', 'category'
|
||||
]));
|
||||
|
||||
if (!empty($personData)) {
|
||||
$this->personModel->save_value($personData, $id);
|
||||
}
|
||||
|
||||
if (!empty($supplierData)) {
|
||||
$this->supplierModel->save_value($supplierData, $id);
|
||||
}
|
||||
|
||||
return $this->respondSuccess([], 200, 'Supplier updated successfully');
|
||||
}
|
||||
|
||||
public function delete($id = null): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$supplier = $this->supplierModel->get_info($id);
|
||||
|
||||
if (empty($supplier) || $supplier->deleted) {
|
||||
return $this->respondNotFound('Supplier not found');
|
||||
}
|
||||
|
||||
$success = $this->supplierModel->delete($id);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Supplier deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete supplier');
|
||||
}
|
||||
|
||||
public function batchDelete(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$data = $this->request->getJSON(true);
|
||||
$ids = $data['ids'] ?? [];
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->respondError('No supplier IDs provided');
|
||||
}
|
||||
|
||||
$success = $this->supplierModel->delete_list($ids);
|
||||
|
||||
if ($success) {
|
||||
return $this->respondSuccess([], 200, 'Suppliers deleted successfully');
|
||||
}
|
||||
|
||||
return $this->respondError('Failed to delete suppliers');
|
||||
}
|
||||
|
||||
public function suggest(): ResponseInterface
|
||||
{
|
||||
if (!$this->hasPermission('suppliers')) {
|
||||
return $this->respondUnauthorized();
|
||||
}
|
||||
|
||||
$term = $this->request->getGet('term');
|
||||
$limit = (int) ($this->request->getGet('limit') ?? 25);
|
||||
|
||||
if (empty($term)) {
|
||||
return $this->respondSuccess(['suggestions' => []]);
|
||||
}
|
||||
|
||||
$suggestions = $this->supplierModel->get_search_suggestions($term, $limit);
|
||||
|
||||
return $this->respondSuccess(['suggestions' => $suggestions]);
|
||||
}
|
||||
|
||||
private function mapSortField(string $field): string
|
||||
{
|
||||
$map = [
|
||||
'personId' => 'people.person_id',
|
||||
'lastName' => 'people.last_name',
|
||||
'companyName' => 'suppliers.company_name'
|
||||
];
|
||||
|
||||
return $map[$field] ?? 'suppliers.company_name';
|
||||
}
|
||||
}
|
||||
84
app/Controllers/ApiKeys.php
Normal file
84
app/Controllers/ApiKeys.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use App\Models\Employee;
|
||||
|
||||
class ApiKeys extends Secure_Controller
|
||||
{
|
||||
protected ApiKey $apiKeyModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('api_keys');
|
||||
$this->apiKeyModel = model(ApiKey::class);
|
||||
}
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
$keys = $this->apiKeyModel->getKeysForEmployee($employeeId);
|
||||
|
||||
echo view('api_keys/manage', [
|
||||
'keys' => $keys,
|
||||
'employee_info' => $this->employee->get_logged_in_employee_info()
|
||||
]);
|
||||
}
|
||||
|
||||
public function generate(): void
|
||||
{
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
$name = $this->request->getPost('name');
|
||||
$expiresAt = $this->request->getPost('expires_at') ?: null;
|
||||
|
||||
$apiKey = $this->apiKeyModel->generateKey($employeeId, $name, $expiresAt);
|
||||
|
||||
if ($apiKey) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => lang('Api_keys.key_generated'),
|
||||
'apiKey' => $apiKey,
|
||||
'keyPrefix' => substr($apiKey, 0, 12) . '...'
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => lang('Api_keys.key_generation_failed')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function revoke(int $apiKeyId): void
|
||||
{
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
|
||||
$success = $this->apiKeyModel->revokeKey($apiKeyId, $employeeId);
|
||||
|
||||
echo json_encode([
|
||||
'success' => $success,
|
||||
'message' => $success ? lang('Api_keys.key_revoked') : lang('Api_keys.key_revoke_failed')
|
||||
]);
|
||||
}
|
||||
|
||||
public function regenerate(int $apiKeyId): void
|
||||
{
|
||||
$employeeId = $this->employee->get_logged_in_employee_info()->person_id;
|
||||
|
||||
$newKey = $this->apiKeyModel->regenerateKey($apiKeyId, $employeeId);
|
||||
|
||||
if ($newKey) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => lang('Api_keys.key_regenerated'),
|
||||
'apiKey' => $newKey,
|
||||
'keyPrefix' => substr($newKey, 0, 12) . '...'
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => lang('Api_keys.key_regeneration_failed')
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -942,7 +942,9 @@ class Config extends Secure_Controller
|
||||
'work_order_enable' => $this->request->getPost('work_order_enable') != null,
|
||||
'work_order_format' => $this->request->getPost('work_order_format'),
|
||||
'last_used_work_order_number' => $this->request->getPost('last_used_work_order_number', FILTER_SANITIZE_NUMBER_INT),
|
||||
'invoice_type' => $this->request->getPost('invoice_type')
|
||||
'invoice_type' => Sale_lib::isValidInvoiceType($this->request->getPost('invoice_type'))
|
||||
? $this->request->getPost('invoice_type')
|
||||
: 'invoice'
|
||||
];
|
||||
|
||||
$success = $this->appconfig->batch_save($batch_save_data);
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -755,8 +755,11 @@ class Sales extends Secure_Controller
|
||||
$data['sale_status'] = COMPLETED;
|
||||
$sale_type = SALE_TYPE_INVOICE;
|
||||
|
||||
// The PHP file name is the same as the invoice_type key
|
||||
$invoice_view = $this->config['invoice_type'];
|
||||
$invoice_type = $this->config['invoice_type'];
|
||||
if (!Sale_lib::isValidInvoiceType($invoice_type)) {
|
||||
$invoice_type = 'invoice';
|
||||
}
|
||||
$invoice_view = $invoice_type;
|
||||
|
||||
// Save the data to the sales table
|
||||
$data['sale_id_num'] = $this->sale->save_value($sale_id, $data['sale_status'], $data['cart'], $customer_id, $employee_id, $data['comments'], $invoice_number, $work_order_number, $quote_number, $sale_type, $data['payments'], $data['dinner_table'], $tax_details);
|
||||
@@ -1107,6 +1110,9 @@ class Sales extends Secure_Controller
|
||||
}
|
||||
|
||||
$invoice_type = $this->config['invoice_type'];
|
||||
if (!Sale_lib::isValidInvoiceType($invoice_type)) {
|
||||
$invoice_type = 'invoice';
|
||||
}
|
||||
$data['invoice_view'] = $invoice_type;
|
||||
|
||||
return $data;
|
||||
|
||||
@@ -267,6 +267,8 @@ class Migration_Sales_Tax_Data extends Migration
|
||||
*/
|
||||
public function round_number(int $rounding_mode, string $amount, int $decimals): float
|
||||
{
|
||||
$amount = (float)$amount;
|
||||
|
||||
if ($rounding_mode == Migration_Sales_Tax_Data::ROUND_UP) {
|
||||
$fig = pow(10, $decimals);
|
||||
$rounded_total = (ceil($fig * $amount) + ceil($fig * $amount - ceil($fig * $amount))) / $fig;
|
||||
@@ -376,7 +378,7 @@ class Migration_Sales_Tax_Data extends Migration
|
||||
$decimals = totals_decimals();
|
||||
|
||||
foreach ($sales_taxes as $row_number => $sales_tax) {
|
||||
$sale_tax_amount = $sales_tax['sale_tax_amount'];
|
||||
$sale_tax_amount = (float)$sales_tax['sale_tax_amount'];
|
||||
$rounding_code = $sales_tax['rounding_code'];
|
||||
$rounded_sale_tax_amount = $sale_tax_amount;
|
||||
|
||||
|
||||
@@ -243,6 +243,8 @@ class Migration_TaxAmount extends Migration
|
||||
*/
|
||||
public function round_number(int $rounding_mode, string $amount, int $decimals): float // TODO: is this currency safe?
|
||||
{ // TODO: This needs to be converted to a switch
|
||||
$amount = (float)$amount;
|
||||
|
||||
if ($rounding_mode == Migration_TaxAmount::ROUND_UP) { // TODO: === ?
|
||||
$fig = pow(10, $decimals);
|
||||
$rounded_total = (ceil($fig * $amount) + ceil($fig * $amount - ceil($fig * $amount))) / $fig;
|
||||
@@ -354,7 +356,7 @@ class Migration_TaxAmount extends Migration
|
||||
$decimals = totals_decimals();
|
||||
|
||||
foreach ($sales_taxes as $row_number => $sales_tax) {
|
||||
$sale_tax_amount = $sales_tax['sale_tax_amount'];
|
||||
$sale_tax_amount = (float)$sales_tax['sale_tax_amount'];
|
||||
$rounding_code = $sales_tax['rounding_code'];
|
||||
$rounded_sale_tax_amount = $sale_tax_amount;
|
||||
|
||||
|
||||
96
app/Database/Migrations/20250310000000_ApiKeys.php
Normal file
96
app/Database/Migrations/20250310000000_ApiKeys.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class ApiKeys extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->forge->addField([
|
||||
'api_key_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true
|
||||
],
|
||||
'employee_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 10
|
||||
],
|
||||
'key_hash' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 64
|
||||
],
|
||||
'key_prefix' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 12
|
||||
],
|
||||
'name' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true
|
||||
],
|
||||
'last_used' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true
|
||||
],
|
||||
'created' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true
|
||||
],
|
||||
'expires_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true
|
||||
],
|
||||
'disabled' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0
|
||||
]
|
||||
]);
|
||||
|
||||
$this->forge->addKey('api_key_id', true);
|
||||
$this->forge->addKey('employee_id');
|
||||
$this->forge->addKey('key_hash');
|
||||
|
||||
$this->forge->createTable('api_keys', true);
|
||||
|
||||
$this->db->query(
|
||||
'ALTER TABLE ' . $this->db->prefixTable('api_keys') .
|
||||
' ADD CONSTRAINT ' . $this->db->prefixTable('api_keys') . '_employee_id_foreign' .
|
||||
' FOREIGN KEY (employee_id) REFERENCES ' . $this->db->prefixTable('employees') .
|
||||
' (person_id) ON DELETE CASCADE ON UPDATE CASCADE'
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
'INSERT INTO ' . $this->db->prefixTable('permissions') . ' (permission_id, module_id)' .
|
||||
" VALUES ('api_keys', 'office') ON DUPLICATE KEY UPDATE permission_id = 'api_keys'"
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
'INSERT INTO ' . $this->db->prefixTable('modules') . ' (module_id, name_lang_key, desc_lang_key, sort)' .
|
||||
" VALUES ('api_keys', 'module_api_keys', 'module_desc_api_keys', 25)" .
|
||||
" ON DUPLICATE KEY UPDATE module_id = 'module_id'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->db->query(
|
||||
'ALTER TABLE ' . $this->db->prefixTable('api_keys') .
|
||||
' DROP FOREIGN KEY ' . $this->db->prefixTable('api_keys') . '_employee_id_foreign'
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
'DELETE FROM ' . $this->db->prefixTable('permissions') . " WHERE permission_id = 'api_keys'"
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
'DELETE FROM ' . $this->db->prefixTable('modules') . " WHERE module_id = 'api_keys'"
|
||||
);
|
||||
|
||||
$this->forge->dropTable('api_keys', true);
|
||||
}
|
||||
}
|
||||
48
app/Filters/ApiAuth.php
Normal file
48
app/Filters/ApiAuth.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Services;
|
||||
|
||||
class ApiAuth implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null): mixed
|
||||
{
|
||||
$apiKey = $request->getHeaderLine('X-API-Key');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
return $this->unauthorized('API key required');
|
||||
}
|
||||
|
||||
$apiKeyModel = model(ApiKey::class);
|
||||
$employeeId = $apiKeyModel->validateKey($apiKey);
|
||||
|
||||
if (!$employeeId) {
|
||||
return $this->unauthorized('Invalid or expired API key');
|
||||
}
|
||||
|
||||
$request->employeeId = $employeeId;
|
||||
Services::set('apiEmployeeId', $employeeId);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): mixed
|
||||
{
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function unauthorized(string $message): ResponseInterface
|
||||
{
|
||||
return Services::response()
|
||||
->setStatusCode(401)
|
||||
->setJSON([
|
||||
'success' => false,
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
}
|
||||
29
app/Language/en/Api_keys.php
Normal file
29
app/Language/en/Api_keys.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'module_api_keys' => 'API Keys',
|
||||
'module_desc_api_keys' => 'Manage API access keys for integrations.',
|
||||
'api_keys' => 'API Keys',
|
||||
'api_key' => 'API Key',
|
||||
'generate_key' => 'Generate API Key',
|
||||
'regenerate_key' => 'Regenerate',
|
||||
'revoke_key' => 'Revoke',
|
||||
'key_name' => 'Key Name',
|
||||
'key_prefix' => 'Key Prefix',
|
||||
'last_used' => 'Last Used',
|
||||
'created' => 'Created',
|
||||
'expires' => 'Expires',
|
||||
'never' => 'Never',
|
||||
'disabled' => 'Disabled',
|
||||
'key_generated' => 'API key generated successfully',
|
||||
'key_generation_failed' => 'Failed to generate API key',
|
||||
'key_revoked' => 'API key revoked successfully',
|
||||
'key_revoke_failed' => 'Failed to revoke API key',
|
||||
'key_regenerated' => 'API key regenerated successfully',
|
||||
'key_regeneration_failed' => 'Failed to regenerate API key',
|
||||
'copy_warning' => 'Copy this key now! It will not be shown again.',
|
||||
'no_keys' => 'No API keys have been generated yet.',
|
||||
'confirm_revoke' => 'Are you sure you want to revoke this API key? This action cannot be undone.',
|
||||
'confirm_regenerate' => 'Are you sure you want to regenerate this API key? The old key will immediately stop working.',
|
||||
'key_description' => 'API keys allow external applications to access your OSPOS data. Keep your keys secure and never share them publicly.',
|
||||
];
|
||||
@@ -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.",
|
||||
|
||||
@@ -88,9 +88,13 @@ class Sale_lib
|
||||
return $register_modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
private const ALLOWED_INVOICE_TYPES = [
|
||||
'invoice',
|
||||
'tax_invoice',
|
||||
'custom_invoice',
|
||||
'custom_tax_invoice'
|
||||
];
|
||||
|
||||
public function get_invoice_type_options(): array
|
||||
{
|
||||
$invoice_types = [];
|
||||
@@ -101,6 +105,11 @@ class Sale_lib
|
||||
return $invoice_types;
|
||||
}
|
||||
|
||||
public static function isValidInvoiceType(string $invoice_type): bool
|
||||
{
|
||||
return in_array($invoice_type, self::ALLOWED_INVOICE_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
|
||||
145
app/Models/ApiKey.php
Normal file
145
app/Models/ApiKey.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ApiKey extends Model
|
||||
{
|
||||
protected $table = 'api_keys';
|
||||
protected $primaryKey = 'api_key_id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $useSoftDeletes = false;
|
||||
protected $allowedFields = [
|
||||
'employee_id',
|
||||
'key_hash',
|
||||
'key_prefix',
|
||||
'name',
|
||||
'last_used',
|
||||
'expires_at',
|
||||
'disabled'
|
||||
];
|
||||
|
||||
protected $useTimestamps = false;
|
||||
protected $createdField = 'created';
|
||||
|
||||
private const KEY_PREFIX = 'ospos_';
|
||||
private const KEY_BYTES = 32;
|
||||
|
||||
public function generateKey(int $employeeId, ?string $name = null, ?string $expiresAt = null): string|false
|
||||
{
|
||||
$rawKey = bin2hex(random_bytes(self::KEY_BYTES));
|
||||
$apiKey = self::KEY_PREFIX . $rawKey;
|
||||
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
$keyPrefix = substr($apiKey, 0, 12);
|
||||
|
||||
$data = [
|
||||
'employee_id' => $employeeId,
|
||||
'key_hash' => $keyHash,
|
||||
'key_prefix' => $keyPrefix,
|
||||
'name' => $name,
|
||||
'expires_at' => $expiresAt
|
||||
];
|
||||
|
||||
if ($this->insert($data)) {
|
||||
return $apiKey;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function validateKey(string $apiKey): int|false
|
||||
{
|
||||
if (!str_starts_with($apiKey, self::KEY_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strlen($apiKey) !== strlen(self::KEY_PREFIX) + (self::KEY_BYTES * 2)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
|
||||
$builder = $this->builder();
|
||||
$builder->where('key_hash', $keyHash);
|
||||
$builder->where('disabled', 0);
|
||||
$builder->groupStart();
|
||||
$builder->where('expires_at IS NULL');
|
||||
$builder->orWhere('expires_at >', date('Y-m-d H:i:s'));
|
||||
$builder->groupEnd();
|
||||
|
||||
$result = $builder->get()->getRow();
|
||||
|
||||
if ($result) {
|
||||
$this->update($result->api_key_id, ['last_used' => date('Y-m-d H:i:s')]);
|
||||
return (int) $result->employee_id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getKeysForEmployee(int $employeeId): array
|
||||
{
|
||||
$builder = $this->builder();
|
||||
$builder->where('employee_id', $employeeId);
|
||||
$builder->orderBy('created', 'DESC');
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
public function revokeKey(int $apiKeyId, int $employeeId): bool
|
||||
{
|
||||
$builder = $this->builder();
|
||||
$builder->where('api_key_id', $apiKeyId);
|
||||
$builder->where('employee_id', $employeeId);
|
||||
|
||||
return $builder->update(['disabled' => 1]) !== false;
|
||||
}
|
||||
|
||||
public function regenerateKey(int $apiKeyId, int $employeeId): string|false
|
||||
{
|
||||
$existingKey = $this->builder()
|
||||
->getWhere([
|
||||
'api_key_id' => $apiKeyId,
|
||||
'employee_id' => $employeeId
|
||||
])
|
||||
->getRow();
|
||||
|
||||
if (!$existingKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newKey = $this->generateKey(
|
||||
$employeeId,
|
||||
$existingKey->name,
|
||||
$existingKey->expires_at
|
||||
);
|
||||
|
||||
if ($newKey) {
|
||||
$this->delete($apiKeyId);
|
||||
return $newKey;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function cleanupExpired(): int
|
||||
{
|
||||
$builder = $this->builder();
|
||||
$builder->where('disabled', 0);
|
||||
$builder->where('expires_at <', date('Y-m-d H:i:s'));
|
||||
$builder->where('expires_at IS NOT NULL');
|
||||
|
||||
$expiredKeys = $builder->get()->getResultArray();
|
||||
$count = 0;
|
||||
|
||||
foreach ($expiredKeys as $key) {
|
||||
if ($this->update($key['api_key_id'], ['disabled' => 1])) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -28,7 +28,7 @@
|
||||
"chartist-plugin-tooltips": "^0.0.17",
|
||||
"clipboard": "^2.0.11",
|
||||
"coffeescript": "^2.7.0",
|
||||
"dompurify": "^3.3.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"elegant-circles": "github:opensourcepos/elegant-circles#minified",
|
||||
"es6-promise": "^4.2.8",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -1483,10 +1483,13 @@
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"chartist-plugin-tooltips": "^0.0.17",
|
||||
"clipboard": "^2.0.11",
|
||||
"coffeescript": "^2.7.0",
|
||||
"dompurify": "^3.3.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"elegant-circles": "github:opensourcepos/elegant-circles#minified",
|
||||
"es6-promise": "^4.2.8",
|
||||
"file-saver": "^2.0.5",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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');
|
||||
}
|
||||
}
|
||||
91
tests/Filters/ApiAuthTest.php
Normal file
91
tests/Filters/ApiAuthTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Filters;
|
||||
|
||||
use App\Filters\ApiAuth;
|
||||
use App\Models\ApiKey;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use CodeIgniter\HTTP\Response;
|
||||
use Config\Services;
|
||||
|
||||
class ApiAuthTest extends CIUnitTestCase
|
||||
{
|
||||
protected ApiAuth $filter;
|
||||
protected ApiKey $apiKeyModel;
|
||||
protected int $testEmployeeId = 1;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->filter = new ApiAuth();
|
||||
$this->apiKeyModel = model(ApiKey::class);
|
||||
}
|
||||
|
||||
public function testBeforeWithNoApiKey(): void
|
||||
{
|
||||
$request = new IncomingRequest(Services::config(), Services::uri(), '');
|
||||
$request->setHeader('X-API-Key', '');
|
||||
|
||||
$result = $this->filter->before($request);
|
||||
|
||||
$this->assertInstanceOf(Response::class, $result);
|
||||
$this->assertEquals(401, $result->getStatusCode());
|
||||
|
||||
$body = json_decode($result->getBody(), true);
|
||||
$this->assertFalse($body['success']);
|
||||
$this->assertEquals('API key required', $body['message']);
|
||||
}
|
||||
|
||||
public function testBeforeWithInvalidApiKey(): void
|
||||
{
|
||||
$request = new IncomingRequest(Services::config(), Services::uri(), '');
|
||||
$request->setHeader('X-API-Key', 'ospos_invalidkey12345678901234567890123456789012345678');
|
||||
|
||||
$result = $this->filter->before($request);
|
||||
|
||||
$this->assertInstanceOf(Response::class, $result);
|
||||
$this->assertEquals(401, $result->getStatusCode());
|
||||
|
||||
$body = json_decode($result->getBody(), true);
|
||||
$this->assertFalse($body['success']);
|
||||
$this->assertEquals('Invalid or expired API key', $body['message']);
|
||||
}
|
||||
|
||||
public function testBeforeWithValidApiKey(): void
|
||||
{
|
||||
$employeeId = $this->testEmployeeId;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$request = new IncomingRequest(Services::config(), Services::uri(), '');
|
||||
$request->setHeader('X-API-Key', $rawKey);
|
||||
|
||||
$result = $this->filter->before($request);
|
||||
|
||||
$this->assertInstanceOf(IncomingRequest::class, $result);
|
||||
$this->assertEquals($employeeId, $result->employeeId);
|
||||
}
|
||||
|
||||
public function testAfterReturnsResponse(): void
|
||||
{
|
||||
$request = new IncomingRequest(Services::config(), Services::uri(), '');
|
||||
$response = new Response(Services::config());
|
||||
|
||||
$result = $this->filter->after($request, $response);
|
||||
|
||||
$this->assertInstanceOf(Response::class, $result);
|
||||
}
|
||||
|
||||
public function testEmployeeIdSetInService(): void
|
||||
{
|
||||
$employeeId = $this->testEmployeeId;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$request = new IncomingRequest(Services::config(), Services::uri(), '');
|
||||
$request->setHeader('X-API-Key', $rawKey);
|
||||
|
||||
$this->filter->before($request);
|
||||
|
||||
$this->assertEquals($employeeId, Services::get('apiEmployeeId'));
|
||||
}
|
||||
}
|
||||
191
tests/Models/ApiKeyTest.php
Normal file
191
tests/Models/ApiKeyTest.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Models;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use CodeIgniter\Test\DatabaseTestTrait;
|
||||
|
||||
class ApiKeyTest extends CIUnitTestCase
|
||||
{
|
||||
use DatabaseTestTrait;
|
||||
|
||||
protected $migrate = true;
|
||||
protected $migrateOnly = ['api_keys'];
|
||||
protected $refresh = true;
|
||||
|
||||
protected ApiKey $apiKeyModel;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->apiKeyModel = new ApiKey();
|
||||
}
|
||||
|
||||
public function testGenerateKey(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$name = 'Test API Key';
|
||||
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, $name);
|
||||
|
||||
$this->assertNotFalse($rawKey);
|
||||
$this->assertStringStartsWith('ospos_', $rawKey);
|
||||
$this->assertEquals(70, strlen($rawKey)); // ospos_ prefix (6 chars) + 64 hex chars
|
||||
|
||||
$keyInDb = $this->apiKeyModel->where('employee_id', $employeeId)
|
||||
->where('name', $name)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($keyInDb);
|
||||
$this->assertEquals(substr($rawKey, 0, 12), $keyInDb->key_prefix);
|
||||
$this->assertEquals(hash('sha256', $rawKey), $keyInDb->key_hash);
|
||||
}
|
||||
|
||||
public function testValidateKeySuccess(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$validatedEmployeeId = $this->apiKeyModel->validateKey($rawKey);
|
||||
|
||||
$this->assertEquals($employeeId, $validatedEmployeeId);
|
||||
}
|
||||
|
||||
public function testValidateKeyInvalidFormat(): void
|
||||
{
|
||||
$result = $this->apiKeyModel->validateKey('invalid_key');
|
||||
$this->assertFalse($result);
|
||||
|
||||
$result = $this->apiKeyModel->validateKey('ospos_short');
|
||||
$this->assertFalse($result);
|
||||
|
||||
$result = $this->apiKeyModel->validateKey('otherprefix_' . str_repeat('a', 64));
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testValidateKeyDisabled(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
$this->apiKeyModel->update($keyRecord->api_key_id, ['disabled' => 1]);
|
||||
|
||||
$result = $this->apiKeyModel->validateKey($rawKey);
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testValidateKeyExpired(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('-1 day'));
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key', $expiresAt);
|
||||
|
||||
$result = $this->apiKeyModel->validateKey($rawKey);
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
public function testValidateKeyNotExpired(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 day'));
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key', $expiresAt);
|
||||
|
||||
$result = $this->apiKeyModel->validateKey($rawKey);
|
||||
$this->assertEquals($employeeId, $result);
|
||||
}
|
||||
|
||||
public function testGetKeysForEmployee(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
|
||||
$this->apiKeyModel->generateKey($employeeId, 'Key 1');
|
||||
$this->apiKeyModel->generateKey($employeeId, 'Key 2');
|
||||
$this->apiKeyModel->generateKey($employeeId, 'Key 3');
|
||||
|
||||
$keys = $this->apiKeyModel->getKeysForEmployee($employeeId);
|
||||
|
||||
$this->assertCount(3, $keys);
|
||||
}
|
||||
|
||||
public function testRevokeKey(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
|
||||
$result = $this->apiKeyModel->revokeKey($keyRecord->api_key_id, $employeeId);
|
||||
$this->assertTrue($result);
|
||||
|
||||
$updatedKey = $this->apiKeyModel->find($keyRecord->api_key_id);
|
||||
$this->assertEquals(1, $updatedKey->disabled);
|
||||
|
||||
$validateResult = $this->apiKeyModel->validateKey($rawKey);
|
||||
$this->assertFalse($validateResult);
|
||||
}
|
||||
|
||||
public function testRevokeKeyWrongEmployee(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
|
||||
$result = $this->apiKeyModel->revokeKey($keyRecord->api_key_id, 999);
|
||||
|
||||
$updatedKey = $this->apiKeyModel->find($keyRecord->api_key_id);
|
||||
$this->assertEquals(0, $updatedKey->disabled);
|
||||
}
|
||||
|
||||
public function testRegenerateKey(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$oldKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
$oldKeyId = $keyRecord->api_key_id;
|
||||
|
||||
$newKey = $this->apiKeyModel->regenerateKey($oldKeyId, $employeeId);
|
||||
|
||||
$this->assertNotFalse($newKey);
|
||||
$this->assertNotEquals($oldKey, $newKey);
|
||||
|
||||
$oldKeyValid = $this->apiKeyModel->validateKey($oldKey);
|
||||
$this->assertFalse($oldKeyValid);
|
||||
|
||||
$newKeyValid = $this->apiKeyModel->validateKey($newKey);
|
||||
$this->assertEquals($employeeId, $newKeyValid);
|
||||
|
||||
$oldKeyExists = $this->apiKeyModel->find($oldKeyId);
|
||||
$this->assertNull($oldKeyExists);
|
||||
}
|
||||
|
||||
public function testKeyHashNotReversible(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
|
||||
$this->assertNotEquals($rawKey, $keyRecord->key_hash);
|
||||
$this->assertEquals(64, strlen($keyRecord->key_hash));
|
||||
}
|
||||
|
||||
public function testLastUsedUpdatesOnValidation(): void
|
||||
{
|
||||
$employeeId = 1;
|
||||
$rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key');
|
||||
|
||||
$keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first();
|
||||
$this->assertNull($keyRecord->last_used);
|
||||
|
||||
sleep(1);
|
||||
|
||||
$this->apiKeyModel->validateKey($rawKey);
|
||||
|
||||
$keyRecord = $this->apiKeyModel->find($keyRecord->api_key_id);
|
||||
$this->assertNotNull($keyRecord->last_used);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user