Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/workflows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
- develop

permissions:
contents: write # Needed to push code style changes back to the PR branch
contents: read

jobs:
format-and-test:
Expand Down Expand Up @@ -67,7 +67,7 @@ jobs:
# git commit -m "Apply PHP-CS-Fixer formatting"
# git push
# fi

- name: Prepare environment file
run: cp .env.example .env

Expand Down
1 change: 1 addition & 0 deletions .php-cs-fixer.cache
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"php":"8.4.11","version":"3.85.1:v3.85.1#2fb6d7f6c3398dca5786a1635b27405d73a417ba","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"new_with_parentheses":true,"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"visibility_required":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true},"hashes":{"app\/Providers\/AppServiceProvider.php":"50869d5235b70741292b4181483fb3a1","app\/Providers\/TelescopeServiceProvider.php":"ebda267f825b9bdb9506b4d36861d190","app\/Providers\/AuthServiceProvider.php":"21d735c2b7b695e9187e769ee9b00a63","app\/Providers\/RouteServiceProvider.php":"2c8cf6e7e10996320f03213cf6fd80c9","app\/Providers\/HorizonServiceProvider.php":"3e77ce1476fe086c39ab902a16466867","app\/Providers\/EventServiceProvider.php":"dfffc1fc5bc838784e1fd61a6be2530b","app\/Models\/Sale.php":"3185fb625054cb0307c381f2a95b6031","app\/Models\/Inventory.php":"bcf3b8fdff335bb1bea129368f16dd49","app\/Models\/Product.php":"c4bf07831d39485629a31bb9a6eae008","app\/Models\/Purchase.php":"0c8e2d61f21462761a4759d312f1fc7b","app\/Models\/User.php":"34e86f5ca68b584e124e81eb3d3f45e1","app\/Models\/Role.php":"26c273b5302f20e7ba86cb377d5ca30d","app\/Models\/Permission.php":"ec9f1430ebf6d98abb1e496e6909e9f7","app\/Saloon\/Connectors\/TextbeltConnector.php":"bee6b70c66a9efc22add60d9fe33d88d","app\/Saloon\/Middlewares\/SaloonLogger.php":"105c98edd63c4b3d8319efd1a0ffd598","app\/Saloon\/Requests\/VerifyTextbeltOTP.php":"6ab3989a6d5267bf63a1618a8ed1a6f6","app\/Saloon\/Requests\/GenerateTextbeltOTP.php":"bc22c4cafdc3bf9687470863645bfe00","app\/Policies\/InventoryPolicy.php":"85fc6635cb57c108d4b0484fb08b0b9a","app\/Policies\/PurchasePolicy.php":"550ab44c86fa6257bcb5f61e1ef19a02","app\/Policies\/ProductPolicy.php":"0384857d1d1ddf1670fd59f13ac08e97","app\/Policies\/UserPolicy.php":"73d992a08e36de2193c252f6f85851a6","app\/Policies\/SalePolicy.php":"6e573fe5e8875e729f648cbf9ece3b34","app\/Exports\/InventoryExport.php":"702cfefafec0fa02f6904cbc03b4ce95","app\/Http\/Middleware\/ApiLocalization.php":"7df372cb93193297702f9d03c13ace76","app\/Http\/Controllers\/UserController.php":"a710cd139d253bb7ecc39416351a85b2","app\/Http\/Controllers\/PurchaseController.php":"5bf4177505312ad861d9322c20235e92","app\/Http\/Controllers\/AuthController.php":"8692d4e1825a7018424ddac6f06bbfee","app\/Http\/Controllers\/SaleController.php":"bf091ecb4c4dd6445db5cec7672e9d97","app\/Http\/Controllers\/InventoryController.php":"0a1c7d667146b857d85cbbd7b216065c","app\/Http\/Controllers\/ProductController.php":"1f2f570957063b7c0fe4c99878af2b6c","app\/Http\/Kernel.php":"a2c118690be1d63f9988bbea60017c8d","app\/Actions\/ExportInventory.php":"4fe02c44c47a9b9b485507433799089e","app\/Events\/ProductsSold.php":"bc0c0bc7ca203bbf7612f4cb913efb01","app\/Events\/UserCreated.php":"9c854425a69334cddb46c752065d8988","app\/Events\/ProductsPurchased.php":"89ccd011c461d49b9fad2a45c5f096ed","app\/Listeners\/SendUserVerificationNotifications.php":"995583e1153f276af906acf46afd07e5","app\/Listeners\/UpdateProduct.php":"3185252ba2085b5cbc2224f1b86498f1","app\/Listeners\/CreateProduct.php":"34047b09e33f77d6e129261a226305f4","app\/Listeners\/UpdateInventory.php":"37ec49ce5f4791608f63893eb5c57c27","app\/Listeners\/CreateInventory.php":"e1f8f5c6f886f0c8d3edbc5c7798c846","app\/Notifications\/SendPhoneVerificationNotification.php":"61d4cb57170fd42a8dc83e7bbaaba612","app\/Notifications\/Contracts\/TextbeltMessage.php":"eae9bb4b7182aab740cbea159c516888","app\/Notifications\/Channels\/TextbeltChannel.php":"c47379b8dd0b252de39b7992da7d3c01"}}
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ A Laravel-based Inventory Management System for managing products, inventory, sa
- Product management
- Inventory tracking
- Sales and purchase records
- User authentication including Mail Address (Laravel Auth) and Phone Number (Textbelt)
- User authorization using Spatie Permissions
- User authentication
- User authorization using Spatie Role/Permissions
- Export Inventory with Filters as Excel Sheet (Laravel Excel)
- Email Address Verification via Laravel Auth
- Phone Number verificartion via Textbelt
- Email Address Verification (Laravel Auth)
- Phone Number verificartion (Textbelt)
- AWS S3 for Cloud Storage (supabase)
- Role and permission management
- RESTful API endpoints
- Localization support (ar, en, es, fr)
- Orion/API endpoints
- Localization support (ar, en)

## Requirements

Expand Down
19 changes: 19 additions & 0 deletions app/Events/ProductsPurchased.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace App\Events;

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductsPurchased
{
use Dispatchable;
use SerializesModels;

public $purchase;

public function __construct($purchase)
{
$this->purchase = $purchase;
}
}
19 changes: 19 additions & 0 deletions app/Events/ProductsSold.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace App\Events;

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductsSold
{
use Dispatchable;
use SerializesModels;

public $sale;

public function __construct($sale)
{
$this->sale = $sale;
}
}
14 changes: 2 additions & 12 deletions app/Exports/InventoryExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,6 @@ public function collection(): Collection
$query->where('quantity', '<=', $this->request->max_quantity);
}

// Apply price range filters if provided
if ($this->request->has('min_price') && $this->request->min_price !== null) {
$query->where('price', '>=', $this->request->min_price);
}

if ($this->request->has('max_price') && $this->request->max_price !== null) {
$query->where('price', '<=', $this->request->max_price);
}

// Apply date filters if provided
if ($this->request->has('date_from') && $this->request->date_from) {
$query->where('last_stocked_at', '>=', $this->request->date_from);
Expand Down Expand Up @@ -100,7 +91,6 @@ public function headings(): array
public function map($inventory): array
{
$product = $inventory->product;
$price = $inventory->price;

return [
$inventory->id,
Expand All @@ -110,8 +100,8 @@ public function map($inventory): array
$product ? number_format($product->price, 2) : 'N/A',
$inventory->storage_location ?: 'N/A',
$inventory->quantity,
number_format($price, 2),
number_format($price * $inventory->quantity, 2),
$product ? number_format($product->price, 2) : 'N/A',
$product ? number_format($product->price * $inventory->quantity, 2) : 'N/A',
$inventory->last_stocked_at ? $inventory->last_stocked_at->format('Y-m-d H:i:s') : 'N/A',
$inventory->created_at->format('Y-m-d H:i:s'),
$inventory->updated_at->format('Y-m-d H:i:s'),
Expand Down
6 changes: 3 additions & 3 deletions app/Http/Controllers/InventoryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@ public function includes(): array

public function filterableBy(): array
{
return ['id', 'product_id', 'quantity', 'storage_location', 'price', 'last_stocked_at'];
return ['id', 'product_id', 'quantity', 'storage_location', 'last_stocked_at'];
}

public function searchableBy(): array
{
return ['id', 'product_id', 'quantity', 'storage_location', 'price', 'last_stocked_at'];
return ['id', 'product_id', 'quantity', 'storage_location', 'last_stocked_at'];
}

public function sortableBy(): array
{
return ['id', 'product_id', 'quantity', 'price', 'last_stocked_at'];
return ['id', 'product_id', 'quantity', 'last_stocked_at'];
}
}
45 changes: 9 additions & 36 deletions app/Http/Controllers/PurchaseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@

namespace App\Http\Controllers;

use App\Events\ProductsPurchased;
use App\Models\Purchase;
use Illuminate\Http\Request;
use App\Policies\PurchasePolicy;
use Illuminate\Support\Facades\Auth;
use Orion\Http\Controllers\Controller;
use Illuminate\Database\Eloquent\Model;

class PurchaseController extends Controller
{
protected $model = Purchase::class;
protected $policy = \App\Policies\PurchasePolicy::class;
protected $policy = PurchasePolicy::class;

public function includes(): array
{
Expand Down Expand Up @@ -58,45 +61,15 @@ public function searchableBy(): array
];
}

/**
* The attributes that are allowed for creating a new model.
*/
public function creatableFields(): array
{
return [
'product_id',
'user_id',
'supplier',
'manufacturer',
'cost_per_unit',
'amount',
'meta',
'quantity'
];
}

/**
* The attributes that are allowed for updating a model.
*/
public function updatableFields(): array
{
return [
'product_id',
'user_id',
'supplier',
'manufacturer',
'cost_per_unit',
'amount',
'meta',
'quantity'
];
}

protected function beforeStore(Request $request, $model)
{
// Set user_id to authenticated user if not provided
if (!$request->has('user_id')) {
$model->user_id = Auth::user()->id;
}
}

protected function afterStore(Request $request, Model $entity)
{
ProductsPurchased::dispatch($entity);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@

namespace App\Http\Controllers;

use App\Models\Sales;
use App\Events\ProductsSold;
use App\Models\Sale;
use App\Models\Product;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Orion\Http\Controllers\Controller;
use Orion\Http\Requests\Request as RequestsRequest;
use Orion\Http\Requests\Request;

class SalesController extends Controller
class SaleController extends Controller
{
protected $model = Sales::class;
protected $policy = \App\Policies\SalesPolicy::class;
protected $model = Sale::class;
protected $policy = \App\Policies\SalePolicy::class;

public function includes(): array
{
Expand Down Expand Up @@ -56,60 +56,22 @@ public function searchableBy(): array
];
}

/**
* The attributes that are allowed for creating a new model.
*/
public function creatableFields(): array
{
return [
'product_id',
'user_id',
'meta',
'quantity',
'amount',
'action'
];
}

/**
* The attributes that are allowed for updating a model.
*/
public function updatableFields(): array
{
return [
'product_id',
'user_id',
'meta',
'quantity',
'amount',
'action'
];
}

/**
* Handle any actions before storing the model
*/
protected function beforeStore(Request $request, $model)
{
// Set user_id to authenticated user if not provided
if (!$request->has('user_id')) {
$model->user_id = Auth::user()->id;
}

if ($request->has('product_id') && !$request->has('amount')) {
$product = Product::find($request->product_id);
if ($product) {
$model->amount = $model->quantity * $product->price;
$model->amount = $request->quantity * $product->price;
}
}
}

protected function afterStore(RequestsRequest $request, Model $entity)
protected function afterStore(Request $request, Model $entity)
{
$inventory = $entity->product->inventory;
if ($inventory) {
$inventory->quantity -= $entity->quantity;
$inventory->save();
}
ProductsSold::dispatch($entity);
}
}
33 changes: 33 additions & 0 deletions app/Listeners/CreateInventory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Listeners;

use App\Models\Inventory;

class CreateInventory
{
public function __construct()
{
//
}

public function handle($purchase): void
{
Inventory::create([
'product_id' => $purchase->product_id,
'quantity' => $purchase->quantity,
'storage_location' => $purchase->meta['storage_location'],
'last_stocked_at' => now(),
]);
}

public function asListener(...$parameters): void
{
$event = $parameters[0];
$purchase = $event->purchase;

if (!$purchase->product?->inventory) {
$this->handle($purchase);
}
}
}
37 changes: 37 additions & 0 deletions app/Listeners/CreateProduct.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace App\Listeners;

use App\Models\Product;

class CreateProduct
{
public function __construct()
{
//
}

public function handle($purchase): void
{
$product = Product::create([
'name' => $purchase->meta['name'],
'description' => $purchase->meta['description'],
'color' => $purchase->meta['color'],
'image_url' => $purchase->meta['image_url'],
'price' => $purchase->meta['price'],
]);

$purchase->product_id = $product->id;
$purchase->save();
}

public function asListener(...$parameters): void
{
$event = $parameters[0];
$purchase = $event->purchase;

if (!$purchase->product_id) {
$this->handle($purchase);
}
}
}
Loading