From 86f1236933d8f0691a6f106fb2e0a32bf20b425c Mon Sep 17 00:00:00 2001 From: rakib Date: Wed, 5 Jun 2024 18:47:17 +0600 Subject: [PATCH 1/9] added adjustment model with warehouse & products relationships --- app/Models/Adjustment.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/Models/Adjustment.php diff --git a/app/Models/Adjustment.php b/app/Models/Adjustment.php new file mode 100644 index 0000000..7da258d --- /dev/null +++ b/app/Models/Adjustment.php @@ -0,0 +1,27 @@ +belongsTo(Warehouse::class); + } + + public function products() + { + return $this->belongsToMany(Product::class, 'adjustment_product', 'adjustment_id', 'product_id')->withPivot(['quantity', 'type']); + } +} From 34683de56b47ffdaeb8f07a1490e9a1557e02885 Mon Sep 17 00:00:00 2001 From: rakib Date: Wed, 5 Jun 2024 18:47:40 +0600 Subject: [PATCH 2/9] added adjustments table migration --- ..._06_04_135143_create_adjustments_table.php | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 database/migrations/2024_06_04_135143_create_adjustments_table.php diff --git a/database/migrations/2024_06_04_135143_create_adjustments_table.php b/database/migrations/2024_06_04_135143_create_adjustments_table.php new file mode 100644 index 0000000..096cfe2 --- /dev/null +++ b/database/migrations/2024_06_04_135143_create_adjustments_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('warehouse_id'); + $table->date('adjustment_date'); + $table->text('reason'); + $table->unsignedBigInteger('created_by'); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->timestamps(); + + $table->foreign('warehouse_id')->references('id')->on('warehouses')->onDelete('cascade'); + $table->foreign('created_by')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('updated_by')->references('id')->on('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('adjustments'); + } +}; From 61c1608a21376adbb2fcfa4b98619e20b3b1f6a0 Mon Sep 17 00:00:00 2001 From: rakib Date: Wed, 5 Jun 2024 18:47:59 +0600 Subject: [PATCH 3/9] added adjustment_product pivot table migration --- ...033050_create_adjustment_product_table.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 database/migrations/2024_06_05_033050_create_adjustment_product_table.php diff --git a/database/migrations/2024_06_05_033050_create_adjustment_product_table.php b/database/migrations/2024_06_05_033050_create_adjustment_product_table.php new file mode 100644 index 0000000..920949d --- /dev/null +++ b/database/migrations/2024_06_05_033050_create_adjustment_product_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('adjustment_id'); + $table->unsignedBigInteger('product_id'); + $table->integer('quantity'); + $table->enum('type', ['addition', 'subtraction']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('adjustment_product'); + } +}; From f634cba5336ba18f5f2ac8af97e65501d14144f4 Mon Sep 17 00:00:00 2001 From: rakib Date: Wed, 5 Jun 2024 18:48:25 +0600 Subject: [PATCH 4/9] added adjustments relationship in product model --- app/Models/Product.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Models/Product.php b/app/Models/Product.php index 870f0ae..d242ac2 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -68,4 +68,9 @@ public function attributes() { return $this->belongsToMany(Attribute::class); } + + public function adjustments() + { + return $this->belongsToMany(Adjustment::class)->withPivot(['quantity', 'type']); + } } From d1a0fda0af375f3d1c9ccbaa2c5ac8faa49ff7f0 Mon Sep 17 00:00:00 2001 From: rakib Date: Wed, 5 Jun 2024 18:48:48 +0600 Subject: [PATCH 5/9] added adjustment permissions to permissionSeeder --- database/seeders/PermissionSeeder.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index c9bacab..8f06e30 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -102,6 +102,12 @@ public function run(): void 'warehouse-delete', 'warehouse-restore', 'warehouse-force-delete', + 'adjustment-list', + 'adjustment-create', + 'adjustment-edit', + 'adjustment-delete', + 'adjustment-restore', + 'adjustment-force-delete', ]; foreach ($permissions as $permission) { From 52e4922c60cde896b0006dde956e29a00203955f Mon Sep 17 00:00:00 2001 From: rakib Date: Wed, 5 Jun 2024 18:49:24 +0600 Subject: [PATCH 6/9] added adjustment observer and add it to appServiceProvider --- app/Observers/AdjustmentObserver.php | 18 ++++++++++++++++++ app/Providers/AppServiceProvider.php | 3 +++ 2 files changed, 21 insertions(+) create mode 100644 app/Observers/AdjustmentObserver.php diff --git a/app/Observers/AdjustmentObserver.php b/app/Observers/AdjustmentObserver.php new file mode 100644 index 0000000..8a90dad --- /dev/null +++ b/app/Observers/AdjustmentObserver.php @@ -0,0 +1,18 @@ +created_by = auth()->user()->id; + } + + public function updating(Adjustment $adjustment) + { + $adjustment->updated_by = auth()->user()->id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e45cda6..fb400ac 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Models\Account; +use App\Models\Adjustment; use App\Models\Attribute; use App\Models\AttributeValue; use App\Models\Brand; @@ -16,6 +17,7 @@ use App\Models\UnitType; use App\Models\Warehouse; use App\Observers\AccountObserver; +use App\Observers\AdjustmentObserver; use App\Observers\AttributeObserver; use App\Observers\AttributeValueObserver; use App\Observers\BrandObserver; @@ -58,5 +60,6 @@ public function boot(): void AttributeValue::observe(AttributeValueObserver::class); Product::observe(ProductObserver::class); Warehouse::observe(WarehouseObserver::class); + Adjustment::observe(AdjustmentObserver::class); } } From 437b5e5a9e331f8e83a277aab0ee955d29f2e9a8 Mon Sep 17 00:00:00 2001 From: rakib Date: Wed, 5 Jun 2024 18:49:59 +0600 Subject: [PATCH 7/9] added adjustment factory, policy & request validation --- app/Http/Requests/StoreAdjustmentRequest.php | 35 +++++++++++ app/Policies/AdjustmentPolicy.php | 65 ++++++++++++++++++++ database/factories/AdjustmentFactory.php | 55 +++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 app/Http/Requests/StoreAdjustmentRequest.php create mode 100644 app/Policies/AdjustmentPolicy.php create mode 100644 database/factories/AdjustmentFactory.php diff --git a/app/Http/Requests/StoreAdjustmentRequest.php b/app/Http/Requests/StoreAdjustmentRequest.php new file mode 100644 index 0000000..694de96 --- /dev/null +++ b/app/Http/Requests/StoreAdjustmentRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'warehouse_id' => 'required|integer|exists:warehouses,id', + 'adjustment_date' => 'required|date', + 'reason' => 'required', + 'adjustment_items' => 'required|array', + 'adjustment_items.*.product_id' => 'required|integer|exists:products,id', + 'adjustment_items.*.quantity' => 'required|integer', + 'adjustment_items.*.type' => 'required|string|in:addition,subtraction', + ]; + } +} diff --git a/app/Policies/AdjustmentPolicy.php b/app/Policies/AdjustmentPolicy.php new file mode 100644 index 0000000..a9e3fc9 --- /dev/null +++ b/app/Policies/AdjustmentPolicy.php @@ -0,0 +1,65 @@ +can('adjustment-list'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Adjustment $adjustment): bool + { + return $user->can('adjustment-list'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->can('adjustment-create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Adjustment $adjustment): bool + { + return $user->can('adjustment-edit'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Adjustment $adjustment): bool + { + return $user->can('adjustment-delete'); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Adjustment $adjustment): bool + { + return $user->can('adjustment-restore'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Adjustment $adjustment): bool + { + return $user->can('adjustment-force-delete'); + } +} diff --git a/database/factories/AdjustmentFactory.php b/database/factories/AdjustmentFactory.php new file mode 100644 index 0000000..68a3de9 --- /dev/null +++ b/database/factories/AdjustmentFactory.php @@ -0,0 +1,55 @@ + + */ +class AdjustmentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'warehouse_id' => Warehouse::factory(), + 'adjustment_date' => fake()->date(), + 'reason' => fake()->sentence(), + ]; + } + + public function configure(): static + { + return $this->afterCreating(function (Adjustment $adjustment) { + $products = Product::factory(10)->create(); + + $products->each(function ($product) use ($adjustment) { + $product->warehouses()->attach($adjustment->warehouse_id, [ + 'quantity' => rand(1, 10), + ]); + }); + + $warehouseProducts = Warehouse::with(['products'])->where('id', $adjustment->warehouse_id)->first()->products; + + $adjustmentItems = $warehouseProducts->pluck('id')->map(function ($id) { + return [ + 'product_id' => $id, + 'quantity' => rand(1, 10), + 'type' => rand(0, 1) ? 'addition' : 'subtraction', + ]; + }); + + foreach ($adjustmentItems as $item) { + $adjustment->products()->attach($item['product_id'], $item); + } + }); + } +} From 834462ac394b74b1c060a22ef11f5ce8c264f226 Mon Sep 17 00:00:00 2001 From: rakib Date: Wed, 5 Jun 2024 18:50:10 +0600 Subject: [PATCH 8/9] added adjustment api route --- routes/api.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/routes/api.php b/routes/api.php index 9bcc5ae..1de8edd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ Date: Wed, 5 Jun 2024 18:50:24 +0600 Subject: [PATCH 9/9] added adjustment controller with test --- app/Http/Controllers/AdjustmentController.php | 159 ++++++++++++ tests/Feature/AdjustmentTest.php | 228 ++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 app/Http/Controllers/AdjustmentController.php create mode 100644 tests/Feature/AdjustmentTest.php diff --git a/app/Http/Controllers/AdjustmentController.php b/app/Http/Controllers/AdjustmentController.php new file mode 100644 index 0000000..5a046e0 --- /dev/null +++ b/app/Http/Controllers/AdjustmentController.php @@ -0,0 +1,159 @@ +with(['warehouse', 'products'])->paginate(20); + } + + public function show(Adjustment $adjustment) + { + Gate::authorize('view', $adjustment); + + return $adjustment->load(['warehouse', 'products']); + } + + public function store(StoreAdjustmentRequest $request) + { + Gate::authorize('create', Adjustment::class); + + DB::transaction(function () use ($request) { + + $warehouseId = $request->input('warehouse_id'); + $adjustments = $request->input('adjustment_items'); + + // update product_warehouse + foreach ($adjustments as $adjustment) { + Product::find($adjustment['product_id']) + ->warehouses() + ->updateExistingPivot($warehouseId, [ + 'quantity' => Product::find($adjustment['product_id']) + ->warehouses() + ->where('warehouse_id', $warehouseId) + ->first() + ->pivot + ->quantity + ($adjustment['type'] == 'addition' ? $adjustment['quantity'] : (-1) * $adjustment['quantity']), + ]); + } + + $items = []; + foreach ($adjustments as $adjustment) { + $items[$adjustment['product_id']] = [ + 'quantity' => $adjustment['quantity'], + 'type' => $adjustment['type'], + ]; + } + + // create adjustment + $adjustment = Adjustment::create([ + 'warehouse_id' => $warehouseId, + 'reason' => $request->input('reason'), + 'adjustment_date' => $request->input('adjustment_date'), + ]); + + // create adjustment_product + $adjustment->products()->attach($items); + }); + + return response()->json(['message' => 'Adjustment created successfully'], 201); + } + + public function update(StoreAdjustmentRequest $request, Adjustment $adjustment) + { + Gate::authorize('update', $adjustment); + + DB::transaction(function () use ($adjustment, $request) { + + $warehouseId = $request->input('warehouse_id'); + $adjustments = $request->input('adjustment_items'); + $reason = $request->input('reason'); + $adjustment_date = $request->input('adjustment_date'); + + $adjustmentProductItems = []; + foreach ($adjustments as $item) { + $adjustmentProductItems[$item['product_id']] = [ + 'quantity' => $item['quantity'], + 'type' => $item['type'], + ]; + } + + //update adjustment + $adjustment->update([ + 'warehouse_id' => $warehouseId, + 'reason' => $reason, + 'adjustment_date' => $adjustment_date, + ]); + + // update product_warehouse(rollback to previous state) + foreach ($adjustment->products as $item) { + Product::find($item->pivot->product_id) + ->warehouses() + ->updateExistingPivot($warehouseId, [ + 'quantity' => Product::find($item->pivot->product_id) + ->warehouses() + ->where('warehouse_id', $warehouseId) + ->first() + ->pivot + ->quantity + ($item->pivot->type == 'addition' ? (-1) * $item->pivot->quantity : $item->pivot->quantity), + ]); + } + + // update product_warehouse + foreach ($adjustments as $item) { + Product::find($item['product_id']) + ->warehouses() + ->updateExistingPivot($warehouseId, [ + 'quantity' => Product::find($item['product_id']) + ->warehouses() + ->where('warehouse_id', $warehouseId) + ->first() + ->pivot + ->quantity + ($item['type'] == 'addition' ? $item['quantity'] : (-1) * $item['quantity']), + ]); + } + + // update adjustment_product + $adjustment->products()->sync($adjustmentProductItems); + }); + + return response()->json(['message' => 'Adjustment updated successfully'], 200); + } + + public function destroy(Adjustment $adjustment) + { + Gate::authorize('delete', $adjustment); + + $warehouseId = $adjustment->warehouse_id; + + foreach ($adjustment->products as $item) { + Product::find($item->pivot->product_id) + ->warehouses() + ->updateExistingPivot($warehouseId, [ + 'quantity' => Product::find($item->pivot->product_id) + ->warehouses() + ->where('warehouse_id', $warehouseId) + ->first() + ->pivot + ->quantity + ($item->pivot->type == 'addition' ? (-1) * $item->pivot->quantity : $item->pivot->quantity), + ]); + } + + $adjustment->products()->detach(); + + $adjustment->delete(); + + return response()->json(['message' => 'Adjustment deleted successfully'], 204); + } +} diff --git a/tests/Feature/AdjustmentTest.php b/tests/Feature/AdjustmentTest.php new file mode 100644 index 0000000..69e1609 --- /dev/null +++ b/tests/Feature/AdjustmentTest.php @@ -0,0 +1,228 @@ +user = Auth::user(); + } + + public function test_user_can_create_adjustment() + { + $this->user->givePermissionTo('adjustment-create'); + + $products = Product::factory(10)->create(); + $warehouse = Warehouse::factory()->create(); + + $products->each(function ($product) use ($warehouse) { + $product->warehouses()->attach($warehouse->id, [ + 'quantity' => rand(10, 20), + ]); + }); + + $warehouseProducts = Warehouse::with(['products'])->where('id', $warehouse->id)->first()->products; + + $adjustmentItems = $products->pluck('id')->map(function ($id) { + return [ + 'product_id' => $id, + 'quantity' => rand(1, 5), + 'type' => rand(0, 1) ? 'addition' : 'subtraction', + ]; + }); + + $productsWithNewQuantity = []; + foreach ($adjustmentItems as $item) { + $productsWithNewQuantity[$item['product_id']] = ($item['type'] == 'addition' ? $item['quantity'] : $item['quantity'] * -1); + } + + $data = [ + 'warehouse_id' => $warehouse->id, + 'adjustment_date' => now(), + 'reason' => 'test reason', + 'adjustment_items' => $adjustmentItems->toArray(), + ]; + + $response = $this->post(route('adjustments.store'), $data); + + $response->assertStatus(201); + + foreach ($warehouseProducts as $warehouseProduct) { + $this->assertDatabaseHas('product_warehouse', [ + 'warehouse_id' => $warehouseProduct->pivot->warehouse_id, + 'product_id' => $warehouseProduct->pivot->product_id, + 'quantity' => $warehouseProduct->pivot->quantity + $productsWithNewQuantity[$warehouseProduct->pivot->product_id], + ]); + } + + $this->assertDatabaseHas('adjustments', [ + 'warehouse_id' => $warehouse->id, + 'reason' => $data['reason'], + ]); + + $adjustmentId = Adjustment::first()->id; + + foreach ($adjustmentItems as $item) { + $this->assertDatabaseHas('adjustment_product', [ + 'adjustment_id' => $adjustmentId, + 'product_id' => $item['product_id'], + 'quantity' => $item['quantity'], + 'type' => $item['type'], + ]); + } + } + + public function test_user_can_read_all_adjustments() + { + $this->user->givePermissionTo('adjustment-list'); + + Adjustment::factory(10)->create(); + + $response = $this->get(route('adjustments.index')); + + $response->assertOk(); + + $response->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'warehouse_id', + 'adjustment_date', + 'reason', + 'products' => [ + '*' => [ + 'id', + 'name', + 'pivot' => [ + 'adjustment_id', + 'product_id', + 'quantity', + 'type', + ], + ], + ], + ], + ], + ]); + + $response->assertJsonCount(10, 'data'); + } + + public function test_user_can_read_an_adjustment() + { + $this->user->givePermissionTo('adjustment-list'); + + $adjustment = Adjustment::factory()->create(); + + $adjustmentItems = $adjustment->products; + + $response = $this->get(route('adjustments.show', $adjustment->id)); + + $response->assertOk(); + + $response->assertJsonStructure([ + 'id', + 'warehouse_id', + 'adjustment_date', + 'reason', + 'products' => [ + '*' => [ + 'id', + 'name', + 'pivot' => [ + 'adjustment_id', + 'product_id', + 'quantity', + 'type', + ], + ], + ], + ]); + + foreach ($adjustmentItems as $item) { + $this->assertDatabaseHas('adjustment_product', [ + 'adjustment_id' => $adjustment->id, + 'product_id' => $item->pivot->product_id, + 'quantity' => $item->pivot->quantity, + 'type' => $item->pivot->type, + ]); + } + } + + public function test_user_can_update_an_adjustment() + { + $this->user->givePermissionTo('adjustment-edit'); + + $adjustment = Adjustment::factory()->create(); + + $products = $adjustment->products()->get(); + + $items = $products->random(3); + + $adjustmentItems = $items->pluck('id')->map(function ($id) { + return [ + 'product_id' => $id, + 'quantity' => rand(1, 10), + 'type' => rand(0, 1) ? 'addition' : 'subtraction', + ]; + }); + + $data = [ + 'warehouse_id' => $adjustment->warehouse_id, + 'adjustment_date' => now(), + 'reason' => 'test reason', + 'adjustment_items' => $adjustmentItems->toArray(), + ]; + + $response = $this->put(route('adjustments.update', $adjustment), $data); + + $response->assertOk(); + + $this->assertDatabaseHas('adjustments', [ + 'id' => $adjustment->id, + 'warehouse_id' => $data['warehouse_id'], + 'reason' => $data['reason'], + ]); + + $this->assertDatabaseCount('adjustment_product', 3); + + foreach ($adjustmentItems as $item) { + $this->assertDatabaseHas('adjustment_product', [ + 'adjustment_id' => $adjustment->id, + 'product_id' => $item['product_id'], + 'quantity' => $item['quantity'], + 'type' => $item['type'], + ]); + } + } + + public function test_user_can_delete_an_adjustment() + { + $this->user->givePermissionTo('adjustment-delete'); + + $adjustment = Adjustment::factory()->create(); + + $response = $this->delete(route('adjustments.destroy', $adjustment)); + + $response->assertNoContent(); + + $this->assertDatabaseCount('adjustments', 0); + + $this->assertDatabaseCount('adjustment_product', 0); + } +}