diff --git a/app/Livewire/Studio/Manage/StockOpname/Index.php b/app/Livewire/Studio/Manage/StockOpname/Index.php index 56b01b4..45b3060 100644 --- a/app/Livewire/Studio/Manage/StockOpname/Index.php +++ b/app/Livewire/Studio/Manage/StockOpname/Index.php @@ -76,6 +76,11 @@ public function approveApproval(StockOpname $stockOpname): void } $itemable = $item->itemable; + + if (! $itemable) { + continue; + } + $morphType = class_basename($itemable); $relationMap = [ @@ -90,9 +95,11 @@ public function approveApproval(StockOpname $stockOpname): void $relationName = $relationMap[$morphType]; - $currentStock = $outlet->$relationName() + $currentPivot = $outlet->$relationName() ->where($itemable->getTable().'.id', $itemable->id) - ->first()?->pivot?->stock ?? 0; + ->first(); + + $currentStock = $currentPivot?->pivot?->stock ?? 0; $quantity = $item->qty_physical; @@ -100,26 +107,13 @@ public function approveApproval(StockOpname $stockOpname): void $itemable->id => ['stock' => $quantity], ]); - StockActivityLogService::log( - logName: 'Stock Opname', - event: 'Mengubah', - description: sprintf( - 'Stok %s di %s diperbarui dari %s menjadi %s', - $itemable->name, - $outlet->name, - $currentStock, - $quantity - ), - performedOn: $itemable, - outlet: $outlet, + StockActivityLogService::stockOpname( + stockOpname: $stockOpname, + product: $itemable, quantityChange: $quantity - $currentStock, previousStock: $currentStock, newStock: $quantity, - extraProperties: [ - // Preserving 'itemable_id' pointing to StockOpnameItem id as per original code overwriting - 'itemable_id' => $item->id ?? null, - 'itemable_type' => $item->itemable_type, - ] + note: $stockOpname->note ); } }); diff --git a/app/Livewire/Studio/Master/Warehouse.php b/app/Livewire/Studio/Master/Warehouse.php deleted file mode 100644 index d222276..0000000 --- a/app/Livewire/Studio/Master/Warehouse.php +++ /dev/null @@ -1,89 +0,0 @@ -canOrAbort('create warehouse'); - - $this->form->store(); - - $this->dispatch('refreshDatatable'); - - $this->toast('Gudang berhasil ditambahkan.'); - - Flux::modals()->close(); - } - - public function update(): void - { - $this->canOrAbort('update warehouse'); - - $this->form->update(); - - $this->dispatch('refreshDatatable'); - - $this->toast('Gudang berhasil diperbarui.'); - - Flux::modals()->close(); - } - - public function delete(WarehouseModel $warehouse): void - { - $this->canOrAbort('delete warehouse'); - - $this->form->warehouse = $warehouse; - - $this->form->delete(); - - $this->dispatch('refreshDatatable'); - - $this->toast('Gudang berhasil dihapus.'); - - Flux::modals()->close(); - } - - #[On('modal:open')] - public function openModal(string $method, string $modalTitle, ?string $id = null): void - { - $this->resetValidation(); - $this->resetErrorBag(); - - $this->method = $method; - $this->modalTitle = $modalTitle; - - $id && $this->form->setWarehouse(WarehouseModel::byHashOrFail($id)); - } - - public function render(): View - { - return view('livewire.studio.master.warehouse', [ - 'pageTitle' => 'Gudang', - ]); - } -} diff --git a/app/Models/Bottle.php b/app/Models/Bottle.php index 54609cf..3cc5ab5 100644 --- a/app/Models/Bottle.php +++ b/app/Models/Bottle.php @@ -40,6 +40,11 @@ public function outlets(): BelongsToMany return $this->belongsToMany(Outlet::class)->withPivot('stock'); } + public function warehouses(): BelongsToMany + { + return $this->belongsToMany(Warehouse::class)->withPivot('stock'); + } + public function purchaseItems(): MorphMany { return $this->morphMany(PurchaseItem::class, 'purchasable'); @@ -49,4 +54,9 @@ public function items(): MorphMany { return $this->morphMany(OrderItem::class, 'orderable'); } + + public function restockItems(): MorphMany + { + return $this->morphMany(RestockItem::class, 'restockable'); + } } diff --git a/app/Models/Outlet.php b/app/Models/Outlet.php index 3f8456c..13a8056 100644 --- a/app/Models/Outlet.php +++ b/app/Models/Outlet.php @@ -99,4 +99,9 @@ public function stockOpname(): HasMany { return $this->hasMany(StockOpname::class); } + + public function restocks(): HasMany + { + return $this->hasMany(Restock::class); + } } diff --git a/app/Models/Perfume.php b/app/Models/Perfume.php index b4e9b17..1df2d0e 100644 --- a/app/Models/Perfume.php +++ b/app/Models/Perfume.php @@ -53,6 +53,11 @@ public function outlets(): BelongsToMany return $this->belongsToMany(Outlet::class)->withPivot('stock'); } + public function warehouses(): BelongsToMany + { + return $this->belongsToMany(Warehouse::class)->withPivot('stock'); + } + public function purchaseItems(): MorphMany { return $this->morphMany(PurchaseItem::class, 'purchasable'); @@ -62,4 +67,9 @@ public function items(): MorphMany { return $this->morphMany(OrderItem::class, 'orderable'); } + + public function restockItems(): MorphMany + { + return $this->morphMany(RestockItem::class, 'restockable'); + } } diff --git a/app/Models/Product.php b/app/Models/Product.php index aa14216..3f4f55f 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -40,6 +40,11 @@ public function outlets(): BelongsToMany return $this->belongsToMany(Outlet::class)->withPivot('stock'); } + public function warehouses(): BelongsToMany + { + return $this->belongsToMany(Warehouse::class)->withPivot('stock'); + } + public function purchaseItems(): MorphMany { return $this->morphMany(PurchaseItem::class, 'purchasable'); @@ -49,4 +54,9 @@ public function items(): MorphMany { return $this->morphMany(OrderItem::class, 'orderable'); } + + public function restockItems(): MorphMany + { + return $this->morphMany(RestockItem::class, 'restockable'); + } } diff --git a/app/Services/StockActivityLogService.php b/app/Services/StockActivityLogService.php index dc810ac..5f76856 100644 --- a/app/Services/StockActivityLogService.php +++ b/app/Services/StockActivityLogService.php @@ -2,14 +2,167 @@ namespace App\Services; +use App\Models\Order; +use App\Models\Outlet; +use App\Models\Purchase; +use App\Models\Restock; +use App\Models\StockOpname; +use App\Models\Warehouse; use Illuminate\Database\Eloquent\Model; class StockActivityLogService { + public const LOG_PURCHASE = 'purchase'; + + public const LOG_DISTRIBUTION = 'distribution'; + + public const LOG_SALE = 'sale'; + + public const LOG_STOCK_OPNAME = 'stock_opname'; + /** - * Log stock related activity. + * Log purchase activity (Incoming to Warehouse). */ - public static function log( + public static function purchase( + Purchase $purchase, + Model $product, + int $quantity, + int $previousStock, + int $newStock, + ?Model $causedBy = null + ): void { + self::logWarehouse( + logName: self::LOG_PURCHASE, + event: 'increase', + description: "Purchase #{$purchase->hash_id}: Added {$quantity} stock to Warehouse.", + performedOn: $product, + warehouse: $purchase->warehouse, + quantityChange: $quantity, + previousStock: $previousStock, + newStock: $newStock, + extraProperties: [ + 'purchase_id' => $purchase->id, + 'purchase_hash' => $purchase->hash_id, + ], + causedBy: $causedBy + ); + } + + /** + * Log distribution activity (Warehouse to Outlet). + * This logs two events: Out from Warehouse, In to Outlet. + */ + public static function distribution( + Restock $restock, + Model $product, + int $quantity, + int $warehousePreviousStock, + int $warehouseNewStock, + int $outletPreviousStock, + int $outletNewStock, + ?Model $causedBy = null + ): void { + // Log Warehouse Out + self::logWarehouse( + logName: self::LOG_DISTRIBUTION, + event: 'decrease', + description: "Restock #{$restock->hash_id}: Moved {$quantity} stock from Warehouse to Outlet.", + performedOn: $product, + warehouse: $restock->warehouse, + quantityChange: -1 * abs($quantity), + previousStock: $warehousePreviousStock, + newStock: $warehouseNewStock, + extraProperties: [ + 'restock_id' => $restock->id, + 'restock_hash' => $restock->hash_id, + 'target_outlet_id' => $restock->outlet_id, + ], + causedBy: $causedBy + ); + + // Log Outlet In + self::logOutlet( + logName: self::LOG_DISTRIBUTION, + event: 'increase', + description: "Restock #{$restock->hash_id}: Received {$quantity} stock from Warehouse.", + performedOn: $product, + outlet: $restock->outlet, + quantityChange: abs($quantity), + previousStock: $outletPreviousStock, + newStock: $outletNewStock, + extraProperties: [ + 'restock_id' => $restock->id, + 'restock_hash' => $restock->hash_id, + 'source_warehouse_id' => $restock->warehouse_id, + ], + causedBy: $causedBy + ); + } + + /** + * Log sale activity (Outlet to Customer). + */ + public static function sale( + Order $order, + Model $product, + int $quantity, + int $previousStock, + int $newStock, + ?Model $causedBy = null + ): void { + self::logOutlet( + logName: self::LOG_SALE, + event: 'decrease', + description: "Order #{$order->hash_id}: Sold {$quantity} items.", + performedOn: $product, + outlet: $order->outlet, + quantityChange: -1 * abs($quantity), + previousStock: $previousStock, + newStock: $newStock, + extraProperties: [ + 'order_id' => $order->id, + 'order_hash' => $order->hash_id, + ], + causedBy: $causedBy ?? $order->user + ); + } + + /** + * Log stock opname activity (Adjustment in Outlet). + */ + public static function stockOpname( + StockOpname $stockOpname, + Model $product, + int $quantityChange, + int $previousStock, + int $newStock, + ?string $note = null, + ?Model $causedBy = null + ): void { + $event = $quantityChange > 0 ? 'increase' : 'decrease'; + + self::logOutlet( + logName: self::LOG_STOCK_OPNAME, + event: $event, + description: "Stock Opname #{$stockOpname->hash_id}: Adjusted stock by {$quantityChange}.", + performedOn: $product, + outlet: $stockOpname->outlet, + quantityChange: $quantityChange, + previousStock: $previousStock, + newStock: $newStock, + extraProperties: [ + 'stock_opname_id' => $stockOpname->id, + 'stock_opname_hash' => $stockOpname->hash_id, + 'note' => $note, + ], + causedBy: $causedBy + ); + } + + /** + * Log stock related activity for Outlet. + */ + public static function logOutlet( string $logName, string $event, string $description, @@ -28,6 +181,42 @@ public static function log( 'quantity_change' => $quantityChange, 'previous_stock' => $previousStock, 'new_stock' => $newStock, + 'location_type' => 'outlet', + 'location_name' => $outlet->name ?? 'Unknown Outlet', + ], $extraProperties); + + activity($logName) + ->performedOn($performedOn) + ->causedBy($causedBy) + ->withProperties($properties) + ->event($event) + ->log($description); + } + + /** + * Log stock related activity for Warehouse. + */ + public static function logWarehouse( + string $logName, + string $event, + string $description, + Model $performedOn, + Warehouse $warehouse, + int $quantityChange, + int $previousStock, + int $newStock, + array $extraProperties = [], + ?Model $causedBy = null + ): void { + $causedBy = $causedBy ?: auth()->user(); + + $properties = array_merge([ + 'warehouse_id' => $warehouse->getKey(), + 'quantity_change' => $quantityChange, + 'previous_stock' => $previousStock, + 'new_stock' => $newStock, + 'location_type' => 'warehouse', + 'location_name' => $warehouse->name ?? 'Unknown Warehouse', ], $extraProperties); activity($logName) diff --git a/app/Traits/Order/WithUpdateStock.php b/app/Traits/Order/WithUpdateStock.php index c2a2f16..e59eff5 100644 --- a/app/Traits/Order/WithUpdateStock.php +++ b/app/Traits/Order/WithUpdateStock.php @@ -39,24 +39,26 @@ public function increaseOutletStock($outlet, $item): void 'stock' => $currentStock + $quantity, ]); } else { + $currentStock = 0; $outlet->{$relation}()->attach($item->orderable_id, [ 'stock' => $quantity, ]); } + $newStock = $currentStock + $quantity; - StockActivityLogService::log( - logName: 'Order', - event: 'Menambah', - description: 'Stok '.$item->orderable->name.' di '.$outlet->name.' bertambah '.formatCurrencyNumber($quantity), + StockActivityLogService::logOutlet( + logName: StockActivityLogService::LOG_SALE, + event: 'increase', + description: 'Stok '.$item->orderable->name.' di '.$outlet->name.' bertambah '.formatCurrencyNumber($quantity).' (Pembatalan/Edit Order)', performedOn: $item->orderable, outlet: $outlet, - quantityChange: $quantity, + quantityChange: abs($quantity), previousStock: $currentStock, - newStock: $currentStock + $quantity, + newStock: $newStock, extraProperties: [ 'orderable_id' => $item->orderable_id, 'orderable_type' => $item->orderable_type, - 'purchase_id' => $item->purchase_id ?? null, + 'order_id' => $item->order_id ?? null, ] ); } @@ -101,21 +103,33 @@ public function decreaseOutletStock($outlet, $item) 'stock' => $newStock, ]); - StockActivityLogService::log( - logName: 'Order', - event: 'Mengurangi', - description: 'Stok '.$item->orderable->name.' di '.$outlet->name.' berkurang '.formatCurrencyNumber($quantity), - performedOn: $item->orderable, - outlet: $outlet, - quantityChange: $quantity, - previousStock: $currentStock, - newStock: $currentStock - $quantity, - extraProperties: [ - 'orderable_id' => $item->orderable_id, - 'orderable_type' => $item->orderable_type, - 'purchase_id' => $item->purchase_id ?? null, - ] - ); + $order = $item->order ?? \App\Models\Order::find($item->order_id); + + if ($order) { + StockActivityLogService::sale( + order: $order, + product: $item->orderable, + quantity: $quantity, + previousStock: $currentStock, + newStock: $newStock + ); + } else { + StockActivityLogService::logOutlet( + logName: StockActivityLogService::LOG_SALE, + event: 'decrease', + description: 'Stok '.$item->orderable->name.' di '.$outlet->name.' berkurang '.formatCurrencyNumber($quantity), + performedOn: $item->orderable, + outlet: $outlet, + quantityChange: -1 * abs($quantity), + previousStock: $currentStock, + newStock: $newStock, + extraProperties: [ + 'orderable_id' => $item->orderable_id, + 'orderable_type' => $item->orderable_type, + 'order_id' => $item->order_id ?? null, + ] + ); + } return ['status' => true]; } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e216150..2a29def 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,6 +15,11 @@ public function run(): void OutletSeeder::class, UserSeeder::class, TierSeeder::class, + BrandSeeder::class, + CategorySeeder::class, + PerfumeSeeder::class, + ProductSeeder::class, + BottleSeeder::class, ]); } else { $this->call([ diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index c0080c9..0021517 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -226,6 +226,7 @@ public function run(): void 'update outlet', 'delete outlet', + 'view user', 'create user', 'update user', 'delete user', @@ -267,7 +268,6 @@ public function run(): void 'delete bottle', 'audit bottle', - 'view payroll', 'manage adjustment', 'delete order', @@ -379,7 +379,6 @@ public function run(): void 'update bottle', 'delete bottle', - 'view payroll', 'manage adjustment', 'delete order', @@ -388,6 +387,11 @@ public function run(): void 'create purchase', 'delete purchase', + 'view restock', + 'create restock', + 'update restock', + 'delete restock', + 'view stock opname', 'manage stock opname', 'show stock opname', diff --git a/resources/views/livewire/studio/manage/restock/form.blade.php b/resources/views/livewire/studio/manage/restock/form.blade.php new file mode 100644 index 0000000..5f5bf98 --- /dev/null +++ b/resources/views/livewire/studio/manage/restock/form.blade.php @@ -0,0 +1,213 @@ + +
+
+ {{ $pageTitle }} +
+
+ + Kembali + +
+
+ +
+ + Informasi Restock + + Proses restock akan **mengurangi** stok di Gudang dan **menambah** stok di Outlet yang dipilih. + + + +
+
+ + + +
+ + Tanggal Restock * + + + + + + Gudang * + + @foreach ($warehouses as $key => $value) + {{ $value }} + + @endforeach + + + + + + Outlet * + + @foreach ($outlets as $key => $value) + {{ $value }} + + @endforeach + + + +
+
+ + @if ($form->warehouse_id && $form->outlet_id) +
+ + + @foreach ($perfumes as $key => $perfume) + {{ $perfume }} + + @endforeach + + + +
+ + Tambah + +
+
+ + + + @foreach ($products as $key => $product) + {{ $product }} + + @endforeach + + + +
+ + Tambah + +
+
+ + + + @foreach ($bottles as $key => $bottle) + {{ $bottle }} + + @endforeach + + + +
+ + Tambah + +
+
+
+ @else + + Silakan pilih Gudang Asal dan Outlet Tujuan terlebih dahulu untuk menampilkan daftar item. + + @endif +
+ +
+ +
+

Gambar

+
+ + @error('form.image') +
+ {{ $message }} +
+ @enderror +
+
+
+ +

Keranjang Restock

+ +
+ + + Item + Qty + Aksi + + + + @forelse($restockItems as $item) + + + +
{{ $item->restockable->name }}
+
+
+ + {{ formatCurrencyNumber($item->quantity, '') }} + + + + + + + + + + + + +
+ @empty + + + Keranjang Kosong... + + + @endforelse +
+
+
+
+
+
+ +
+ + Simpan + +
+
+ + +
+ Ubah {{ $modalTitle }} + + + +
+ + + Perbarui + +
+
+
+
diff --git a/resources/views/livewire/studio/manage/restock/index.blade.php b/resources/views/livewire/studio/manage/restock/index.blade.php new file mode 100644 index 0000000..d6d7a8d --- /dev/null +++ b/resources/views/livewire/studio/manage/restock/index.blade.php @@ -0,0 +1,35 @@ + +
+
+ {{ $pageTitle }} +
+ @can('create restock') +
+ + Tambah + +
+ @endcan +
+ +
+ + Informasi + + Menghapus data restock otomatis mengembalikan stok gudang. + + + + +
+ + @include('components.modals.confirmation', [ + 'modalName' => 'delete-confirmation', + 'modalTitle' => 'Apakah Anda yakin?', + 'modalMessage' => 'Data restock akan dihapus dari riwayat.', + 'buttonVariant' => 'primary', + 'buttonColor' => 'danger', + 'buttonText' => 'Ya, Hapus', + ]) +
diff --git a/routes/pages/studio.php b/routes/pages/studio.php index df8f195..5129225 100644 --- a/routes/pages/studio.php +++ b/routes/pages/studio.php @@ -22,7 +22,6 @@ use App\Livewire\Studio\Finance\Payroll as PayrollComponent; use App\Livewire\Studio\Information\Broadcast as BroadcastComponent; use App\Livewire\Studio\Information\Faq as FaqComponent; -use App\Livewire\Studio\Information\PriceRequest as PriceRequestComponent; use App\Livewire\Studio\Information\Testimonial as TestimonialComponent; use App\Livewire\Studio\Loyalty\Customer as CustomerComponent; use App\Livewire\Studio\Loyalty\RedemptionVerification; @@ -39,6 +38,8 @@ use App\Livewire\Studio\Manage\Order\Show as OrderShow; use App\Livewire\Studio\Manage\Purchase\Create as PurchaseCreate; use App\Livewire\Studio\Manage\Purchase\Index as PurchaseIndex; +use App\Livewire\Studio\Manage\Restock\Create as RestockCreate; +use App\Livewire\Studio\Manage\Restock\Index as RestockIndex; use App\Livewire\Studio\Manage\StockOpname\Index as StockOpnameIndex; use App\Livewire\Studio\Manage\StockOpname\Manage as StockOpnameManage; use App\Livewire\Studio\Manage\StockOpname\Show as StockOpnameShow; @@ -50,7 +51,8 @@ use App\Livewire\Studio\Master\User\Create as UserCreate; use App\Livewire\Studio\Master\User\Edit as UserEdit; use App\Livewire\Studio\Master\User\Index as UserIndex; -use App\Livewire\Studio\Master\Warehouse as WarehouseComponent; +use App\Livewire\Studio\Master\Warehouse\Index as WarehouseIndex; +use App\Livewire\Studio\Master\Warehouse\Show as WarehouseShow; use App\Livewire\Studio\Setting\Account as AccountComponent; use App\Livewire\Studio\Setting\Application as ApplicationComponent; use Illuminate\Support\Facades\Route; @@ -73,8 +75,9 @@ Route::get('/{outlet}/edit', OutletEdit::class)->name('edit')->middleware('can:update outlet'); }); - Route::prefix('warehouse')->name('warehouse.')->group(function () { - Route::get('/', WarehouseComponent::class)->name('index')->middleware('can:view warehouse'); + Route::prefix('warehouses')->name('warehouse.')->group(function () { + Route::get('/', WarehouseIndex::class)->name('index')->middleware('can:view warehouse'); + Route::get('/{warehouse}/show', WarehouseShow::class)->name('show')->middleware('can:view warehouse'); }); Route::prefix('users')->name('user.')->group(function () { @@ -165,6 +168,11 @@ Route::get('/create', PurchaseCreate::class)->name('create')->middleware('can:create purchase'); }); + Route::prefix('restocks')->name('restock.')->group(function () { + Route::get('/', RestockIndex::class)->name('index')->middleware('can:view restock'); + Route::get('/create', RestockCreate::class)->name('create')->middleware('can:create restock'); + }); + Route::prefix('stock-opname')->name('stock_opname.')->group(function () { Route::get('/', StockOpnameIndex::class)->name('index')->middleware('can:view stock opname'); Route::get('/{stockOpname}/show', StockOpnameShow::class)->name('show')->middleware('can:show stock opname'); @@ -181,12 +189,6 @@ Route::prefix('information')->name('studio.information.')->group(function () { Route::get('/broadcasts', BroadcastComponent::class)->name('broadcast.index')->middleware('can:view broadcast'); - Route::prefix('price-requests')->name('price_request.')->group(function () { - Route::get('/', PriceRequestComponent::class)->name('index')->middleware('can:view price request'); - Route::patch('/{priceRequest}/approve', PriceRequestComponent::class)->name('approve')->middleware('can:approve price request'); - Route::patch('/{priceRequest}/reject', PriceRequestComponent::class)->name('reject')->middleware('can:reject price request'); - }); - Route::prefix('faqs')->name('faq.')->group(function () { Route::get('/', FaqComponent::class)->name('index')->middleware('can:view faq'); });