diff --git a/app/Console/Commands/GenerateStockOpname.php b/app/Console/Commands/GenerateStockOpname.php new file mode 100644 index 0000000..a42a87c --- /dev/null +++ b/app/Console/Commands/GenerateStockOpname.php @@ -0,0 +1,53 @@ +format('Y-m'); + + $outlets = Outlet::query()->operational()->get(); + + $user = User::role('Developer')->first(); + + DB::transaction(function () use ($outlets, $periodMonth, $user) { + foreach ($outlets as $outlet) { + $stockOpname = StockOpname::firstOrCreate([ + 'outlet_id' => $outlet->id, + 'period_month' => $periodMonth, + ]); + + $items = collect() + ->merge($outlet->perfumes) + ->merge($outlet->bottles) + ->merge($outlet->products); + + foreach ($items as $item) { + StockOpnameItem::firstOrCreate([ + 'stock_opname_id' => $stockOpname->id, + 'itemable_type' => get_class($item), + 'itemable_id' => $item->id, + ], [ + 'user_id' => $user->id, + 'qty_system' => $item->pivot?->stock, + ]); + } + } + }); + + $this->info("Stock opname untuk bulan {$periodMonth} berhasil digenerate."); + } +} diff --git a/app/Enums/StockOpnameStatus.php b/app/Enums/StockOpnameStatus.php new file mode 100644 index 0000000..e42681a --- /dev/null +++ b/app/Enums/StockOpnameStatus.php @@ -0,0 +1,36 @@ + 'Draft', + self::PROCESS => 'Proses', + self::PENDING_APPROVAL => 'Menunggu Persetujuan', + self::COMPLETED => 'Selesai', + }; + } + + public function color() + { + return match ($this) { + self::DRAFT => 'yellow', + self::PROCESS => 'teal', + self::PENDING_APPROVAL => 'orange', + self::COMPLETED => 'emerald', + }; + } +} diff --git a/app/Livewire/Datatable/Studio/Catalog/BottlesTable.php b/app/Livewire/Datatable/Studio/Catalog/BottlesTable.php index 80884ff..63f20d3 100644 --- a/app/Livewire/Datatable/Studio/Catalog/BottlesTable.php +++ b/app/Livewire/Datatable/Studio/Catalog/BottlesTable.php @@ -95,8 +95,8 @@ public function columns(): array $route = route('studio.catalog.bottle.audit', [$value['id'], $value['bottle_id']]); $canAudit = auth()->user()->can('audit bottle'); - $tag = $canAudit ? 'div' : 'a'; - $attrs = $canAudit ? '' : 'href="'.$route.'" wire:navigate'; + $tag = ! $canAudit ? 'div' : 'a'; + $attrs = ! $canAudit ? '' : 'href="'.$route.'" wire:navigate'; return Blade::render(" <{$tag} {$attrs} diff --git a/app/Livewire/Datatable/Studio/Catalog/PerfumesTable.php b/app/Livewire/Datatable/Studio/Catalog/PerfumesTable.php index 56a3301..9e11234 100644 --- a/app/Livewire/Datatable/Studio/Catalog/PerfumesTable.php +++ b/app/Livewire/Datatable/Studio/Catalog/PerfumesTable.php @@ -98,8 +98,8 @@ public function columns(): array $route = route('studio.catalog.perfume.audit', [$value['id'], $value['perfume_id']]); $canAudit = auth()->user()->can('audit perfume'); - $tag = $canAudit ? 'div' : 'a'; - $attrs = $canAudit ? '' : 'href="'.$route.'" wire:navigate'; + $tag = ! $canAudit ? 'div' : 'a'; + $attrs = ! $canAudit ? '' : 'href="'.$route.'" wire:navigate'; return Blade::render(" <{$tag} {$attrs} diff --git a/app/Livewire/Datatable/Studio/Catalog/ProductsTable.php b/app/Livewire/Datatable/Studio/Catalog/ProductsTable.php index fc49900..36b8674 100644 --- a/app/Livewire/Datatable/Studio/Catalog/ProductsTable.php +++ b/app/Livewire/Datatable/Studio/Catalog/ProductsTable.php @@ -87,8 +87,8 @@ public function columns(): array $route = route('studio.catalog.product.audit', [$value['id'], $value['product_id']]); $canAudit = auth()->user()->can('audit product'); - $tag = $canAudit ? 'div' : 'a'; - $attrs = $canAudit ? '' : 'href="'.$route.'" wire:navigate'; + $tag = ! $canAudit ? 'div' : 'a'; + $attrs = ! $canAudit ? '' : 'href="'.$route.'" wire:navigate'; return Blade::render(" <{$tag} {$attrs} diff --git a/app/Livewire/Datatable/Studio/Manage/StockOpnameTable.php b/app/Livewire/Datatable/Studio/Manage/StockOpnameTable.php new file mode 100644 index 0000000..f4049f7 --- /dev/null +++ b/app/Livewire/Datatable/Studio/Manage/StockOpnameTable.php @@ -0,0 +1,56 @@ +searchable(), + + Column::make('Periode Bulan', 'period_month') + ->format(fn ($row) => formatDate($row, 'F Y')) + ->searchable() + ->sortable(), + + Column::make('Aksi') + ->label(function ($row) { + $actions = ''; + + if (auth()->user()->can('show stock opname')) { + $actions .= view('components.datatables.new-page-action', [ + 'label' => 'Lihat', + 'route' => route('studio.manage.stock_opname.show', ['stockOpname', $row->hash]), + 'buttonColor' => 'blue', + 'buttonIcon' => 'eye', + ])->render(); + } + + return $actions; + }) + ->html() + ->hideIf(auth()->user()->cannot('show stock opname')), + ]; + } + + public function builder(): Builder + { + return StockOpname::select('stock_opname.id', 'period_month') + ->where('period_month', '!=', now()->format('Y-m')) + ->with('outlet'); + } +} diff --git a/app/Livewire/Forms/Studio/Manage/StockOpnameForm.php b/app/Livewire/Forms/Studio/Manage/StockOpnameForm.php new file mode 100644 index 0000000..1a8659c --- /dev/null +++ b/app/Livewire/Forms/Studio/Manage/StockOpnameForm.php @@ -0,0 +1,61 @@ + 'array', + 'outlet_stock.*' => ['nullable', new UnsignedInteger], + 'note' => 'nullable', + ]; + } + + public function validationAttributes() + { + return [ + 'outlet_stock' => 'stock outlet', + 'outlet_stock.*' => 'stock outlet', + 'note' => 'catatan', + ]; + } + + public function setStockOpname(StockOpname $stockOpname) + { + $stockOpname->load('items'); + + $this->stockOpname = $stockOpname; + + $this->note = $stockOpname->note; + } + + public function update() + { + $this->validate(); + + $this->stockOpname->update(['note' => $this->note]); + + foreach ($this->outlet_stock as $itemId => $qtyPhysical) { + $item = StockOpnameItem::find($itemId); + if (! $item) { + continue; + } + + $item->qty_physical = $qtyPhysical; + $item->save(); + } + } +} diff --git a/app/Livewire/Studio/Manage/StockOpname/Index.php b/app/Livewire/Studio/Manage/StockOpname/Index.php new file mode 100644 index 0000000..3130677 --- /dev/null +++ b/app/Livewire/Studio/Manage/StockOpname/Index.php @@ -0,0 +1,136 @@ +loadItems(); + } + + protected function loadItems() + { + $this->stockOpname = StockOpname::with(['outlet', 'items']) + ->where('period_month', now()->format('Y-m')) + ->get() + ->map(function ($stockOpname) { + $outlet = $stockOpname->outlet; + + $parfumCount = $outlet->perfumes()->count(); + $botolCount = $outlet->bottles()->count(); + $produkCount = $outlet->products()->count(); + + $total = $parfumCount + $botolCount + $produkCount; + + $filled = $stockOpname->items() + ->whereNotNull('qty_physical') + ->count(); + + $percent = $total > 0 ? round(($filled / $total) * 100) : 0; + + $stockOpname->total_item = $total; + $stockOpname->progress_filled = $filled; + $stockOpname->progress_percent = $percent; + $stockOpname->period_month = formatDate($stockOpname->period_month, 'F Y'); + + return $stockOpname; + }); + } + + public function requestApproval(StockOpname $stockOpname) + { + $stockOpname->update(['status' => StockOpnameStatus::PENDING_APPROVAL]); + + $this->toast('Permohonan persetujuan berhasil diajukan.', 'Berhasil'); + + $this->redirectRoute('studio.manage.stock_opname.index', navigate: true); + } + + public function approveApproval(StockOpname $stockOpname) + { + DB::transaction(function () use ($stockOpname) { + + $stockOpname->update(['status' => StockOpnameStatus::COMPLETED]); + + $outlet = $stockOpname->outlet; + + foreach ($stockOpname->items as $item) { + + if (is_null($item->qty_physical)) { + continue; + } + + $itemable = $item->itemable; + $morphType = class_basename($itemable); + + $relationMap = [ + 'Perfume' => 'perfumes', + 'Product' => 'products', + 'Bottle' => 'bottles', + ]; + + if (! isset($relationMap[$morphType])) { + continue; + } + + $relationName = $relationMap[$morphType]; + + $currentStock = $outlet->$relationName() + ->where($itemable->getTable().'.id', $itemable->id) + ->first()?->pivot?->stock ?? 0; + + $quantity = $item->qty_physical; + + $outlet->$relationName()->syncWithoutDetaching([ + $itemable->id => ['stock' => $quantity], + ]); + + activity('Stock Opname') + ->performedOn($itemable) + ->causedBy(auth()->user()) + ->withProperties([ + 'itemable_id' => $item->itemable_id, + 'itemable_type' => $item->itemable_type, + 'outlet_id' => $outlet->id, + 'quantity_change' => $quantity, + 'previous_stock' => $currentStock, + 'new_stock' => $currentStock + $quantity, + 'itemable_id' => $item->id ?? null, + ]) + ->event('Mengubah') + ->log(sprintf( + 'Stok %s di %s diperbarui dari %s menjadi %s', + $itemable->name, + $outlet->name, + $currentStock, + $quantity + )); + } + }); + + $this->toast('Stock opname berhasil disetujui dan stok outlet disesuaikan.', 'Berhasil'); + + $this->redirectRoute('studio.manage.stock_opname.index', navigate: true); + } + + public function render() + { + return view('livewire.studio.manage.stock-opname.index', [ + 'pageTitle' => 'Stock Opname', + ]); + } +} diff --git a/app/Livewire/Studio/Manage/StockOpname/Manage.php b/app/Livewire/Studio/Manage/StockOpname/Manage.php new file mode 100644 index 0000000..dbd6c66 --- /dev/null +++ b/app/Livewire/Studio/Manage/StockOpname/Manage.php @@ -0,0 +1,66 @@ +status !== StockOpnameStatus::PROCESS) { + abort(403); + } + + $this->items = StockOpnameItem::with('itemable') + ->where('stock_opname_id', $stockOpname->id) + ->get(); + + $outletStock = []; + foreach ($this->items as $item) { + $outletStock[$item->id] = $item->qty_physical; + } + + $this->form->outlet_stock = $outletStock; + + $this->form->setStockOpname($stockOpname); + } + + public function updated(string $propertyName, $value) + { + $this->canOrAbort('manage stock opname'); + + $this->form->update(); + + if (str_starts_with($propertyName, 'form.outlet_stock.')) { + $itemId = explode('.', $propertyName)[2]; + + $item = $this->items->firstWhere('id', $itemId); + if ($item) { + $item->qty_physical = $value; + $item->difference = $value - $item->qty_system; + } + } + } + + public function render() + { + return view('livewire.studio.manage.stock-opname.manage', [ + 'pageTitle' => 'Kelola Stock Opname', + ]); + } +} diff --git a/app/Livewire/Studio/Manage/StockOpname/Show.php b/app/Livewire/Studio/Manage/StockOpname/Show.php new file mode 100644 index 0000000..7f2cdbd --- /dev/null +++ b/app/Livewire/Studio/Manage/StockOpname/Show.php @@ -0,0 +1,35 @@ +items = StockOpnameItem::with('itemable') + ->where('stock_opname_id', $stockOpname->id) + ->get(); + + $this->stockOpname = $stockOpname; + } + + public function render() + { + return view('livewire.studio.manage.stock-opname.show', [ + 'pageTitle' => 'Lihat Stock Opname', + ]); + } +} diff --git a/app/Models/Outlet.php b/app/Models/Outlet.php index a948972..3f8456c 100644 --- a/app/Models/Outlet.php +++ b/app/Models/Outlet.php @@ -4,6 +4,8 @@ use App\Enums\OutletStatus; use Dyrynda\Database\Support\CascadeSoftDeletes; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -37,6 +39,12 @@ public function getSlugOptions(): SlugOptions ->saveSlugsTo('slug'); } + #[Scope] + public function operational(Builder $query): void + { + $query->where('status', OutletStatus::OPERATIONAL->value); + } + public function openingHours(): HasMany { return $this->hasMany(OpeningHour::class); @@ -54,17 +62,17 @@ public function vouchers(): BelongsToMany public function perfumes(): BelongsToMany { - return $this->belongsToMany(Perfume::class); + return $this->belongsToMany(Perfume::class)->withPivot('stock'); } public function products(): BelongsToMany { - return $this->belongsToMany(Product::class); + return $this->belongsToMany(Product::class)->withPivot('stock'); } public function bottles(): BelongsToMany { - return $this->belongsToMany(Bottle::class); + return $this->belongsToMany(Bottle::class)->withPivot('stock'); } public function expenses(): HasMany @@ -86,4 +94,9 @@ public function orders(): HasMany { return $this->hasMany(Order::class); } + + public function stockOpname(): HasMany + { + return $this->hasMany(StockOpname::class); + } } diff --git a/app/Models/StockOpname.php b/app/Models/StockOpname.php new file mode 100644 index 0000000..9dab412 --- /dev/null +++ b/app/Models/StockOpname.php @@ -0,0 +1,38 @@ + StockOpnameStatus::class, + ]; + } + + public function outlet(): BelongsTo + { + return $this->belongsTo(Outlet::class); + } + + public function items(): HasMany + { + return $this->hasMany(StockOpnameItem::class); + } +} diff --git a/app/Models/StockOpnameItem.php b/app/Models/StockOpnameItem.php new file mode 100644 index 0000000..1fbe6e7 --- /dev/null +++ b/app/Models/StockOpnameItem.php @@ -0,0 +1,40 @@ + 'int', + 'qty_physical' => 'int', + 'differences' => 'int', + ]; + } + + public function stockOpname(): BelongsTo + { + return $this->belongsTo(StockOpname::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function itemable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Providers/ViewServiceProvider.php b/app/Providers/ViewServiceProvider.php index 343ba4c..9b1a7df 100644 --- a/app/Providers/ViewServiceProvider.php +++ b/app/Providers/ViewServiceProvider.php @@ -4,6 +4,7 @@ use App\Enums\PriceRequestStatus; use App\Models\PriceRequest; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; @@ -11,6 +12,10 @@ class ViewServiceProvider extends ServiceProvider { public function boot(): void { + if (! Schema::hasTable('price_requests')) { + return; + } + $priceRequestCount = PriceRequest::where('status', PriceRequestStatus::PENDING->value)->count(); View::composer('*', function ($view) use ($priceRequestCount) { @@ -162,6 +167,13 @@ public function boot(): void 'match' => 'studio.manage.purchase.*', 'can' => 'view purchase', ], + [ + 'label' => 'Stock Opname', + 'icon' => 'clipboard-document-check', + 'route' => 'studio.manage.stock_opname.index', + 'match' => 'studio.manage.stock_opname.*', + 'can' => 'view stock opname', + ], [ 'label' => 'Artikel', 'icon' => 'newspaper', diff --git a/database/migrations/2025_11_23_193832_create_stock_opname_table.php b/database/migrations/2025_11_23_193832_create_stock_opname_table.php new file mode 100644 index 0000000..d6a76d0 --- /dev/null +++ b/database/migrations/2025_11_23_193832_create_stock_opname_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('outlet_id')->constrained()->cascadeOnDelete(); + $table->string('period_month', 7); + $table->enum('status', [StockOpnameStatus::values()])->default(StockOpnameStatus::DRAFT)->comment(StockOpnameStatus::comment()); + $table->text('note')->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stock_opname'); + } +}; diff --git a/database/migrations/2025_11_23_194551_create_stock_opname_items_table.php b/database/migrations/2025_11_23_194551_create_stock_opname_items_table.php new file mode 100644 index 0000000..5993b90 --- /dev/null +++ b/database/migrations/2025_11_23_194551_create_stock_opname_items_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('stock_opname_id')->constrained('stock_opname')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->morphs('itemable'); + $table->unsignedInteger('qty_system'); + $table->unsignedInteger('qty_physical')->nullable(); + $table->integer('difference')->storedAs('CAST(qty_physical AS SIGNED) - CAST(qty_system AS SIGNED)'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stock_opname_items'); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 4df2681..229b6b0 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -113,6 +113,11 @@ public function run(): void 'create purchase', 'delete purchase', + 'view stock opname', + 'manage stock opname', + 'show stock opname', + 'approve stock opname', + 'view article', 'create article', 'update article', @@ -242,6 +247,8 @@ public function run(): void 'create purchase', 'delete purchase', + 'approve stock opname', + 'view faq', 'create faq', 'update faq', @@ -346,6 +353,10 @@ public function run(): void 'create purchase', 'delete purchase', + 'view stock opname', + 'manage stock opname', + 'show stock opname', + 'view article', 'create article', 'update article', diff --git a/resources/views/components/datatables/new-page-action.blade.php b/resources/views/components/datatables/new-page-action.blade.php new file mode 100644 index 0000000..b43ab51 --- /dev/null +++ b/resources/views/components/datatables/new-page-action.blade.php @@ -0,0 +1,5 @@ + + + + diff --git a/resources/views/livewire/studio/manage/stock-opname/index.blade.php b/resources/views/livewire/studio/manage/stock-opname/index.blade.php new file mode 100644 index 0000000..f68e39f --- /dev/null +++ b/resources/views/livewire/studio/manage/stock-opname/index.blade.php @@ -0,0 +1,106 @@ + +
+
+ {{ $pageTitle }} +
+
+ +
+
+ @foreach ($stockOpname as $item) + +
+
+ {{ $item->outlet->name }} + {{ $item->period_month }} +
+
+ + {{ $item->status->label() }} + +
+
+ +
+
+ Total Item +
{{ $item->total_item }}
+
+
+ Progress +
+ {{ $item->progress_filled }} / {{ $item->total_item }} | + ({{ $item->progress_percent }}%) +
+
+ @if ($item['note']) +
+ Catatan +
+ {{ $item['note'] }} +
+
+ @endif +
+ + + +
+ + + + + + @if ($item->status === \App\Enums\StockOpnameStatus::PROCESS) + + + + + + @if ($item->progress_percent == 100) + + + + + + + @endif + @endif + + @if ($item->status === \App\Enums\StockOpnameStatus::PENDING_APPROVAL && auth()->user()->can('approve stock opname')) + + + + + + + @endif +
+
+ @endforeach +
+ + +
+ + @include('components.confirmation') +
diff --git a/resources/views/livewire/studio/manage/stock-opname/manage.blade.php b/resources/views/livewire/studio/manage/stock-opname/manage.blade.php new file mode 100644 index 0000000..2dd792e --- /dev/null +++ b/resources/views/livewire/studio/manage/stock-opname/manage.blade.php @@ -0,0 +1,48 @@ + +
+
+ {{ $pageTitle }} +
+
+ + Kembali + +
+
+ +
+
+ + + + Item + Stok Sistem + Stok Outlet + Selisih + + + + @foreach ($items as $item) + + {{ $item->itemable?->name }} + {{ $item->qty_system }} + + + + + {{ $item->difference }} + + + @endforeach + + + + + + + +
+
+
diff --git a/resources/views/livewire/studio/manage/stock-opname/show.blade.php b/resources/views/livewire/studio/manage/stock-opname/show.blade.php new file mode 100644 index 0000000..eef04a4 --- /dev/null +++ b/resources/views/livewire/studio/manage/stock-opname/show.blade.php @@ -0,0 +1,54 @@ + +
+
+ {{ $pageTitle }} +
+
+ + Kembali + +
+
+ +
+
+ + + + Item + Stok Sistem + Stok Outlet + Selisih + + + + @foreach ($items as $item) + + {{ $item->itemable?->name }} + {{ $item->qty_system }} + {{ $item->qty_physical }} + + {{ $item->difference }} + + + @endforeach + + + + + @if ($stockOpname['note']) + +
+ Catatan +
+ +
+ + {{ $stockOpname['note'] }} + +
+
+ @endif +
+
+
diff --git a/routes/pages/studio.php b/routes/pages/studio.php index ffc7738..6ccb144 100644 --- a/routes/pages/studio.php +++ b/routes/pages/studio.php @@ -36,6 +36,9 @@ 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\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\Master\Formula as FormulaComponent; use App\Livewire\Studio\Master\Outlet\Create as OutletCreate; use App\Livewire\Studio\Master\Outlet\Edit as OutletEdit; @@ -162,6 +165,12 @@ Route::delete('/{purchase}/delete', PurchaseCreate::class)->name('delete')->middleware('can:delete purchase'); }); + 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'); + Route::get('/{stockOpname}/manage', StockOpnameManage::class)->name('manage')->middleware('can:manage stock opname'); + }); + Route::prefix('articles')->name('article.')->group(function () { Route::get('/', ArticleIndex::class)->name('index')->middleware('can:view article'); Route::get('/create', ArticleCreate::class)->name('create')->middleware('can:create article');