feat(stock opname): implementasi module/fitur stock opname
This commit is contained in:
parent
87ae5538b3
commit
9b25af9ef0
53
app/Console/Commands/GenerateStockOpname.php
Normal file
53
app/Console/Commands/GenerateStockOpname.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Outlet;
|
||||
use App\Models\StockOpname;
|
||||
use App\Models\StockOpnameItem;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GenerateStockOpname extends Command
|
||||
{
|
||||
protected $signature = 'stock-opname:generate';
|
||||
|
||||
protected $description = 'Automatically generate a stock opname document';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$periodMonth = now()->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.");
|
||||
}
|
||||
}
|
||||
36
app/Enums/StockOpnameStatus.php
Normal file
36
app/Enums/StockOpnameStatus.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\WithCommentEnum;
|
||||
use App\Traits\WithValueEnum;
|
||||
|
||||
enum StockOpnameStatus: int
|
||||
{
|
||||
use WithCommentEnum, WithValueEnum;
|
||||
|
||||
case DRAFT = 1;
|
||||
case PROCESS = 2;
|
||||
case PENDING_APPROVAL = 3;
|
||||
case COMPLETED = 4;
|
||||
|
||||
public function label()
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => '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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
56
app/Livewire/Datatable/Studio/Manage/StockOpnameTable.php
Normal file
56
app/Livewire/Datatable/Studio/Manage/StockOpnameTable.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Datatable\Studio\Manage;
|
||||
|
||||
use App\Models\StockOpname;
|
||||
use App\Traits\Datatable\WithAppendColumn;
|
||||
use App\Traits\Datatable\WithConfiguration;
|
||||
use App\Traits\Datatable\WithPrependColumn;
|
||||
use App\Traits\WithMediaHandler;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||
|
||||
class StockOpnameTable extends DataTableComponent
|
||||
{
|
||||
use WithAppendColumn, WithConfiguration, WithMediaHandler, WithPrependColumn;
|
||||
|
||||
protected $model = StockOpname::class;
|
||||
|
||||
public function columns(): array
|
||||
{
|
||||
return [
|
||||
Column::make('Outlet', 'outlet.name')->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');
|
||||
}
|
||||
}
|
||||
61
app/Livewire/Forms/Studio/Manage/StockOpnameForm.php
Normal file
61
app/Livewire/Forms/Studio/Manage/StockOpnameForm.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Forms\Studio\Manage;
|
||||
|
||||
use App\Models\StockOpname;
|
||||
use App\Models\StockOpnameItem;
|
||||
use App\Rules\UnsignedInteger;
|
||||
use Livewire\Form;
|
||||
|
||||
class StockOpnameForm extends Form
|
||||
{
|
||||
public StockOpname $stockOpname;
|
||||
|
||||
public array $outlet_stock = [];
|
||||
|
||||
public ?string $note = null;
|
||||
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'outlet_stock' => '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();
|
||||
}
|
||||
}
|
||||
}
|
||||
136
app/Livewire/Studio/Manage/StockOpname/Index.php
Normal file
136
app/Livewire/Studio/Manage/StockOpname/Index.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Studio\Manage\StockOpname;
|
||||
|
||||
use App\Enums\StockOpnameStatus;
|
||||
use App\Models\StockOpname;
|
||||
use App\Traits\WithConfirmation;
|
||||
use App\Traits\WithToast;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Stock Opname')]
|
||||
class Index extends Component
|
||||
{
|
||||
use WithConfirmation, WithToast;
|
||||
|
||||
public $stockOpname;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Livewire/Studio/Manage/StockOpname/Manage.php
Normal file
66
app/Livewire/Studio/Manage/StockOpname/Manage.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Studio\Manage\StockOpname;
|
||||
|
||||
use App\Enums\StockOpnameStatus;
|
||||
use App\Livewire\Forms\Studio\Manage\StockOpnameForm;
|
||||
use App\Models\StockOpname;
|
||||
use App\Models\StockOpnameItem;
|
||||
use App\Traits\WithAuthorization;
|
||||
use App\Traits\WithConfirmation;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Kelola Stock Opname')]
|
||||
class Manage extends Component
|
||||
{
|
||||
use WithAuthorization, WithConfirmation;
|
||||
|
||||
public StockOpnameForm $form;
|
||||
|
||||
public $items;
|
||||
|
||||
public function mount(StockOpname $stockOpname)
|
||||
{
|
||||
if ($stockOpname->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
35
app/Livewire/Studio/Manage/StockOpname/Show.php
Normal file
35
app/Livewire/Studio/Manage/StockOpname/Show.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Studio\Manage\StockOpname;
|
||||
|
||||
use App\Models\StockOpname;
|
||||
use App\Models\StockOpnameItem;
|
||||
use App\Traits\WithAuthorization;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Lihat Stock Opname')]
|
||||
class Show extends Component
|
||||
{
|
||||
use WithAuthorization;
|
||||
|
||||
public $items;
|
||||
|
||||
public StockOpname $stockOpname;
|
||||
|
||||
public function mount(StockOpname $stockOpname)
|
||||
{
|
||||
$this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
38
app/Models/StockOpname.php
Normal file
38
app/Models/StockOpname.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\StockOpnameStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Veelasky\LaravelHashId\Eloquent\HashableId;
|
||||
|
||||
class StockOpname extends Model
|
||||
{
|
||||
use HashableId, SoftDeletes;
|
||||
|
||||
protected $table = 'stock_opname';
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $cascadeDeletes = ['items'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => StockOpnameStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function outlet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Outlet::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockOpnameItem::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/StockOpnameItem.php
Normal file
40
app/Models/StockOpnameItem.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Veelasky\LaravelHashId\Eloquent\HashableId;
|
||||
|
||||
class StockOpnameItem extends Model
|
||||
{
|
||||
use HashableId, SoftDeletes;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'qty_system' => '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();
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\StockOpnameStatus;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('stock_opname', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('stock_opname_items', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
<flux:tooltip content="{{ $label }}">
|
||||
<flux:button href="{{ $route }}" variant="primary" color="{{ $buttonColor }}" icon="{{ $buttonIcon }}"
|
||||
size="sm" wire:navigate>
|
||||
</flux:button>
|
||||
</flux:tooltip>
|
||||
@ -0,0 +1,106 @@
|
||||
<flux:main>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mb-4 text-sm items-start" wire:ignore>
|
||||
@foreach ($stockOpname as $item)
|
||||
<flux:card class="h-auto" wire:key="stock-opname-{{ $item->id }}">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div>
|
||||
<flux:heading>{{ $item->outlet->name }}</flux:heading>
|
||||
<flux:text>{{ $item->period_month }}</flux:text>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<flux:badge color="{{ $item->status->color() }}">
|
||||
{{ $item->status->label() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-3 text-sm">
|
||||
<div>
|
||||
<flux:text>Total Item</flux:text>
|
||||
<div class="font-medium">{{ $item->total_item }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<flux:text>Progress</flux:text>
|
||||
<div class="font-medium">
|
||||
{{ $item->progress_filled }} / {{ $item->total_item }} |
|
||||
({{ $item->progress_percent }}%)
|
||||
</div>
|
||||
</div>
|
||||
@if ($item['note'])
|
||||
<div>
|
||||
<flux:text>Catatan</flux:text>
|
||||
<div class="font-medium">
|
||||
{{ $item['note'] }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<flux:separator class="my-2" />
|
||||
|
||||
<div class="flex items-center mt-2 gap-2">
|
||||
<flux:tooltip content="Lihat">
|
||||
<flux:button variant="primary" color="blue" size="sm" icon="eye"
|
||||
href="{{ route('studio.manage.stock_opname.show', ['stockOpname' => $item->hash]) }}"
|
||||
wire:navigate>
|
||||
</flux:button>
|
||||
</flux:tooltip>
|
||||
|
||||
@if ($item->status === \App\Enums\StockOpnameStatus::PROCESS)
|
||||
<flux:tooltip content="Kelola">
|
||||
<flux:button variant="primary" color="yellow" size="sm" icon="document-text"
|
||||
href="{{ route('studio.manage.stock_opname.manage', ['stockOpname' => $item->hash]) }}"
|
||||
wire:navigate>
|
||||
</flux:button>
|
||||
</flux:tooltip>
|
||||
|
||||
@if ($item->progress_percent == 100)
|
||||
<flux:modal.trigger name="confirmation-modal">
|
||||
<flux:tooltip content="Ajukan Persetujuan">
|
||||
<flux:button variant="primary" color="emerald" icon="document-check"
|
||||
size="sm"
|
||||
wire:click="$dispatch('fn:confirmAction', {
|
||||
id : '{{ $item->hash }}',
|
||||
message: 'Pastikan proses stock opname sudah tepat dan sesuai.',
|
||||
confirmButtonText: 'Ajukan',
|
||||
target: 'requestApproval',
|
||||
confirmButtonColor : 'emerald'
|
||||
})">
|
||||
</flux:button>
|
||||
</flux:tooltip>
|
||||
</flux:modal.trigger>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($item->status === \App\Enums\StockOpnameStatus::PENDING_APPROVAL && auth()->user()->can('approve stock opname'))
|
||||
<flux:modal.trigger name="confirmation-modal">
|
||||
<flux:tooltip content="Setujui">
|
||||
<flux:button variant="primary" color="emerald" icon="check-circle" size="sm"
|
||||
wire:click="$dispatch('fn:confirmAction', {
|
||||
id : '{{ $item->hash }}',
|
||||
message: 'Proses ini akan menyesuaikan kembali stok barang dengan hasil inputan dan proes tidak dapat dibatalkan, silakan periksa kembali dengan benar.',
|
||||
confirmButtonText: 'Ajukan',
|
||||
target: 'approveApproval',
|
||||
confirmButtonColor : 'emerald'
|
||||
})">
|
||||
</flux:button>
|
||||
</flux:tooltip>
|
||||
</flux:modal.trigger>
|
||||
@endif
|
||||
</div>
|
||||
</flux:card>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<livewire:datatable.studio.manage.stock-opname-table />
|
||||
</div>
|
||||
|
||||
@include('components.confirmation')
|
||||
</flux:main>
|
||||
@ -0,0 +1,48 @@
|
||||
<flux:main>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
|
||||
</div>
|
||||
<div>
|
||||
<flux:button href="{{ route('studio.manage.stock_opname.index') }}" wire:navigate.hover class="text-sm">
|
||||
Kembali
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="space-y-4">
|
||||
<flux:card>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>Item</flux:table.column>
|
||||
<flux:table.column>Stok Sistem</flux:table.column>
|
||||
<flux:table.column>Stok Outlet</flux:table.column>
|
||||
<flux:table.column>Selisih</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@foreach ($items as $item)
|
||||
<flux:table.row wire:key="{{ $item->id }}">
|
||||
<flux:table.cell>{{ $item->itemable?->name }}</flux:table.cell>
|
||||
<flux:table.cell>{{ $item->qty_system }}</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:input wire:model.blur="form.outlet_stock.{{ $item->id }}"
|
||||
x-mask:dynamic="$money($input, ',')" autocomplete="off" />
|
||||
</flux:table.cell>
|
||||
<flux:table.cell variant="strong">
|
||||
{{ $item->difference }}
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:textarea label="Catatan" placeholder="Blue emotion tumpah 10ml" wire:model.blur="form.note"
|
||||
autocomplete="off" rows="2" />
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
</flux:main>
|
||||
@ -0,0 +1,54 @@
|
||||
<flux:main>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
|
||||
</div>
|
||||
<div>
|
||||
<flux:button href="{{ route('studio.manage.stock_opname.index') }}" wire:navigate.hover class="text-sm">
|
||||
Kembali
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="space-y-4">
|
||||
<flux:card>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>Item</flux:table.column>
|
||||
<flux:table.column>Stok Sistem</flux:table.column>
|
||||
<flux:table.column>Stok Outlet</flux:table.column>
|
||||
<flux:table.column>Selisih</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@foreach ($items as $item)
|
||||
<flux:table.row wire:key="{{ $item->id }}">
|
||||
<flux:table.cell>{{ $item->itemable?->name }}</flux:table.cell>
|
||||
<flux:table.cell>{{ $item->qty_system }}</flux:table.cell>
|
||||
<flux:table.cell>{{ $item->qty_physical }}</flux:table.cell>
|
||||
<flux:table.cell variant="strong">
|
||||
{{ $item->difference }}
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</flux:card>
|
||||
|
||||
@if ($stockOpname['note'])
|
||||
<flux:card>
|
||||
<div>
|
||||
<flux:heading size="lg">Catatan</flux:heading>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<flux:text>
|
||||
{{ $stockOpname['note'] }}
|
||||
</flux:text>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</flux:main>
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user