diff --git a/.github/workflows/workflows.yml b/.github/workflows/workflows.yml index 6a1f310..f098458 100644 --- a/.github/workflows/workflows.yml +++ b/.github/workflows/workflows.yml @@ -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: @@ -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 diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache new file mode 100644 index 0000000..a57ad9e --- /dev/null +++ b/.php-cs-fixer.cache @@ -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"}} \ No newline at end of file diff --git a/README.md b/README.md index 19cb8dc..0011767 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/Events/ProductsPurchased.php b/app/Events/ProductsPurchased.php new file mode 100644 index 0000000..e5bb5fa --- /dev/null +++ b/app/Events/ProductsPurchased.php @@ -0,0 +1,19 @@ +purchase = $purchase; + } +} diff --git a/app/Events/ProductsSold.php b/app/Events/ProductsSold.php new file mode 100644 index 0000000..de3da37 --- /dev/null +++ b/app/Events/ProductsSold.php @@ -0,0 +1,19 @@ +sale = $sale; + } +} diff --git a/app/Exports/InventoryExport.php b/app/Exports/InventoryExport.php index 073ef7d..026cf20 100644 --- a/app/Exports/InventoryExport.php +++ b/app/Exports/InventoryExport.php @@ -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); @@ -100,7 +91,6 @@ public function headings(): array public function map($inventory): array { $product = $inventory->product; - $price = $inventory->price; return [ $inventory->id, @@ -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'), diff --git a/app/Http/Controllers/InventoryController.php b/app/Http/Controllers/InventoryController.php index 29b32ef..bd3071b 100644 --- a/app/Http/Controllers/InventoryController.php +++ b/app/Http/Controllers/InventoryController.php @@ -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']; } } diff --git a/app/Http/Controllers/PurchaseController.php b/app/Http/Controllers/PurchaseController.php index ee61798..9f25d5d 100644 --- a/app/Http/Controllers/PurchaseController.php +++ b/app/Http/Controllers/PurchaseController.php @@ -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 { @@ -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); + } } diff --git a/app/Http/Controllers/SalesController.php b/app/Http/Controllers/SaleController.php similarity index 50% rename from app/Http/Controllers/SalesController.php rename to app/Http/Controllers/SaleController.php index d7f338e..afb5114 100644 --- a/app/Http/Controllers/SalesController.php +++ b/app/Http/Controllers/SaleController.php @@ -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 { @@ -56,42 +56,8 @@ 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; } @@ -99,17 +65,13 @@ protected function beforeStore(Request $request, $model) 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); } } diff --git a/app/Listeners/CreateInventory.php b/app/Listeners/CreateInventory.php new file mode 100644 index 0000000..d2401a0 --- /dev/null +++ b/app/Listeners/CreateInventory.php @@ -0,0 +1,33 @@ + $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); + } + } +} diff --git a/app/Listeners/CreateProduct.php b/app/Listeners/CreateProduct.php new file mode 100644 index 0000000..2f0839e --- /dev/null +++ b/app/Listeners/CreateProduct.php @@ -0,0 +1,37 @@ + $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); + } + } +} diff --git a/app/Listeners/UpdateInventory.php b/app/Listeners/UpdateInventory.php new file mode 100644 index 0000000..90edf21 --- /dev/null +++ b/app/Listeners/UpdateInventory.php @@ -0,0 +1,31 @@ +sale)) { + $inventory = $event->sale->product->inventory; + $inventory->quantity -= $event->sale->quantity; + $inventory->save(); + } elseif (isset($event->purchase)) { + $inventory = $event->purchase->product->inventory; + $inventory->quantity += $event->purchase->quantity; + $inventory->save(); + } + } + + public function asListener(...$parameters): void + { + $event = $parameters[0]; + if ($event->sale || ($event->purchase && $event->purchase->product?->inventory)) { + $this->handle($event); + } + } +} diff --git a/app/Listeners/UpdateProduct.php b/app/Listeners/UpdateProduct.php new file mode 100644 index 0000000..10a0d1b --- /dev/null +++ b/app/Listeners/UpdateProduct.php @@ -0,0 +1,37 @@ +product_id); + + if ($product) { + $product->update([ + 'name' => isset($purchase->meta['name']) ? $purchase->meta['name'] : $product->name, + 'description' => isset($purchase->meta['description']) ? $purchase->meta['description'] : $product->description, + 'color' => isset($purchase->meta['color']) ? $purchase->meta['color'] : $product->color, + 'image_url' => isset($purchase->meta['image_url']) ? $purchase->meta['image_url'] : $product->image_url, + 'price' => isset($purchase->meta['price']) ? $purchase->meta['price'] : $product->price, + ]); + } + } + + public function asListener(...$parameters): void + { + $event = $parameters[0]; + $purchase = $event->purchase; + + if ($purchase->meta) { + $this->handle($purchase); + } + } +} diff --git a/app/Models/Inventory.php b/app/Models/Inventory.php index d19cf16..9299687 100644 --- a/app/Models/Inventory.php +++ b/app/Models/Inventory.php @@ -19,12 +19,10 @@ class Inventory extends Model 'product_id', 'quantity', 'storage_location', - 'price', 'last_stocked_at', ]; protected $casts = [ - 'price' => 'decimal:2', 'last_stocked_at' => 'datetime', ]; @@ -35,20 +33,4 @@ public function product(): BelongsTo { return $this->belongsTo(Product::class); } - - // /** - // * Get the price per unit (amount / quantity) - // */ - // public function getPricePerUnitAttribute() - // { - // return $this->quantity > 0 ? $this->amount / $this->quantity : 0; - // } - - // /** - // * Set the amount based on quantity and price per unit - // */ - // public function setPricePerUnitAttribute($value) - // { - // $this->amount = $this->quantity * $value; - // } } diff --git a/app/Models/Product.php b/app/Models/Product.php index 6268cfa..2fdcc1c 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Models\Sale; +use App\Models\Purchase; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\HasOne; @@ -31,7 +33,7 @@ class Product extends Model */ public function sales(): HasMany { - return $this->hasMany(Sales::class); + return $this->hasMany(Sale::class); } /** diff --git a/app/Models/Sales.php b/app/Models/Sale.php similarity index 97% rename from app/Models/Sales.php rename to app/Models/Sale.php index f71dbde..6e3650f 100644 --- a/app/Models/Sales.php +++ b/app/Models/Sale.php @@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Factories\HasFactory; -class Sales extends Model +class Sale extends Model { use HasFactory; use SoftDeletes; diff --git a/app/Models/User.php b/app/Models/User.php index 91d6b4c..6043484 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,14 +2,16 @@ namespace App\Models; -use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Models\Sale; +use App\Models\Purchase; use Laravel\Sanctum\HasApiTokens; -use Illuminate\Notifications\Notifiable; -use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Foundation\Auth\User as Authenticatable; use Spatie\Permission\Traits\HasRoles; +use Illuminate\Notifications\Notifiable; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable implements MustVerifyEmail { @@ -57,17 +59,11 @@ protected function casts(): array ]; } - /** - * Get the sales records for this user. - */ public function sales(): HasMany { - return $this->hasMany(Sales::class); + return $this->hasMany(Sale::class); } - /** - * Get the purchases records for this user. - */ public function purchases(): HasMany { return $this->hasMany(Purchase::class); diff --git a/app/Policies/SalesPolicy.php b/app/Policies/SalePolicy.php similarity index 78% rename from app/Policies/SalesPolicy.php rename to app/Policies/SalePolicy.php index b21634c..b90d8f2 100644 --- a/app/Policies/SalesPolicy.php +++ b/app/Policies/SalePolicy.php @@ -3,10 +3,10 @@ namespace App\Policies; use App\Models\User; -use App\Models\Sales; +use App\Models\Sale; use Illuminate\Auth\Access\HandlesAuthorization; -class SalesPolicy +class SalePolicy { use HandlesAuthorization; @@ -36,7 +36,7 @@ public function viewAny(User $user): bool /** * Determine whether the user can view the model. */ - public function view(User $user, Sales $sales): bool + public function view(User $user, Sale $Sale): bool { return true; } @@ -52,7 +52,7 @@ public function create(User $user): bool /** * Determine whether the user can update the model. */ - public function update(User $user, Sales $sales): bool + public function update(User $user, Sale $Sale): bool { return true; } @@ -60,7 +60,7 @@ public function update(User $user, Sales $sales): bool /** * Determine whether the user can delete the model. */ - public function delete(User $user, Sales $sales): bool + public function delete(User $user, Sale $Sale): bool { return true; } @@ -68,7 +68,7 @@ public function delete(User $user, Sales $sales): bool /** * Determine whether the user can restore the model. */ - public function restore(User $user, Sales $sales): bool + public function restore(User $user, Sale $Sale): bool { return true; } @@ -76,7 +76,7 @@ public function restore(User $user, Sales $sales): bool /** * Determine whether the user can permanently delete the model. */ - public function forceDelete(User $user, Sales $sales): bool + public function forceDelete(User $user, Sale $Sale): bool { return true; } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 8a437a5..f9e630b 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,12 +2,22 @@ namespace App\Providers; +use App\Events\UserCreated; +use App\Events\ProductsSold; +use App\Listeners\CreateProduct; +use App\Listeners\UpdateProduct; +use App\Events\ProductsPurchased; +use App\Listeners\CreateInventory; +use App\Listeners\UpdateInventory; use Illuminate\Support\ServiceProvider; +use App\Listeners\SendUserVerificationNotifications; class EventServiceProvider extends ServiceProvider { protected $listen = [ - \App\Events\UserCreated::class => [\App\Listeners\SendUserVerificationNotifications::class], + UserCreated::class => [SendUserVerificationNotifications::class], + ProductsSold::class => [UpdateInventory::class], + ProductsPurchased::class => [CreateProduct::class, UpdateProduct::class, CreateInventory::class, UpdateInventory::class] ]; /** diff --git a/database/factories/InventoryFactory.php b/database/factories/InventoryFactory.php index 20ced2a..fa5c0b2 100644 --- a/database/factories/InventoryFactory.php +++ b/database/factories/InventoryFactory.php @@ -21,7 +21,6 @@ public function definition(): array 'product_id' => Product::factory(), 'quantity' => fake()->numberBetween(1, 1000), 'storage_location' => fake()->randomElement(['Warehouse A', 'Warehouse B', 'Shelf 1', 'Shelf 2']), - 'price' => fake()->randomFloat(2, 0.5, 50), 'last_stocked_at' => fake()->dateTimeBetween('-30 days', 'now'), ]; } diff --git a/database/factories/SalesFactory.php b/database/factories/SaleFactory.php similarity index 95% rename from database/factories/SalesFactory.php rename to database/factories/SaleFactory.php index cbe9daa..f01118e 100644 --- a/database/factories/SalesFactory.php +++ b/database/factories/SaleFactory.php @@ -8,7 +8,7 @@ /** * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Inventory> */ -class SalesFactory extends Factory +class SaleFactory extends Factory { /** * Define the model's default state. diff --git a/database/migrations/2025_08_08_131332_create_inventory_table.php b/database/migrations/2025_08_08_131332_create_inventory_table.php index 5ca52f8..58f47fb 100644 --- a/database/migrations/2025_08_08_131332_create_inventory_table.php +++ b/database/migrations/2025_08_08_131332_create_inventory_table.php @@ -16,7 +16,6 @@ public function up(): void $table->foreignId('product_id')->constrained()->onDelete('cascade'); $table->integer('quantity')->default(0); $table->string('storage_location'); - $table->decimal('price', 10, 2); $table->timestamp('last_stocked_at')->nullable(); $table->timestamps(); $table->softDeletes(); diff --git a/database/migrations/2025_08_08_193356_create_purchases_table.php b/database/migrations/2025_08_08_193356_create_purchases_table.php index 5882c5f..e484772 100644 --- a/database/migrations/2025_08_08_193356_create_purchases_table.php +++ b/database/migrations/2025_08_08_193356_create_purchases_table.php @@ -13,7 +13,7 @@ public function up(): void { Schema::create('purchases', function (Blueprint $table) { $table->id(); - $table->integer('product_id'); + $table->integer('product_id')->nullable(); $table->integer('user_id'); $table->string('supplier'); $table->string('manufacturer'); diff --git a/routes/orion.php b/routes/orion.php index 4718385..b606429 100644 --- a/routes/orion.php +++ b/routes/orion.php @@ -6,7 +6,7 @@ use App\Http\Controllers\InventoryController; use App\Http\Controllers\UserController; use App\Http\Controllers\PurchaseController; -use App\Http\Controllers\SalesController; +use App\Http\Controllers\SaleController; use App\Actions\ExportInventory; Route::middleware('auth:sanctum')->group(function () { @@ -14,7 +14,7 @@ Orion::resource('inventory', InventoryController::class); Orion::resource('users', UserController::class)->only('index', 'search', 'show', 'update'); Orion::resource('purchases', PurchaseController::class); - Orion::resource('sales', SalesController::class); + Orion::resource('sales', SaleController::class); }); diff --git a/tests/Feature/AuthTest.php b/tests/Feature/AuthTest.php new file mode 100644 index 0000000..d5e5aec --- /dev/null +++ b/tests/Feature/AuthTest.php @@ -0,0 +1,24 @@ +create(['email_verified_at' => null, 'phone' => '+1234567890']); + event(new UserCreated($user)); + Notification::assertSentTo($user, SendPhoneVerificationNotification::class); + $this->assertFalse($user->hasVerifiedEmail()); + } +} diff --git a/tests/Feature/ExportInventoryTest.php b/tests/Feature/ExportInventoryTest.php index 94b120e..8cc3d5f 100644 --- a/tests/Feature/ExportInventoryTest.php +++ b/tests/Feature/ExportInventoryTest.php @@ -35,7 +35,6 @@ public function test_export_inventory_without_filters() 'product_id' => $product->id, 'quantity' => 100, 'storage_location' => 'Warehouse A', - 'price' => 800.00, 'last_stocked_at' => now() ]); @@ -118,27 +117,6 @@ public function test_export_inventory_with_storage_location_filter() $response->assertStatus(200); } - public function test_export_inventory_with_price_range_filter() - { - $product = Product::factory()->create(); - Inventory::factory()->create([ - 'product_id' => $product->id, - 'price' => 5.00 - ]); - Inventory::factory()->create([ - 'product_id' => $product->id, - 'price' => 25.00 - ]); - - $response = $this->actingAs($this->user) - ->post('/api/inventory/export', [ - 'min_price' => 10.00, - 'max_price' => 30.00 - ]); - - $response->assertStatus(200); - } - public function test_export_inventory_with_date_range_filter() { $product = Product::factory()->create(); diff --git a/tests/Feature/PurchaseTest.php b/tests/Feature/PurchaseTest.php index 50ac9c2..736a7d8 100644 --- a/tests/Feature/PurchaseTest.php +++ b/tests/Feature/PurchaseTest.php @@ -3,9 +3,16 @@ namespace Tests\Feature; use Tests\TestCase; -use App\Models\Purchase; use App\Models\User; use App\Models\Product; +use App\Models\Purchase; +use App\Models\Inventory; +use App\Events\ProductsPurchased; +use App\Listeners\CreateInventory; +use App\Listeners\CreateProduct; +use App\Listeners\UpdateInventory; +use App\Listeners\UpdateProduct; +use Illuminate\Support\Facades\Event; use Illuminate\Foundation\Testing\RefreshDatabase; class PurchaseTest extends TestCase @@ -71,4 +78,85 @@ public function test_delete_purchase() $response = $this->actingAs($this->user)->deleteJson('/api/purchases/' . $purchase->id); $response->assertOk(); } + + public function test_create_product_and_inventory_on_products_purchased() + { + Event::fake(); + $data = [ + 'product_id' => null, + 'supplier' => 'Test Supplier', + 'manufacturer' => 'Test Manufacturer', + 'cost_per_unit' => 5.00, + 'amount' => 50.00, + 'quantity' => 10, + 'meta' => [ + 'name' => 'Test Product', + 'description' => 'desc', + 'color' => 'blue', + 'image_url' => 'http://example.com/img.jpg', + 'price' => 5, + 'storage_location' => 'A1', + ] + ]; + + $response = $this->actingAs($this->user)->postJson('/api/purchases', $data); + $response->assertStatus(201)->assertJsonStructure(['data']); + $purchase = Purchase::find($response->getData()->data->id); + + Event::assertDispatched(ProductsPurchased::class); + $event = new ProductsPurchased($purchase); + (new CreateProduct())->handle($event->purchase); + (new CreateInventory())->handle($event->purchase); + + $purchase->refresh(); + $this->assertNotNull($purchase->product_id); + $product = Product::find($purchase->product_id); + $this->assertEquals('Test Product', $product->name); + $inventory = Inventory::where('product_id', $product->id)->first(); + $this->assertEquals(10, $inventory->quantity); + } + + public function test_update_product_and_inventory_on_products_purchased() + { + Event::fake(); + + $product = Product::factory()->create([ + 'name' => 'Product', + 'description' => 'desc', + 'color' => 'blue', + 'image_url' => 'http://example.com/img.jpg', + 'price' => 10 + ]); + + $inventory = Inventory::factory()->create([ + 'product_id' => $product->id, + 'quantity' => 10, + 'storage_location' => 'A', + 'last_stocked_at' => now() + ]); + + $data = [ + 'product_id' => $product->id, + 'supplier' => 'Test Supplier', + 'manufacturer' => 'Test Manufacturer', + 'cost_per_unit' => 5.00, + 'amount' => 50.00, + 'quantity' => 10, + 'meta' => [ + 'name' => 'Updated Product' + ] + ]; + + $response = $this->actingAs($this->user)->postJson('/api/purchases', $data); + $response->assertStatus(201)->assertJsonStructure(['data']); + $purchase = Purchase::find($response->getData()->data->id); + + Event::assertDispatched(ProductsPurchased::class); + $event = new ProductsPurchased($purchase); + (new UpdateProduct())->handle($event->purchase); + (new UpdateInventory())->handle($event); + + $this->assertEquals('Updated Product', $product->fresh()->name); + $this->assertEquals(20, $inventory->fresh()->quantity); + } } diff --git a/tests/Feature/SalesTest.php b/tests/Feature/SaleTest.php similarity index 56% rename from tests/Feature/SalesTest.php rename to tests/Feature/SaleTest.php index e67b75d..2348a04 100644 --- a/tests/Feature/SalesTest.php +++ b/tests/Feature/SaleTest.php @@ -3,12 +3,16 @@ namespace Tests\Feature; use Tests\TestCase; -use App\Models\Sales; +use App\Models\Sale; use App\Models\User; use App\Models\Product; +use App\Models\Inventory; +use App\Events\ProductsSold; +use App\Listeners\UpdateInventory; +use Illuminate\Support\Facades\Event; use Illuminate\Foundation\Testing\RefreshDatabase; -class SalesTest extends TestCase +class SaleTest extends TestCase { use RefreshDatabase; @@ -20,24 +24,24 @@ protected function setUp(): void $this->user = User::factory()->create(); } - public function test_index_sales() + public function test_index_Sale() { - Sales::factory()->count(3)->create(); + Sale::factory()->count(3)->create(); $response = $this->actingAs($this->user)->getJson('/api/sales'); $response->assertOk()->assertJsonStructure(['data']); } - public function test_search_sales() + public function test_search_Sale() { $product = Product::factory()->create(); - Sales::factory()->create(['product_id' => $product->id]); + Sale::factory()->create(['product_id' => $product->id]); $response = $this->actingAs($this->user)->postJson('/api/sales/search', ['product_id' => $product->id]); $response->assertOk()->assertJsonStructure(['data']); } public function test_show_sale() { - $sale = Sales::factory()->create(); + $sale = Sale::factory()->create(); $response = $this->actingAs($this->user)->getJson('/api/sales/' . $sale->id); $response->assertOk()->assertJsonStructure(['data']); } @@ -59,15 +63,39 @@ public function test_store_sale() public function test_update_sale() { - $sale = Sales::factory()->create(); + $sale = Sale::factory()->create(); $response = $this->actingAs($this->user)->putJson('/api/sales/' . $sale->id, ['quantity' => 5]); $response->assertOk()->assertJsonStructure(['data']); } public function test_delete_sale() { - $sale = Sales::factory()->create(); + $sale = Sale::factory()->create(); $response = $this->actingAs($this->user)->deleteJson('/api/sales/' . $sale->id); $response->assertOk(); } + + public function test_update_inventory_on_products_sold() + { + Event::fake(); + + $product = Product::factory()->create(['price' => 10]); + $inventory = Inventory::factory()->create(['product_id' => $product->id, 'quantity' => 20]); + $data = [ + 'product_id' => $product->id, + 'quantity' => 5, + 'user_id' => $this->user->id, + 'action' => 'sold', + 'meta' => [] + ]; + + $response = $this->actingAs($this->user)->postJson('/api/sales', $data); + $sale = Sale::find($response->getData()->data->id); + + $response->assertStatus(201)->assertJsonStructure(['data']); + Event::assertDispatched(ProductsSold::class); + + (new UpdateInventory())->handle(new ProductsSold($sale)); + $this->assertEquals(15, $inventory->fresh()->quantity); + } }