diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php new file mode 100644 index 0000000..891ecbb --- /dev/null +++ b/app/Http/Controllers/ProductController.php @@ -0,0 +1,75 @@ +with(['category', 'brand', 'unitType', 'warehouses', 'creator'])->paginate(20); + } + + public function show(Product $product) + { + Gate::authorize('view', $product); + + return $product->load(['category', 'brand', 'unitType', 'warehouses', 'creator', 'latestPrice', 'prices', 'attributes']); + } + + public function store(StoreProductRequest $request) + { + Gate::authorize('create', Product::class); + + DB::transaction(function () use ($request) { + $product = Product::create($request->validated()); + + $product->prices()->create(['price' => $request->price]); + + $product->attributes()->attach($request->input('attributes')); + }); + + return response()->json(['message' => 'Product created successfully'], 201); + } + + public function update(StoreProductRequest $request, Product $product) + { + Gate::authorize('update', $product); + + DB::transaction(function () use ($request, $product) { + $product->update($request->validated()); + + $product->attributes()->sync($request->input('attributes')); + + $latestPrice = $product->latestPrice; + + if ($latestPrice == null || $latestPrice->price != $request->input('price')) { + Price::create([ + 'product_id' => $product->id, + 'price' => $request->input('price'), + ]); + } + }); + + return response()->json(['message' => 'Product updated successfully'], 200); + } + + public function destroy(Product $product) + { + Gate::authorize('delete', $product); + + $product->deleted_by = auth()->id(); + $product->save(); + + $product->delete(); + + return response()->json(['message' => 'Product deleted successfully'], 204); + } +} diff --git a/app/Http/Requests/StoreProductRequest.php b/app/Http/Requests/StoreProductRequest.php new file mode 100644 index 0000000..ba45aa7 --- /dev/null +++ b/app/Http/Requests/StoreProductRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'category_id' => 'required|integer|exists:categories,id', + 'brand_id' => 'required|integer|exists:brands,id', + 'unit_type_id' => 'required|integer|exists:unit_types,id', + 'name' => 'required|max:255', + 'description' => 'nullable', + 'price' => 'required|numeric', + 'attributes' => 'array', + ]; + } +} diff --git a/app/Models/Brand.php b/app/Models/Brand.php index 2f96fb0..611579a 100644 --- a/app/Models/Brand.php +++ b/app/Models/Brand.php @@ -31,4 +31,9 @@ public function deleter() { return $this->belongsTo(User::class, 'deleted_by')->select(['id', 'name']); } + + public function products() + { + return $this->hasMany(Product::class); + } } diff --git a/app/Models/Category.php b/app/Models/Category.php index b0e0c41..887ead7 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -28,4 +28,9 @@ public function deleter() { return $this->belongsTo(User::class, 'deleted_by')->select(['id', 'name']); } + + public function products() + { + return $this->hasMany(Product::class); + } } diff --git a/app/Models/Price.php b/app/Models/Price.php new file mode 100644 index 0000000..606ccbf --- /dev/null +++ b/app/Models/Price.php @@ -0,0 +1,21 @@ +belongsTo(Product::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..870f0ae --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,71 @@ +belongsTo(Category::class)->select(['id', 'name']); + } + + public function brand() + { + return $this->belongsTo(Brand::class)->select(['id', 'name']); + } + + public function unitType() + { + return $this->belongsTo(UnitType::class)->select(['id', 'name']); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by')->select(['id', 'name']); + } + + public function updater() + { + return $this->belongsTo(User::class, 'updated_by')->select(['id', 'name']); + } + + public function deleter() + { + return $this->belongsTo(User::class, 'deleted_by')->select(['id', 'name']); + } + + public function warehouses() + { + return $this->belongsToMany(Warehouse::class)->withPivot(['quantity']); + } + + public function prices() + { + return $this->hasMany(Price::class); + } + + public function latestPrice() + { + return $this->hasOne(Price::class)->latestOfMany(); + } + + public function attributes() + { + return $this->belongsToMany(Attribute::class); + } +} diff --git a/app/Models/UnitType.php b/app/Models/UnitType.php index 3c8fd69..cb2419f 100644 --- a/app/Models/UnitType.php +++ b/app/Models/UnitType.php @@ -29,4 +29,9 @@ public function deleter() { return $this->belongsTo(User::class, 'deleted_by')->select(['id', 'name']); } + + public function products() + { + return $this->hasMany(Product::class); + } } diff --git a/app/Models/Warehouse.php b/app/Models/Warehouse.php index 6d5b011..2d0ed76 100644 --- a/app/Models/Warehouse.php +++ b/app/Models/Warehouse.php @@ -31,4 +31,9 @@ public function deleter() { return $this->belongsTo(User::class, 'deleted_by')->select(['id', 'name']); } + + public function products() + { + return $this->belongsToMany(Product::class)->withPivot(['quantity']); + } } diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 0000000..39fb8f3 --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,21 @@ +slug = str($product->name)->slug()->toString(); + $product->created_by = auth()->id(); + $product->status = 'active'; + } + + public function updating(Product $product) + { + $product->slug = str($product->name)->slug()->toString(); + $product->updated_by = auth()->id(); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 0000000..b6e617e --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,65 @@ +can('product-list'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Product $product): bool + { + return $user->can('product-list'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->can('product-create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Product $product): bool + { + return $user->can('product-edit'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Product $product): bool + { + return $user->can('product-delete'); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Product $product): bool + { + return $user->can('product-restore'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Product $product): bool + { + return $user->can('product-force-delete'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8423a5f..e45cda6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -12,6 +12,7 @@ use App\Models\Expense; use App\Models\ExpenseCategory; use App\Models\PaymentMethod; +use App\Models\Product; use App\Models\UnitType; use App\Models\Warehouse; use App\Observers\AccountObserver; @@ -24,6 +25,7 @@ use App\Observers\ExpenseCategoryObserver; use App\Observers\ExpenseObserver; use App\Observers\PaymentMethodObserver; +use App\Observers\ProductObserver; use App\Observers\UnitTypeObserver; use App\Observers\WarehouseObserver; use Illuminate\Support\ServiceProvider; @@ -54,6 +56,7 @@ public function boot(): void UnitType::observe(UnitTypeObserver::class); Attribute::observe(AttributeObserver::class); AttributeValue::observe(AttributeValueObserver::class); + Product::observe(ProductObserver::class); Warehouse::observe(WarehouseObserver::class); } } diff --git a/database/factories/PriceFactory.php b/database/factories/PriceFactory.php new file mode 100644 index 0000000..bb513a5 --- /dev/null +++ b/database/factories/PriceFactory.php @@ -0,0 +1,23 @@ + + */ +class PriceFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'price' => fake()->numberBetween(100, 1000), + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 0000000..d3792e0 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,31 @@ + + */ +class ProductFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->sentence(3, true), + 'slug' => fake()->slug(), + 'description' => fake()->text(), + 'category_id' => Category::factory(), + 'brand_id' => Brand::factory(), + 'unit_type_id' => UnitType::factory(), + ]; + } +} diff --git a/database/migrations/2024_06_03_151352_create_warehouses_table.php b/database/migrations/2024_06_03_122418_create_warehouses_table.php similarity index 100% rename from database/migrations/2024_06_03_151352_create_warehouses_table.php rename to database/migrations/2024_06_03_122418_create_warehouses_table.php diff --git a/database/migrations/2024_06_03_144544_create_products_table.php b/database/migrations/2024_06_03_144544_create_products_table.php new file mode 100644 index 0000000..13927e9 --- /dev/null +++ b/database/migrations/2024_06_03_144544_create_products_table.php @@ -0,0 +1,46 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->enum('status', ['active', 'inactive'])->default('active'); + $table->string('image')->nullable(); + $table->unsignedBigInteger('category_id'); + $table->unsignedBigInteger('brand_id'); + $table->unsignedBigInteger('unit_type_id'); + $table->unsignedBigInteger('created_by'); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->foreign('category_id')->references('id')->on('categories')->onDelete('cascade'); + $table->foreign('brand_id')->references('id')->on('brands')->onDelete('cascade'); + $table->foreign('unit_type_id')->references('id')->on('unit_types')->onDelete('cascade'); + $table->foreign('created_by')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('updated_by')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('deleted_by')->references('id')->on('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2024_06_03_150123_create_prices_table.php b/database/migrations/2024_06_03_150123_create_prices_table.php new file mode 100644 index 0000000..2334a02 --- /dev/null +++ b/database/migrations/2024_06_03_150123_create_prices_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('product_id'); + $table->decimal('price', 10, 2); + $table->timestamps(); + + $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('prices'); + } +}; diff --git a/database/migrations/2024_06_03_150708_create_attribute_product_table.php b/database/migrations/2024_06_03_150708_create_attribute_product_table.php new file mode 100644 index 0000000..ef8be07 --- /dev/null +++ b/database/migrations/2024_06_03_150708_create_attribute_product_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('attribute_id'); + $table->unsignedBigInteger('product_id'); + $table->timestamps(); + + $table->foreign('attribute_id')->references('id')->on('attributes')->onDelete('cascade'); + $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('attribute_product'); + } +}; diff --git a/database/migrations/2024_06_03_171300_create_product_warehouse_table.php b/database/migrations/2024_06_03_171300_create_product_warehouse_table.php new file mode 100644 index 0000000..783a09e --- /dev/null +++ b/database/migrations/2024_06_03_171300_create_product_warehouse_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('product_id'); + $table->unsignedBigInteger('warehouse_id'); + $table->integer('quantity')->default(0); + $table->timestamps(); + + $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade'); + $table->foreign('warehouse_id')->references('id')->on('warehouses')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_warehouse'); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 65a7b9d..c9bacab 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -90,6 +90,12 @@ public function run(): void 'attribute-value-delete', 'attribute-value-restore', 'attribute-value-force-delete', + 'product-list', + 'product-create', + 'product-edit', + 'product-delete', + 'product-restore', + 'product-force-delete', 'warehouse-list', 'warehouse-create', 'warehouse-edit', diff --git a/routes/api.php b/routes/api.php index 4f7beab..9bcc5ae 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,6 +11,7 @@ use App\Http\Controllers\ExpenseCategoryController; use App\Http\Controllers\ExpenseController; use App\Http\Controllers\PaymentMethodController; +use App\Http\Controllers\ProductController; use App\Http\Controllers\UnitTypeController; use App\Http\Controllers\WarehouseController; use Illuminate\Support\Facades\Route; @@ -29,5 +30,6 @@ Route::apiResource('unitTypes', UnitTypeController::class); Route::apiResource('attributes', AttributeController::class); Route::apiResource('attributeValues', AttributeValueController::class); + Route::apiResource('products', ProductController::class); Route::apiResource('warehouses', WarehouseController::class); }); diff --git a/tests/Feature/ProductTest.php b/tests/Feature/ProductTest.php new file mode 100644 index 0000000..54c2a4a --- /dev/null +++ b/tests/Feature/ProductTest.php @@ -0,0 +1,266 @@ +user = Auth::user(); + } + + public function test_user_can_view_all_products() + { + $this->user->givePermissionTo('product-list'); + + $products = Product::factory(10)->create(); + $warehouses = Warehouse::factory(3)->create(); + + $products->each(function ($product) use ($warehouses) { + $product->warehouses()->attach($warehouses->random(1)->pluck('id')); + }); + + $response = $this->get(route('products.index')); + + $response->assertOk(); + + $response->assertJsonCount(10, 'data'); + + $response->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'name', + 'category' => [ + 'id', + 'name', + ], + 'brand' => [ + 'id', + 'name', + ], + 'unit_type' => [ + 'id', + 'name', + ], + 'creator' => [ + 'id', + 'name', + ], + 'warehouses' => [ + '*' => [ + 'id', + 'name', + 'pivot' => [ + 'quantity', + ], + ], + ], + ], + ], + ]); + } + + public function test_user_can_view_product() + { + $this->user->givePermissionTo('product-list'); + + $product = Product::factory()->create(); + $attributes = Attribute::factory(10)->create(); + $randomAttributes = $attributes->random(3)->pluck('id')->toArray(); + + $product->attributes()->attach($randomAttributes); + + $product->prices()->create([ + 'price' => $price = rand(100, 1000), + ]); + + $response = $this->get(route('products.show', $product)); + + $response->assertOk(); + + $response->assertJsonStructure([ + 'id', + 'name', + 'category' => [ + 'id', + 'name', + ], + 'brand' => [ + 'id', + 'name', + ], + 'unit_type' => [ + 'id', + 'name', + ], + 'creator' => [ + 'id', + 'name', + ], + 'attributes' => [ + '*' => [ + 'id', + 'name', + ], + ], + 'prices' => [ + '*' => [ + 'price', + ], + ], + 'warehouses' => [ + '*' => [ + 'id', + 'name', + 'pivot' => [ + 'quantity', + ], + ], + ], + ]); + + } + + public function test_user_can_create_a_product() + { + $this->user->givePermissionTo('product-create'); + + $attributes = Attribute::factory(10)->create(); + $product = Product::factory()->make(); + $randomAttributes = $attributes->random(3)->pluck('id')->toArray(); + + $data = [ + 'price' => $price = rand(100, 1000), + ...$product->toArray(), + 'attributes' => $randomAttributes, + ]; + + $this->post(route('products.store'), $data) + ->assertCreated(); + + $this->assertDatabaseHas('products', [ + 'name' => $product->name, + 'category_id' => $product->category_id, + 'brand_id' => $product->brand_id, + 'unit_type_id' => $product->unit_type_id, + 'created_by' => $this->user->id, + ]); + + $productId = Product::first()->id; + + $this->assertDatabaseHas('prices', [ + 'price' => $price, + 'product_id' => $productId, + ]); + + $this->assertDatabaseCount('attribute_product', count($randomAttributes)); + + foreach ($randomAttributes as $attribute) { + $this->assertDatabaseHas('attribute_product', [ + 'product_id' => $productId, + 'attribute_id' => $attribute, + ]); + } + } + + public function test_user_can_update_a_product() + { + $this->user->givePermissionTo('product-edit'); + + $product = Product::factory()->create(); + $attributes = Attribute::factory(10)->create(); + $randomAttributes = $attributes->random(3)->pluck('id')->toArray(); + + $product->attributes()->attach($randomAttributes); + + $product->prices()->create([ + 'price' => rand(100, 1000), + ]); + + $newRandomAttributes = $attributes->random(5)->pluck('id')->toArray(); + $data = [ + 'price' => $newPrice = rand(100, 1000), + 'name' => 'updated name', + 'attributes' => $newRandomAttributes, + 'category_id' => $product->category_id, + 'brand_id' => $product->brand_id, + 'unit_type_id' => $product->unit_type_id, + ]; + + $response = $this->put(route('products.update', $product->id), $data); + + $response->assertStatus(200); + + $this->assertDatabaseHas('products', [ + 'id' => $product->id, + 'name' => 'updated name', + 'updated_by' => $this->user->id, + ]); + + $this->assertDatabaseHas('prices', [ + 'price' => $newPrice, + 'product_id' => $product->id, + ]); + + $this->assertDatabaseCount('attribute_product', count($newRandomAttributes)); + + foreach ($newRandomAttributes as $attribute) { + $this->assertDatabaseHas('attribute_product', [ + 'product_id' => $product->id, + 'attribute_id' => $attribute, + ]); + } + } + + public function test_user_can_delete_a_product() + { + $this->user->givePermissionTo('product-delete'); + + $product = Product::factory()->create(); + $attributes = Attribute::factory(10)->create(); + $randomAttributes = $attributes->random(3)->pluck('id')->toArray(); + + $product->attributes()->attach($randomAttributes); + + $product->prices()->create([ + 'price' => $price = rand(100, 1000), + ]); + + $this->delete(route('products.destroy', $product->id)) + ->assertNoContent(); + + $this->assertSoftDeleted('products', [ + 'id' => $product->id, + 'deleted_by' => $this->user->id, + ]); + + $this->assertDatabaseHas('prices', [ + 'price' => $price, + 'product_id' => $product->id, + ]); + + $this->assertDatabaseCount('attribute_product', count($randomAttributes)); + + foreach ($randomAttributes as $attribute) { + $this->assertDatabaseHas('attribute_product', [ + 'product_id' => $product->id, + 'attribute_id' => $attribute, + ]); + } + } +}