diff --git a/app/Livewire/Datatable/Manage/RestocksTable.php b/app/Livewire/Datatable/Manage/RestocksTable.php new file mode 100644 index 0000000..54e1533 --- /dev/null +++ b/app/Livewire/Datatable/Manage/RestocksTable.php @@ -0,0 +1,77 @@ +searchable(), + + Column::make('Outlet', 'outlet.name')->searchable(), + + Column::make('Tanggal Restock', 'restock_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->restockable?->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 restock')) { + $actions .= view('components.actions.table.delete', [ + 'id' => $row->hash, + ])->render(); + } + + return $actions; + }) + ->html() + ->hideIf(auth()->user()->cannot('update restock')), + ]; + } + + public function builder(): Builder + { + return Restock::select('restocks.id', 'warehouse_id', 'outlet_id', 'restock_date', 'note') + ->with(['warehouse', 'outlet', 'items', 'items.restockable']) + ->whereIn('outlet_id', auth()->user()->outlets->pluck('id')->toArray()); + } +} diff --git a/app/Livewire/Forms/Studio/Manage/RestockForm.php b/app/Livewire/Forms/Studio/Manage/RestockForm.php new file mode 100644 index 0000000..3015cb7 --- /dev/null +++ b/app/Livewire/Forms/Studio/Manage/RestockForm.php @@ -0,0 +1,129 @@ + ['nullable', 'string', 'max:100'], + 'restock_date' => ['required', 'date', 'before_or_equal:today'], + 'warehouse_id' => ['required', Rule::exists('warehouses', 'id')], + 'outlet_id' => ['required', Rule::exists('outlets', 'id')], + 'image' => ['nullable', 'array', 'max:1'], + ]; + } + + public function validationAttributes(): array + { + return [ + 'note' => 'catatan', + 'restock_date' => 'tanggal restock', + 'warehouse_id' => 'gudang', + 'outlet_id' => 'outlet', + 'image' => 'gambar', + ]; + } + + public function setRestock(Restock $restock): void + { + $this->restock = $restock; + + $this->note = $restock->note; + $this->restock_date = $restock->restock_date; + $this->warehouse_id = $restock->warehouse_id; + $this->outlet_id = $restock->outlet_id; + + $this->image = $this->mapMediaCollection($restock->getMedia('image')); + } + + public function store(): void + { + $this->validate(); + + $restockItems = $this->loadRestockItems(); + + DB::transaction(function () use ($restockItems) { + $data = $this->prepareSavedData(); + + $restock = Restock::create($data); + + $restock->load(['warehouse', 'outlet']); + + $this->uploadMedia($this->image, $restock, 'image'); + + foreach ($restockItems as $item) { + if ($item->quantity <= 0) { + continue; + } + + $item->update(['restock_id' => $restock->id]); + + $this->transferStockFromWarehouseToOutlet($restock->warehouse, $restock->outlet, $item); + } + }); + } + + public function update(): void + { + $this->validate(); + + DB::transaction(function () { + $data = $this->prepareSavedData(); + + $this->restock->update($data); + + $this->syncMedia($this->image, $this->restock, 'image'); + $this->uploadMedia($this->image, $this->restock, 'image'); + }); + } + + private function prepareSavedData(): array + { + return [ + 'note' => $this->note, + 'warehouse_id' => $this->warehouse_id, + 'outlet_id' => $this->outlet_id, + 'restock_date' => $this->restock_date, + ]; + } +} diff --git a/app/Livewire/Studio/Manage/Restock/Create.php b/app/Livewire/Studio/Manage/Restock/Create.php new file mode 100644 index 0000000..626c113 --- /dev/null +++ b/app/Livewire/Studio/Manage/Restock/Create.php @@ -0,0 +1,127 @@ +warehouses = Warehouse::orderBy('name')->pluck('name', 'id')->toArray(); + $this->outlets = auth()->user()->outlets->pluck('name', 'id')->toArray(); + + $this->restockItems = $this->loadRestockItems(); + } + + public function updated($property, $value): void + { + if ($property === 'form.warehouse_id' || $property === 'form.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->warehouse_id || ! $this->form->outlet_id) { + return; + } + + $warehouse = Warehouse::find($this->form->warehouse_id); + + if (! $warehouse) { + return; + } + + $this->perfumes = $warehouse->perfumes() + ->wherePivot('stock', '>', 0) + ->orderBy('name') + ->get() + ->pluck('name', 'id') + ->toArray(); + + $this->products = $warehouse->products() + ->wherePivot('stock', '>', 0) + ->orderBy('name') + ->get() + ->pluck('name', 'id') + ->toArray(); + + $this->bottles = $warehouse->bottles() + ->wherePivot('stock', '>', 0) + ->orderBy('name') + ->get() + ->pluck('name', 'id') + ->toArray(); + } + + public function save(): void + { + $this->canOrAbort('create restock'); + + if ($this->restockItems->isEmpty()) { + $this->toast('Keranjang restock tidak boleh kosong.', 'Gagal', 'danger'); + + return; + } + + try { + $this->form->store(); + } catch (\Exception $e) { + $this->toast($e->getMessage(), 'Gagal', 'danger'); + + return; + } + + $this->redirectRoute('studio.manage.restock.index', [ + 'notification' => 'Restock berhasil diproses.', + ], navigate: true); + } + + public function render(): View + { + return view('livewire.studio.manage.restock.form', [ + 'pageTitle' => 'Tambah Restock Toko', + ]); + } +} diff --git a/app/Livewire/Studio/Manage/Restock/Index.php b/app/Livewire/Studio/Manage/Restock/Index.php new file mode 100644 index 0000000..802e258 --- /dev/null +++ b/app/Livewire/Studio/Manage/Restock/Index.php @@ -0,0 +1,49 @@ +canOrAbort('delete restock'); + + DB::transaction(function () use ($restock) { + foreach ($restock->items as $item) { + $this->reverseStockTransfer($restock->warehouse, $restock->outlet, $item); + } + + $restock->delete(); + }); + + $this->dispatch('refreshDatatable'); + + $this->toast('Restock berhasil dihapus.'); + + Flux::modals()->close(); + } + + public function render(): View + { + return view('livewire.studio.manage.restock.index', [ + 'pageTitle' => 'Restock Toko', + ]); + } +} diff --git a/app/Models/Restock.php b/app/Models/Restock.php new file mode 100644 index 0000000..8ff7797 --- /dev/null +++ b/app/Models/Restock.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 warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function outlet(): BelongsTo + { + return $this->belongsTo(Outlet::class); + } + + public function items(): HasMany + { + return $this->hasMany(RestockItem::class); + } +} diff --git a/app/Models/RestockItem.php b/app/Models/RestockItem.php new file mode 100644 index 0000000..0eed277 --- /dev/null +++ b/app/Models/RestockItem.php @@ -0,0 +1,46 @@ + 'int', + ]; + } + + #[Scope] + public function currentUser(Builder $query): void + { + $query->where('user_id', auth()->id()); + } + + public function restock(): BelongsTo + { + return $this->belongsTo(Restock::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function restockable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php index 3a0c807..a7b2c5e 100644 --- a/app/Providers/ViewServiceProvider.php +++ b/app/Providers/ViewServiceProvider.php @@ -182,7 +182,7 @@ public function boot(): void 'can' => 'view purchase', ], [ - 'label' => 'Restock Toko', + 'label' => 'Restock Outlet', 'icon' => 'arrows-right-left', 'route' => 'studio.manage.restock.index', 'match' => 'studio.manage.restock.*', diff --git a/app/Traits/Restock/WithRestockAddItem.php b/app/Traits/Restock/WithRestockAddItem.php new file mode 100644 index 0000000..e59ff69 --- /dev/null +++ b/app/Traits/Restock/WithRestockAddItem.php @@ -0,0 +1,112 @@ +canOrAbort('create restock'); + + if (! $this->form->warehouse_id) { + $this->toast('Silakan pilih gudang terlebih dahulu.', 'Gagal', 'warning'); + + return; + } + + $warehouse = Warehouse::find($this->form->warehouse_id); + + if (! $warehouse) { + $this->toast('Gudang tidak ditemukan.', 'Gagal', 'danger'); + + return; + } + + $data = match ($type) { + 'parfum' => [ + 'restockable_type' => Perfume::class, + 'restockable_id' => $this->form->perfume_id, + 'quantity' => parseRupiahToInt($this->form->quantity_perfume), + ], + 'produk' => [ + 'restockable_type' => Product::class, + 'restockable_id' => $this->form->product_id, + 'quantity' => parseRupiahToInt($this->form->quantity_product), + ], + 'botol' => [ + 'restockable_type' => Bottle::class, + 'restockable_id' => $this->form->bottle_id, + 'quantity' => parseRupiahToInt($this->form->quantity_bottle), + ], + default => null, + }; + + if (! $data || ! $data['restockable_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) { + $warehouseStock = $warehouse->{$relationName}() + ->where($data['restockable_type']::make()->getTable().'.id', $data['restockable_id']) + ->first() + ?->pivot + ?->stock ?? 0; + + // Is item already in cart? + $existing = RestockItem::where([ + 'user_id' => auth()->id(), + 'restockable_id' => $data['restockable_id'], + 'restockable_type' => $data['restockable_type'], + ])->whereNull('restock_id')->first(); + + $currentCartQty = $existing ? $existing->quantity : 0; + $requestedQty = $data['quantity']; + + if (($currentCartQty + $requestedQty) > $warehouseStock) { + $this->toast("Stok tidak mencukupi. Stok di gudang: {$warehouseStock}. Sudah di keranjang: {$currentCartQty}.", 'Gagal', 'danger'); + + return; + } + + if ($existing) { + $existing->increment('quantity', $requestedQty); + } else { + RestockItem::create(array_merge($data, ['user_id' => auth()->id()])); + } + } + + $this->resetFormItem($type); + $this->restockItems = $this->loadRestockItems(); + $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/Restock/WithRestockDeleteItem.php b/app/Traits/Restock/WithRestockDeleteItem.php new file mode 100644 index 0000000..8f16c0b --- /dev/null +++ b/app/Traits/Restock/WithRestockDeleteItem.php @@ -0,0 +1,18 @@ +canOrAbort('create restock'); + + $item->delete(); + + $this->restockItems = $this->loadRestockItems(); + $this->toast('Item berhasil dihapus dari keranjang.'); + } +} diff --git a/app/Traits/Restock/WithRestockItems.php b/app/Traits/Restock/WithRestockItems.php new file mode 100644 index 0000000..01ba8b2 --- /dev/null +++ b/app/Traits/Restock/WithRestockItems.php @@ -0,0 +1,19 @@ +restockItems = RestockItem::query() + ->currentUser() + ->where('user_id', auth()->id()) + ->whereNull('restock_id') + ->latest() + ->get(); + } +} diff --git a/app/Traits/Restock/WithRestockStock.php b/app/Traits/Restock/WithRestockStock.php new file mode 100644 index 0000000..115cee3 --- /dev/null +++ b/app/Traits/Restock/WithRestockStock.php @@ -0,0 +1,169 @@ +quantity; + + switch ($item->restockable_type) { + case Perfume::class: + $relation = 'perfumes'; + break; + case Product::class: + $relation = 'products'; + break; + case Bottle::class: + $relation = 'bottles'; + break; + default: + return; + } + + // Decrease warehouse stock + $warehouseItem = $warehouse->{$relation}() + ->where("{$relation}.id", $item->restockable_id) + ->lockForUpdate() + ->withPivot('stock') + ->first(); + + $warehousePreviousStock = 0; + + if ($warehouseItem) { + $warehousePreviousStock = $warehouseItem->pivot->stock ?? 0; + } + + if ($warehousePreviousStock < $quantity) { + throw new \Exception("Stok {$item->restockable->name} di gudang tidak mencukupi. Tersisa {$warehousePreviousStock}, diminta {$quantity}."); + } + + $warehouseNewStock = $warehousePreviousStock - $quantity; + + $warehouse->{$relation}()->updateExistingPivot($item->restockable_id, [ + 'stock' => $warehouseNewStock, + ]); + + // Increase outlet stock + $outletItem = $outlet->{$relation}() + ->where("{$relation}.id", $item->restockable_id) + ->lockForUpdate() + ->withPivot('stock') + ->first(); + + if ($outletItem) { + $outletPreviousStock = $outletItem->pivot->stock ?? 0; + $outlet->{$relation}()->updateExistingPivot($item->restockable_id, [ + 'stock' => $outletPreviousStock + $quantity, + ]); + } else { + $outletPreviousStock = 0; + $outlet->{$relation}()->attach($item->restockable_id, [ + 'stock' => $quantity, + ]); + } + $outletNewStock = $outletPreviousStock + $quantity; + + $restock = $item->restock ?? Restock::find($item->restock_id); + + if ($restock) { + StockActivityLogService::distribution( + restock: $restock, + product: $item->restockable, + quantity: $quantity, + warehousePreviousStock: $warehousePreviousStock, + warehouseNewStock: $warehouseNewStock, + outletPreviousStock: $outletPreviousStock, + outletNewStock: $outletNewStock + ); + } + } + + public function reverseStockTransfer($warehouse, $outlet, $item): void + { + $quantity = $item->quantity; + + switch ($item->restockable_type) { + case Perfume::class: + $relation = 'perfumes'; + break; + case Product::class: + $relation = 'products'; + break; + case Bottle::class: + $relation = 'bottles'; + break; + default: + return; + } + + // Return items to Warehouse + $warehousePivot = $warehouse->{$relation}() + ->where("{$relation}.id", $item->restockable_id) + ->first(); + + $currentWarehouseStock = $warehousePivot?->pivot?->stock ?? 0; + $newWarehouseStock = $currentWarehouseStock + $quantity; + + if ($warehousePivot) { + $warehouse->{$relation}()->updateExistingPivot($item->restockable_id, [ + 'stock' => $newWarehouseStock, + ]); + } else { + $warehouse->{$relation}()->attach($item->restockable_id, [ + 'stock' => $quantity, + ]); + } + + $restock = $item->restock ?? Restock::find($item->restock_id); + + if ($restock) { + StockActivityLogService::logWarehouse( + logName: StockActivityLogService::LOG_DISTRIBUTION, + event: 'increase', + description: "Restock #{$restock->hash_id} Deleted: Returned {$quantity} stock to Warehouse.", + performedOn: $item->restockable, + warehouse: $warehouse, + quantityChange: $quantity, + previousStock: $currentWarehouseStock, + newStock: $newWarehouseStock, + extraProperties: ['restock_id' => $restock->id] + ); + } + + // Remove items from Outlet + $outletPivot = $outlet->{$relation}() + ->where("{$relation}.id", $item->restockable_id) + ->first(); + + $currentOutletStock = $outletPivot?->pivot?->stock ?? 0; + $newOutletStock = max(0, $currentOutletStock - $quantity); + + if ($outletPivot) { + $outlet->{$relation}()->updateExistingPivot($item->restockable_id, [ + 'stock' => $newOutletStock, + ]); + } + + if ($restock) { + StockActivityLogService::logOutlet( + logName: StockActivityLogService::LOG_DISTRIBUTION, + event: 'decrease', + description: "Restock #{$restock->hash_id} Deleted: Removed {$quantity} stock from Outlet.", + performedOn: $item->restockable, + outlet: $outlet, + quantityChange: -1 * abs($quantity), + previousStock: $currentOutletStock, + newStock: $newOutletStock, + extraProperties: ['restock_id' => $restock->id] + ); + } + } +} diff --git a/app/Traits/Restock/WithRestockUpdateItem.php b/app/Traits/Restock/WithRestockUpdateItem.php new file mode 100644 index 0000000..5ec0eb1 --- /dev/null +++ b/app/Traits/Restock/WithRestockUpdateItem.php @@ -0,0 +1,84 @@ +canOrAbort('create restock'); + + if (! $this->form->warehouse_id) { + $this->toast('Silakan pilih gudang terlebih dahulu.', 'Gagal', 'warning'); + + return; + } + + $warehouse = Warehouse::find($this->form->warehouse_id); + + if (! $warehouse) { + $this->toast('Gudang tidak ditemukan.', 'Gagal', 'danger'); + + return; + } + + $item = RestockItem::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->restockable_type) { + Perfume::class => 'perfumes', + Product::class => 'products', + Bottle::class => 'bottles', + default => null, + }; + + if ($relationName) { + $warehouseStock = $warehouse->{$relationName}() + ->where($item->restockable_type::make()->getTable().'.id', $item->restockable_id) + ->first() + ?->pivot + ?->stock ?? 0; + + if ($newQuantity > $warehouseStock) { + $this->toast("Stok tidak mencukupi (Update). Stok di gudang: {$warehouseStock}.", 'Gagal', 'danger'); + + return; + } + } + + $item->update([ + 'quantity' => $newQuantity, + ]); + } + + $this->restockItems = $this->loadRestockItems(); + $this->toast('Item berhasil diperbarui.'); + $this->dispatch('modal:close', 'form-modal'); + } + + #[On('modal:open')] + public function openModal($item): void + { + $item = RestockItem::find($item['item']); + $this->form->item_id = $item->id; + $this->form->quantity_edit = formatCurrencyNumber($item->quantity, ''); + $this->modalTitle = $item->restockable->name; + } +} diff --git a/database/factories/RestockFactory.php b/database/factories/RestockFactory.php new file mode 100644 index 0000000..41a3aa4 --- /dev/null +++ b/database/factories/RestockFactory.php @@ -0,0 +1,31 @@ + + */ +class RestockFactory extends Factory +{ + protected $model = Restock::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'warehouse_id' => Warehouse::factory(), + 'outlet_id' => Outlet::factory(), + 'restock_date' => fake()->dateTimeBetween('-1 month', 'now'), + 'note' => fake()->optional()->sentence(), + ]; + } +} diff --git a/database/factories/RestockItemFactory.php b/database/factories/RestockItemFactory.php new file mode 100644 index 0000000..5f334c4 --- /dev/null +++ b/database/factories/RestockItemFactory.php @@ -0,0 +1,38 @@ + + */ +class RestockItemFactory extends Factory +{ + protected $model = RestockItem::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $restockableType = fake()->randomElement([Perfume::class, Product::class, Bottle::class]); + $restockable = $restockableType::factory()->create(); + + return [ + 'restock_id' => Restock::factory(), + 'user_id' => User::factory(), + 'restockable_type' => $restockableType, + 'restockable_id' => $restockable->id, + 'quantity' => fake()->numberBetween(1, 100), + ]; + } +} diff --git a/database/migrations/2025_12_27_100004_create_restocks_table.php b/database/migrations/2025_12_27_100004_create_restocks_table.php new file mode 100644 index 0000000..220c1a9 --- /dev/null +++ b/database/migrations/2025_12_27_100004_create_restocks_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $table->foreignId('outlet_id')->constrained()->cascadeOnDelete(); + $table->date('restock_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('restocks'); + } +}; diff --git a/database/migrations/2025_12_27_100005_create_restock_items_table.php b/database/migrations/2025_12_27_100005_create_restock_items_table.php new file mode 100644 index 0000000..c762ce0 --- /dev/null +++ b/database/migrations/2025_12_27_100005_create_restock_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('restock_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->morphs('restockable'); + $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('restock_items'); + } +};