From dffc9f9e7250870be6bb472e0e36253808f00ce5 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 9 Jan 2026 21:41:49 +0700 Subject: [PATCH] feat: add stock transfer management functionality - Implemented stock transfer creation and management with Livewire components. - Created StockTransfer and StockTransferItem models with necessary relationships. - Added migration files for stock_transfers and stock_transfer_items tables. - Developed traits for handling stock transfer items (add, delete, update). - Integrated stock transfer logging in StockActivityLogService. - Updated ViewServiceProvider to include stock transfer permissions and routes. - Created frontend views for stock transfer form and index with appropriate UI components. - Added role permissions for stock transfer actions in RolePermissionSeeder. --- .../Datatable/Manage/StockTransfersTable.php | 78 +++++++ .../Forms/Studio/Manage/StockTransferForm.php | 129 +++++++++++ .../Studio/Manage/StockTransfer/Create.php | 130 +++++++++++ .../Studio/Manage/StockTransfer/Index.php | 49 ++++ app/Models/StockTransfer.php | 55 +++++ app/Models/StockTransferItem.php | 46 ++++ app/Providers/ViewServiceProvider.php | 7 + app/Services/StockActivityLogService.php | 54 +++++ .../WithStockTransferAddItem.php | 112 +++++++++ .../WithStockTransferDeleteItem.php | 18 ++ .../StockTransfer/WithStockTransferItems.php | 19 ++ .../StockTransfer/WithStockTransferStock.php | 169 ++++++++++++++ .../WithStockTransferUpdateItem.php | 84 +++++++ ...09_212348_create_stock_transfers_table.php | 33 +++ ...2415_create_stock_transfer_items_table.php | 33 +++ database/seeders/RolePermissionSeeder.php | 10 + .../manage/stock-transfer/form.blade.php | 213 ++++++++++++++++++ .../manage/stock-transfer/index.blade.php | 35 +++ routes/pages/studio.php | 7 + 19 files changed, 1281 insertions(+) create mode 100644 app/Livewire/Datatable/Manage/StockTransfersTable.php create mode 100644 app/Livewire/Forms/Studio/Manage/StockTransferForm.php create mode 100644 app/Livewire/Studio/Manage/StockTransfer/Create.php create mode 100644 app/Livewire/Studio/Manage/StockTransfer/Index.php create mode 100644 app/Models/StockTransfer.php create mode 100644 app/Models/StockTransferItem.php create mode 100644 app/Traits/StockTransfer/WithStockTransferAddItem.php create mode 100644 app/Traits/StockTransfer/WithStockTransferDeleteItem.php create mode 100644 app/Traits/StockTransfer/WithStockTransferItems.php create mode 100644 app/Traits/StockTransfer/WithStockTransferStock.php create mode 100644 app/Traits/StockTransfer/WithStockTransferUpdateItem.php create mode 100644 database/migrations/2026_01_09_212348_create_stock_transfers_table.php create mode 100644 database/migrations/2026_01_09_212415_create_stock_transfer_items_table.php create mode 100644 resources/views/livewire/studio/manage/stock-transfer/form.blade.php create mode 100644 resources/views/livewire/studio/manage/stock-transfer/index.blade.php diff --git a/app/Livewire/Datatable/Manage/StockTransfersTable.php b/app/Livewire/Datatable/Manage/StockTransfersTable.php new file mode 100644 index 0000000..67c2a0c --- /dev/null +++ b/app/Livewire/Datatable/Manage/StockTransfersTable.php @@ -0,0 +1,78 @@ +searchable(), + + Column::make('Outlet Tujuan', 'destinationOutlet.name')->searchable(), + + Column::make('Tanggal Transfer', 'transfer_date') + ->format(fn ($value) => formatDateLocalized($value)) + ->searchable() + ->sortable(), + + Column::make('Catatan', 'note')->searchable(), + + ArrayColumn::make('Item') + ->data( + fn ($value, $row) => $row->items->map(fn ($item) => [ + 'name' => $item->transferable?->name, + 'quantity' => formatCurrencyNumber($item->quantity, ''), + ])->toArray() + ) + ->outputFormat( + fn ($index, $value) => " +
+
{$value['name']}
+
Qty: {$value['quantity']}
+
" + ) + ->flexCol(['class' => 'flex-col gap-3']), + + Column::make('Aksi') + ->label(function ($row) { + $actions = ''; + + if (auth()->user()->can('delete stock transfer')) { + $actions .= view('components.actions.table.delete', [ + 'id' => $row->hash, + ])->render(); + } + + return $actions; + }) + ->html() + ->hideIf(auth()->user()->cannot('update stock transfer')), + ]; + } + + public function builder(): Builder + { + return StockTransfer::select('stock_transfers.id', 'source_outlet_id', 'destination_outlet_id', 'transfer_date', 'note') + ->with(['sourceOutlet', 'destinationOutlet', 'items', 'items.transferable']) + ->whereIn('source_outlet_id', auth()->user()->outlets->pluck('id')->toArray()) + ->orWhereIn('destination_outlet_id', auth()->user()->outlets->pluck('id')->toArray()); + } +} diff --git a/app/Livewire/Forms/Studio/Manage/StockTransferForm.php b/app/Livewire/Forms/Studio/Manage/StockTransferForm.php new file mode 100644 index 0000000..3558a11 --- /dev/null +++ b/app/Livewire/Forms/Studio/Manage/StockTransferForm.php @@ -0,0 +1,129 @@ + ['nullable', 'string', 'max:100'], + 'transfer_date' => ['required', 'date', 'before_or_equal:today'], + 'source_outlet_id' => ['required', Rule::exists('outlets', 'id')], + 'destination_outlet_id' => ['required', Rule::exists('outlets', 'id'), 'different:source_outlet_id'], + 'image' => ['nullable', 'array', 'max:1'], + ]; + } + + public function validationAttributes(): array + { + return [ + 'note' => 'catatan', + 'transfer_date' => 'tanggal transfer', + 'source_outlet_id' => 'outlet asal', + 'destination_outlet_id' => 'outlet tujuan', + 'image' => 'gambar', + ]; + } + + public function setStockTransfer(StockTransfer $stockTransfer): void + { + $this->stockTransfer = $stockTransfer; + + $this->note = $stockTransfer->note; + $this->transfer_date = $stockTransfer->transfer_date; + $this->source_outlet_id = $stockTransfer->source_outlet_id; + $this->destination_outlet_id = $stockTransfer->destination_outlet_id; + + $this->image = $this->mapMediaCollection($stockTransfer->getMedia('image')); + } + + public function store(): void + { + $this->validate(); + + $stockTransferItems = $this->loadStockTransferItems(); + + DB::transaction(function () use ($stockTransferItems) { + $data = $this->prepareSavedData(); + + $stockTransfer = StockTransfer::create($data); + + $stockTransfer->load(['sourceOutlet', 'destinationOutlet']); + + $this->uploadMedia($this->image, $stockTransfer, 'image'); + + foreach ($stockTransferItems as $item) { + if ($item->quantity <= 0) { + continue; + } + + $item->update(['stock_transfer_id' => $stockTransfer->id]); + + $this->transferStockBetweenOutlets($stockTransfer->sourceOutlet, $stockTransfer->destinationOutlet, $item); + } + }); + } + + public function update(): void + { + $this->validate(); + + DB::transaction(function () { + $data = $this->prepareSavedData(); + + $this->stockTransfer->update($data); + + $this->syncMedia($this->image, $this->stockTransfer, 'image'); + $this->uploadMedia($this->image, $this->stockTransfer, 'image'); + }); + } + + private function prepareSavedData(): array + { + return [ + 'note' => $this->note, + 'source_outlet_id' => $this->source_outlet_id, + 'destination_outlet_id' => $this->destination_outlet_id, + 'transfer_date' => $this->transfer_date, + ]; + } +} diff --git a/app/Livewire/Studio/Manage/StockTransfer/Create.php b/app/Livewire/Studio/Manage/StockTransfer/Create.php new file mode 100644 index 0000000..9333202 --- /dev/null +++ b/app/Livewire/Studio/Manage/StockTransfer/Create.php @@ -0,0 +1,130 @@ +sourceOutlets = auth()->user()->outlets->pluck('name', 'id')->toArray(); + $this->destinationOutlets = Outlet::orderBy('name')->pluck('name', 'id')->toArray(); + + $this->stockTransferItems = $this->loadStockTransferItems(); + } + + public function updated($property, $value): void + { + if ($property === 'form.source_outlet_id' || $property === 'form.destination_outlet_id') { + $this->loadCreateItems(); + } + } + + private function loadCreateItems(): void + { + $this->reset(['perfumes', 'products', 'bottles']); + + $this->form->reset( + 'perfume_id', + 'quantity_perfume', + 'product_id', + 'quantity_product', + 'bottle_id', + 'quantity_bottle' + ); + + if (! $this->form->source_outlet_id || ! $this->form->destination_outlet_id) { + return; + } + + $sourceOutlet = Outlet::find($this->form->source_outlet_id); + + if (! $sourceOutlet) { + return; + } + + $this->perfumes = $sourceOutlet->perfumes() + ->wherePivot('stock', '>', 0) + ->orderBy('name') + ->get() + ->pluck('name', 'id') + ->toArray(); + + $this->products = $sourceOutlet->products() + ->wherePivot('stock', '>', 0) + ->orderBy('name') + ->get() + ->pluck('name', 'id') + ->toArray(); + + $this->bottles = $sourceOutlet->bottles() + ->wherePivot('stock', '>', 0) + ->orderBy('name') + ->get() + ->pluck('name', 'id') + ->toArray(); + } + + public function save(): void + { + $this->canOrAbort('create stock transfer'); + + if ($this->stockTransferItems->isEmpty()) { + $this->toast('Keranjang transfer stok tidak boleh kosong.', 'Gagal', 'danger'); + + return; + } + + try { + $this->form->store(); + } catch (ValidationException $e) { + throw $e; + } catch (\Exception $e) { + $this->toast($e->getMessage(), 'Gagal', 'danger'); + + return; + } + + $this->redirectRoute('studio.manage.stock_transfer.index', [ + 'notification' => 'Transfer stok berhasil diproses.', + ], navigate: true); + } + + public function render(): View + { + return view('livewire.studio.manage.stock-transfer.form', [ + 'pageTitle' => 'Tambah Transfer Stok Antar Outlet', + ]); + } +} diff --git a/app/Livewire/Studio/Manage/StockTransfer/Index.php b/app/Livewire/Studio/Manage/StockTransfer/Index.php new file mode 100644 index 0000000..6fab4e6 --- /dev/null +++ b/app/Livewire/Studio/Manage/StockTransfer/Index.php @@ -0,0 +1,49 @@ +canOrAbort('delete stock transfer'); + + DB::transaction(function () use ($stockTransfer) { + foreach ($stockTransfer->items as $item) { + $this->reverseStockTransferBetweenOutlets($stockTransfer->sourceOutlet, $stockTransfer->destinationOutlet, $item); + } + + $stockTransfer->delete(); + }); + + $this->dispatch('refreshDatatable'); + + $this->toast('Transfer stok berhasil dihapus.'); + + Flux::modals()->close(); + } + + public function render(): View + { + return view('livewire.studio.manage.stock-transfer.index', [ + 'pageTitle' => 'Transfer Stok Antar Outlet', + ]); + } +} diff --git a/app/Models/StockTransfer.php b/app/Models/StockTransfer.php new file mode 100644 index 0000000..f87d8fe --- /dev/null +++ b/app/Models/StockTransfer.php @@ -0,0 +1,55 @@ + 'date', + ]; + } + + #[Scope] + public function today(Builder $query): void + { + $query->whereDate('created_at', today()); + } + + #[Scope] + public function yesterday(Builder $query): void + { + $query->whereDate('created_at', today()->subDay()); + } + + public function sourceOutlet(): BelongsTo + { + return $this->belongsTo(Outlet::class, 'source_outlet_id'); + } + + public function destinationOutlet(): BelongsTo + { + return $this->belongsTo(Outlet::class, 'destination_outlet_id'); + } + + public function items(): HasMany + { + return $this->hasMany(StockTransferItem::class); + } +} diff --git a/app/Models/StockTransferItem.php b/app/Models/StockTransferItem.php new file mode 100644 index 0000000..d75ed5c --- /dev/null +++ b/app/Models/StockTransferItem.php @@ -0,0 +1,46 @@ + 'int', + ]; + } + + #[Scope] + public function currentUser(Builder $query): void + { + $query->where('user_id', auth()->id()); + } + + public function stockTransfer(): BelongsTo + { + return $this->belongsTo(StockTransfer::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function transferable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php index 4ab15df..371a47f 100644 --- a/app/Providers/ViewServiceProvider.php +++ b/app/Providers/ViewServiceProvider.php @@ -188,6 +188,13 @@ public function boot(): void 'match' => 'studio.manage.restock.*', 'can' => 'view restock', ], + [ + 'label' => 'Transfer Stok', + 'icon' => 'clipboard-document-list', + 'route' => 'studio.manage.stock_transfer.index', + 'match' => 'studio.manage.stock_transfer.*', + 'can' => 'view stock transfer', + ], [ 'label' => 'Stock Opname', 'icon' => 'clipboard-document-check', diff --git a/app/Services/StockActivityLogService.php b/app/Services/StockActivityLogService.php index 5f76856..f086519 100644 --- a/app/Services/StockActivityLogService.php +++ b/app/Services/StockActivityLogService.php @@ -7,6 +7,7 @@ use App\Models\Purchase; use App\Models\Restock; use App\Models\StockOpname; +use App\Models\StockTransfer; use App\Models\Warehouse; use Illuminate\Database\Eloquent\Model; @@ -20,6 +21,8 @@ class StockActivityLogService public const LOG_STOCK_OPNAME = 'stock_opname'; + public const LOG_TRANSFER = 'transfer'; + /** * Log purchase activity (Incoming to Warehouse). */ @@ -159,6 +162,57 @@ public static function stockOpname( ); } + /** + * Log transfer activity (Outlet to Outlet). + * This logs two events: Out from Source Outlet, In to Destination Outlet. + */ + public static function transfer( + StockTransfer $stockTransfer, + Model $product, + int $quantity, + int $sourcePreviousStock, + int $sourceNewStock, + int $destinationPreviousStock, + int $destinationNewStock, + ?Model $causedBy = null + ): void { + // Log Source Outlet Out + self::logOutlet( + logName: self::LOG_TRANSFER, + event: 'decrease', + description: "Stock Transfer #{$stockTransfer->hash_id}: Moved {$quantity} stock from Source to Destination Outlet.", + performedOn: $product, + outlet: $stockTransfer->sourceOutlet, + quantityChange: -1 * abs($quantity), + previousStock: $sourcePreviousStock, + newStock: $sourceNewStock, + extraProperties: [ + 'stock_transfer_id' => $stockTransfer->id, + 'stock_transfer_hash' => $stockTransfer->hash_id, + 'destination_outlet_id' => $stockTransfer->destination_outlet_id, + ], + causedBy: $causedBy + ); + + // Log Destination Outlet In + self::logOutlet( + logName: self::LOG_TRANSFER, + event: 'increase', + description: "Stock Transfer #{$stockTransfer->hash_id}: Received {$quantity} stock from Source Outlet.", + performedOn: $product, + outlet: $stockTransfer->destinationOutlet, + quantityChange: abs($quantity), + previousStock: $destinationPreviousStock, + newStock: $destinationNewStock, + extraProperties: [ + 'stock_transfer_id' => $stockTransfer->id, + 'stock_transfer_hash' => $stockTransfer->hash_id, + 'source_outlet_id' => $stockTransfer->source_outlet_id, + ], + causedBy: $causedBy + ); + } + /** * Log stock related activity for Outlet. */ diff --git a/app/Traits/StockTransfer/WithStockTransferAddItem.php b/app/Traits/StockTransfer/WithStockTransferAddItem.php new file mode 100644 index 0000000..1460433 --- /dev/null +++ b/app/Traits/StockTransfer/WithStockTransferAddItem.php @@ -0,0 +1,112 @@ +canOrAbort('create stock transfer'); + + if (! $this->form->source_outlet_id) { + $this->toast('Silakan pilih outlet asal terlebih dahulu.', 'Gagal', 'warning'); + + return; + } + + $sourceOutlet = Outlet::find($this->form->source_outlet_id); + + if (! $sourceOutlet) { + $this->toast('Outlet asal tidak ditemukan.', 'Gagal', 'danger'); + + return; + } + + $data = match ($type) { + 'parfum' => [ + 'transferable_type' => Perfume::class, + 'transferable_id' => $this->form->perfume_id, + 'quantity' => parseRupiahToInt($this->form->quantity_perfume), + ], + 'produk' => [ + 'transferable_type' => Product::class, + 'transferable_id' => $this->form->product_id, + 'quantity' => parseRupiahToInt($this->form->quantity_product), + ], + 'botol' => [ + 'transferable_type' => Bottle::class, + 'transferable_id' => $this->form->bottle_id, + 'quantity' => parseRupiahToInt($this->form->quantity_bottle), + ], + default => null, + }; + + if (! $data || ! $data['transferable_id'] || $data['quantity'] <= 0) { + $this->toast('Data item tidak valid.', 'Gagal', 'danger'); + + return; + } + + // Validate stock availability + $relationName = match ($type) { + 'parfum' => 'perfumes', + 'produk' => 'products', + 'botol' => 'bottles', + default => null, + }; + + if ($relationName) { + $sourceStock = $sourceOutlet->{$relationName}() + ->where($data['transferable_type']::make()->getTable().'.id', $data['transferable_id']) + ->first() + ?->pivot + ?->stock ?? 0; + + // Is item already in cart? + $existing = StockTransferItem::where([ + 'user_id' => auth()->id(), + 'transferable_id' => $data['transferable_id'], + 'transferable_type' => $data['transferable_type'], + ])->whereNull('stock_transfer_id')->first(); + + $currentCartQty = $existing ? $existing->quantity : 0; + $requestedQty = $data['quantity']; + + if (($currentCartQty + $requestedQty) > $sourceStock) { + $this->toast("Stok tidak mencukupi. Stok di outlet asal: {$sourceStock}. Sudah di keranjang: {$currentCartQty}.", 'Gagal', 'danger'); + + return; + } + + if ($existing) { + $existing->increment('quantity', $requestedQty); + } else { + StockTransferItem::create(array_merge($data, ['user_id' => auth()->id()])); + } + } + + $this->resetFormItem($type); + $this->stockTransferItems = $this->loadStockTransferItems(); + $this->toast('Item berhasil ditambahkan ke keranjang.'); + } + + private function resetFormItem(string $type): void + { + if ($type === 'parfum') { + $this->form->perfume_id = ''; + $this->form->quantity_perfume = ''; + } elseif ($type === 'produk') { + $this->form->product_id = ''; + $this->form->quantity_product = ''; + } elseif ($type === 'botol') { + $this->form->bottle_id = ''; + $this->form->quantity_bottle = ''; + } + } +} diff --git a/app/Traits/StockTransfer/WithStockTransferDeleteItem.php b/app/Traits/StockTransfer/WithStockTransferDeleteItem.php new file mode 100644 index 0000000..04ede10 --- /dev/null +++ b/app/Traits/StockTransfer/WithStockTransferDeleteItem.php @@ -0,0 +1,18 @@ +canOrAbort('create stock transfer'); + + $item->delete(); + + $this->stockTransferItems = $this->loadStockTransferItems(); + $this->toast('Item berhasil dihapus dari keranjang.'); + } +} diff --git a/app/Traits/StockTransfer/WithStockTransferItems.php b/app/Traits/StockTransfer/WithStockTransferItems.php new file mode 100644 index 0000000..b132a42 --- /dev/null +++ b/app/Traits/StockTransfer/WithStockTransferItems.php @@ -0,0 +1,19 @@ +stockTransferItems = StockTransferItem::query() + ->currentUser() + ->where('user_id', auth()->id()) + ->whereNull('stock_transfer_id') + ->latest() + ->get(); + } +} diff --git a/app/Traits/StockTransfer/WithStockTransferStock.php b/app/Traits/StockTransfer/WithStockTransferStock.php new file mode 100644 index 0000000..7867f23 --- /dev/null +++ b/app/Traits/StockTransfer/WithStockTransferStock.php @@ -0,0 +1,169 @@ +quantity; + + switch ($item->transferable_type) { + case Perfume::class: + $relation = 'perfumes'; + break; + case Product::class: + $relation = 'products'; + break; + case Bottle::class: + $relation = 'bottles'; + break; + default: + return; + } + + // Decrease source outlet stock + $sourceItem = $sourceOutlet->{$relation}() + ->where("{$relation}.id", $item->transferable_id) + ->lockForUpdate() + ->withPivot('stock') + ->first(); + + $sourcePreviousStock = 0; + + if ($sourceItem) { + $sourcePreviousStock = $sourceItem->pivot->stock ?? 0; + } + + if ($sourcePreviousStock < $quantity) { + throw new \Exception("Stok {$item->transferable->name} di outlet asal tidak mencukupi. Tersisa {$sourcePreviousStock}, diminta {$quantity}."); + } + + $sourceNewStock = $sourcePreviousStock - $quantity; + + $sourceOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [ + 'stock' => $sourceNewStock, + ]); + + // Increase destination outlet stock + $destinationItem = $destinationOutlet->{$relation}() + ->where("{$relation}.id", $item->transferable_id) + ->lockForUpdate() + ->withPivot('stock') + ->first(); + + if ($destinationItem) { + $destinationPreviousStock = $destinationItem->pivot->stock ?? 0; + $destinationOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [ + 'stock' => $destinationPreviousStock + $quantity, + ]); + } else { + $destinationPreviousStock = 0; + $destinationOutlet->{$relation}()->attach($item->transferable_id, [ + 'stock' => $quantity, + ]); + } + $destinationNewStock = $destinationPreviousStock + $quantity; + + $stockTransfer = $item->stockTransfer ?? StockTransfer::find($item->stock_transfer_id); + + if ($stockTransfer) { + StockActivityLogService::transfer( + stockTransfer: $stockTransfer, + product: $item->transferable, + quantity: $quantity, + sourcePreviousStock: $sourcePreviousStock, + sourceNewStock: $sourceNewStock, + destinationPreviousStock: $destinationPreviousStock, + destinationNewStock: $destinationNewStock + ); + } + } + + public function reverseStockTransferBetweenOutlets($sourceOutlet, $destinationOutlet, $item): void + { + $quantity = $item->quantity; + + switch ($item->transferable_type) { + case Perfume::class: + $relation = 'perfumes'; + break; + case Product::class: + $relation = 'products'; + break; + case Bottle::class: + $relation = 'bottles'; + break; + default: + return; + } + + // Return items to source outlet + $sourcePivot = $sourceOutlet->{$relation}() + ->where("{$relation}.id", $item->transferable_id) + ->first(); + + $currentSourceStock = $sourcePivot?->pivot?->stock ?? 0; + $newSourceStock = $currentSourceStock + $quantity; + + if ($sourcePivot) { + $sourceOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [ + 'stock' => $newSourceStock, + ]); + } else { + $sourceOutlet->{$relation}()->attach($item->transferable_id, [ + 'stock' => $quantity, + ]); + } + + $stockTransfer = $item->stockTransfer ?? StockTransfer::find($item->stock_transfer_id); + + if ($stockTransfer) { + StockActivityLogService::logOutlet( + logName: StockActivityLogService::LOG_TRANSFER, + event: 'increase', + description: "Stock Transfer #{$stockTransfer->hash_id} Deleted: Returned {$quantity} stock to source outlet.", + performedOn: $item->transferable, + outlet: $sourceOutlet, + quantityChange: $quantity, + previousStock: $currentSourceStock, + newStock: $newSourceStock, + extraProperties: ['stock_transfer_id' => $stockTransfer->id] + ); + } + + // Remove items from destination outlet + $destinationPivot = $destinationOutlet->{$relation}() + ->where("{$relation}.id", $item->transferable_id) + ->first(); + + $currentDestinationStock = $destinationPivot?->pivot?->stock ?? 0; + $newDestinationStock = max(0, $currentDestinationStock - $quantity); + + if ($destinationPivot) { + $destinationOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [ + 'stock' => $newDestinationStock, + ]); + } + + if ($stockTransfer) { + StockActivityLogService::logOutlet( + logName: StockActivityLogService::LOG_TRANSFER, + event: 'decrease', + description: "Stock Transfer #{$stockTransfer->hash_id} Deleted: Removed {$quantity} stock from destination outlet.", + performedOn: $item->transferable, + outlet: $destinationOutlet, + quantityChange: -1 * abs($quantity), + previousStock: $currentDestinationStock, + newStock: $newDestinationStock, + extraProperties: ['stock_transfer_id' => $stockTransfer->id] + ); + } + } +} diff --git a/app/Traits/StockTransfer/WithStockTransferUpdateItem.php b/app/Traits/StockTransfer/WithStockTransferUpdateItem.php new file mode 100644 index 0000000..a0fbd91 --- /dev/null +++ b/app/Traits/StockTransfer/WithStockTransferUpdateItem.php @@ -0,0 +1,84 @@ +canOrAbort('create stock transfer'); + + if (! $this->form->source_outlet_id) { + $this->toast('Silakan pilih outlet asal terlebih dahulu.', 'Gagal', 'warning'); + + return; + } + + $sourceOutlet = Outlet::find($this->form->source_outlet_id); + + if (! $sourceOutlet) { + $this->toast('Outlet asal tidak ditemukan.', 'Gagal', 'danger'); + + return; + } + + $item = StockTransferItem::find($this->form->item_id); + + if ($item) { + $newQuantity = parseRupiahToInt($this->form->quantity_edit); + + if ($newQuantity <= 0) { + $this->toast('Jumlah harus lebih dari 0.', 'Gagal', 'danger'); + + return; + } + + $relationName = match ($item->transferable_type) { + Perfume::class => 'perfumes', + Product::class => 'products', + Bottle::class => 'bottles', + default => null, + }; + + if ($relationName) { + $sourceStock = $sourceOutlet->{$relationName}() + ->where($item->transferable_type::make()->getTable().'.id', $item->transferable_id) + ->first() + ?->pivot + ?->stock ?? 0; + + if ($newQuantity > $sourceStock) { + $this->toast("Stok tidak mencukupi (Update). Stok di outlet asal: {$sourceStock}.", 'Gagal', 'danger'); + + return; + } + } + + $item->update([ + 'quantity' => $newQuantity, + ]); + } + + $this->stockTransferItems = $this->loadStockTransferItems(); + $this->toast('Item berhasil diperbarui.'); + $this->dispatch('modal:close', 'form-modal'); + } + + #[On('modal:open')] + public function openModal($item): void + { + $item = StockTransferItem::find($item['item']); + $this->form->item_id = $item->id; + $this->form->quantity_edit = formatCurrencyNumber($item->quantity, ''); + $this->modalTitle = $item->transferable->name; + } +} diff --git a/database/migrations/2026_01_09_212348_create_stock_transfers_table.php b/database/migrations/2026_01_09_212348_create_stock_transfers_table.php new file mode 100644 index 0000000..4b4b3e0 --- /dev/null +++ b/database/migrations/2026_01_09_212348_create_stock_transfers_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('source_outlet_id')->constrained('outlets')->cascadeOnDelete(); + $table->foreignId('destination_outlet_id')->constrained('outlets')->cascadeOnDelete(); + $table->date('transfer_date'); + $table->string('note', 100)->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stock_transfers'); + } +}; diff --git a/database/migrations/2026_01_09_212415_create_stock_transfer_items_table.php b/database/migrations/2026_01_09_212415_create_stock_transfer_items_table.php new file mode 100644 index 0000000..5ad327d --- /dev/null +++ b/database/migrations/2026_01_09_212415_create_stock_transfer_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('stock_transfer_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->morphs('transferable'); + $table->unsignedInteger('quantity'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stock_transfer_items'); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 9c25c00..1b902c7 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -139,6 +139,11 @@ public function run(): void 'update restock', 'delete restock', + 'view stock transfer', + 'create stock transfer', + 'update stock transfer', + 'delete stock transfer', + 'view reward', 'create reward', 'update reward', @@ -399,6 +404,11 @@ public function run(): void 'update restock', 'delete restock', + 'view stock transfer', + 'create stock transfer', + 'update stock transfer', + 'delete stock transfer', + 'view reward', 'create reward', 'update reward', diff --git a/resources/views/livewire/studio/manage/stock-transfer/form.blade.php b/resources/views/livewire/studio/manage/stock-transfer/form.blade.php new file mode 100644 index 0000000..bf04e2b --- /dev/null +++ b/resources/views/livewire/studio/manage/stock-transfer/form.blade.php @@ -0,0 +1,213 @@ + +
+
+ {{ $pageTitle }} +
+
+ + Kembali + +
+
+ +
+ + Informasi Transfer Stok + + Proses transfer stok akan **mengurangi** stok di Outlet Asal dan **menambah** stok di Outlet Tujuan yang dipilih. + + + +
+
+ + + +
+ + Tanggal Transfer * + + + + + + Outlet Asal * + + @foreach ($sourceOutlets as $key => $value) + {{ $value }} + + @endforeach + + + + + + Outlet Tujuan * + + @foreach ($destinationOutlets as $key => $value) + {{ $value }} + + @endforeach + + + +
+
+ + @if ($form->source_outlet_id && $form->destination_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 Outlet Asal dan Outlet Tujuan terlebih dahulu untuk menampilkan daftar item. + + @endif +
+ +
+ +
+

Gambar

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

Keranjang Transfer Stok

+ +
+ + + Item + Qty + Aksi + + + + @forelse($stockTransferItems as $item) + + + +
{{ $item->transferable->name }}
+
+
+ + {{ formatCurrencyNumber($item->quantity, '') }} + + + + + + + + + + + + +
+ @empty + + + Keranjang Kosong... + + + @endforelse +
+
+
+
+
+
+ +
+ + Simpan + +
+
+ + +
+ Ubah {{ $modalTitle }} + + + +
+ + + Perbarui + +
+
+
+
diff --git a/resources/views/livewire/studio/manage/stock-transfer/index.blade.php b/resources/views/livewire/studio/manage/stock-transfer/index.blade.php new file mode 100644 index 0000000..7ca5544 --- /dev/null +++ b/resources/views/livewire/studio/manage/stock-transfer/index.blade.php @@ -0,0 +1,35 @@ + +
+
+ {{ $pageTitle }} +
+ @can('create stock transfer') +
+ + Tambah + +
+ @endcan +
+ +
+ + Informasi + + Menghapus data transfer stok otomatis mengembalikan stok ke outlet asal. + + + + +
+ + @include('components.modals.confirmation', [ + 'modalName' => 'delete-confirmation', + 'modalTitle' => 'Apakah Anda yakin?', + 'modalMessage' => 'Data transfer stok akan dihapus dari riwayat.', + 'buttonVariant' => 'primary', + 'buttonColor' => 'danger', + 'buttonText' => 'Ya, Hapus', + ]) +
diff --git a/routes/pages/studio.php b/routes/pages/studio.php index 5dce889..7dc748f 100644 --- a/routes/pages/studio.php +++ b/routes/pages/studio.php @@ -42,6 +42,8 @@ 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; +use App\Livewire\Studio\Manage\StockTransfer\Create as StockTransferCreate; +use App\Livewire\Studio\Manage\StockTransfer\Index as StockTransferIndex; use App\Livewire\Studio\Master\Category as CategoryComponent; use App\Livewire\Studio\Master\Formula as FormulaComponent; use App\Livewire\Studio\Master\Outlet\Create as OutletCreate; @@ -172,6 +174,11 @@ Route::get('/create', RestockCreate::class)->name('create')->middleware('can:create restock'); }); + Route::prefix('stock-transfers')->name('stock_transfer.')->group(function () { + Route::get('/', StockTransferIndex::class)->name('index')->middleware('can:view stock transfer'); + Route::get('/create', StockTransferCreate::class)->name('create')->middleware('can:create stock transfer'); + }); + 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');