feat(restock): fitur restock toko
This commit is contained in:
parent
65b1b4d6cf
commit
77d3d25cd9
77
app/Livewire/Datatable/Manage/RestocksTable.php
Normal file
77
app/Livewire/Datatable/Manage/RestocksTable.php
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Datatable\Manage;
|
||||||
|
|
||||||
|
use App\Models\Restock;
|
||||||
|
use App\Traits\Datatable\WithAppendColumn;
|
||||||
|
use App\Traits\Datatable\WithConfiguration;
|
||||||
|
use App\Traits\Datatable\WithPrependColumn;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Columns\ArrayColumn;
|
||||||
|
|
||||||
|
class RestocksTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
use WithAppendColumn, WithConfiguration, WithPrependColumn;
|
||||||
|
|
||||||
|
protected $model = Restock::class;
|
||||||
|
|
||||||
|
protected string $sortColumn = 'restock_date';
|
||||||
|
|
||||||
|
protected string $sortDirection = 'desc';
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make('Gudang', 'warehouse.name')->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) => "
|
||||||
|
<div class='text-[13px] leading-tight mb-1'>
|
||||||
|
<div class='font-bold text-gray-800 dark:text-white'>{$value['name']}</div>
|
||||||
|
<div class='text-gray-400 text-[12px]'>Qty: {$value['quantity']}</div>
|
||||||
|
</div>"
|
||||||
|
)
|
||||||
|
->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Livewire/Forms/Studio/Manage/RestockForm.php
Normal file
129
app/Livewire/Forms/Studio/Manage/RestockForm.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Forms\Studio\Manage;
|
||||||
|
|
||||||
|
use App\Models\Restock;
|
||||||
|
use App\Traits\Media\WithMediaHandler;
|
||||||
|
use App\Traits\Restock\WithRestockItems;
|
||||||
|
use App\Traits\Restock\WithRestockStock;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Form;
|
||||||
|
|
||||||
|
class RestockForm extends Form
|
||||||
|
{
|
||||||
|
use WithMediaHandler, WithRestockItems, WithRestockStock;
|
||||||
|
|
||||||
|
public ?Restock $restock = null;
|
||||||
|
|
||||||
|
public ?string $note = null;
|
||||||
|
|
||||||
|
public string $restock_date = '';
|
||||||
|
|
||||||
|
public string $warehouse_id = '';
|
||||||
|
|
||||||
|
public string $outlet_id = '';
|
||||||
|
|
||||||
|
public array $image = [];
|
||||||
|
|
||||||
|
public string $perfume_id = '';
|
||||||
|
|
||||||
|
public string $quantity_perfume = '';
|
||||||
|
|
||||||
|
public string $product_id = '';
|
||||||
|
|
||||||
|
public string $quantity_product = '';
|
||||||
|
|
||||||
|
public string $bottle_id = '';
|
||||||
|
|
||||||
|
public string $quantity_bottle = '';
|
||||||
|
|
||||||
|
public string $item_id = '';
|
||||||
|
|
||||||
|
public string $quantity_edit = '';
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'note' => ['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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/Livewire/Studio/Manage/Restock/Create.php
Normal file
127
app/Livewire/Studio/Manage/Restock/Create.php
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Studio\Manage\Restock;
|
||||||
|
|
||||||
|
use App\Livewire\Forms\Studio\Manage\RestockForm;
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
use App\Traits\Authorization\WithAuthorization;
|
||||||
|
use App\Traits\Components\WithConfirmation;
|
||||||
|
use App\Traits\Components\WithToast;
|
||||||
|
use App\Traits\Restock\WithRestockAddItem;
|
||||||
|
use App\Traits\Restock\WithRestockDeleteItem;
|
||||||
|
use App\Traits\Restock\WithRestockItems;
|
||||||
|
use App\Traits\Restock\WithRestockUpdateItem;
|
||||||
|
use App\Traits\Utilities\WithUpdatedData;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Title('Tambah Restock Toko')]
|
||||||
|
class Create extends Component
|
||||||
|
{
|
||||||
|
use WithAuthorization, WithConfirmation, WithRestockAddItem, WithRestockDeleteItem, WithRestockItems, WithRestockUpdateItem, WithToast, WithUpdatedData;
|
||||||
|
|
||||||
|
public RestockForm $form;
|
||||||
|
|
||||||
|
public array $warehouses = [];
|
||||||
|
|
||||||
|
public array $outlets = [];
|
||||||
|
|
||||||
|
public array $perfumes = [];
|
||||||
|
|
||||||
|
public array $products = [];
|
||||||
|
|
||||||
|
public array $bottles = [];
|
||||||
|
|
||||||
|
public $restockItems;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Livewire/Studio/Manage/Restock/Index.php
Normal file
49
app/Livewire/Studio/Manage/Restock/Index.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Studio\Manage\Restock;
|
||||||
|
|
||||||
|
use App\Models\Restock;
|
||||||
|
use App\Traits\Authorization\WithAuthorization;
|
||||||
|
use App\Traits\Components\WithCloseModal;
|
||||||
|
use App\Traits\Components\WithConfirmation;
|
||||||
|
use App\Traits\Components\WithToast;
|
||||||
|
use App\Traits\Notification\WithSubscribeNotification;
|
||||||
|
use App\Traits\Restock\WithRestockStock;
|
||||||
|
use App\Traits\Utilities\WithRequestNotification;
|
||||||
|
use Flux\Flux;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Title('Restock Toko')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
use WithAuthorization, WithCloseModal, WithConfirmation, WithRequestNotification, WithRestockStock, WithSubscribeNotification, WithToast;
|
||||||
|
|
||||||
|
public function delete(Restock $restock): void
|
||||||
|
{
|
||||||
|
$this->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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Models/Restock.php
Normal file
55
app/Models/Restock.php
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
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\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
use Veelasky\LaravelHashId\Eloquent\HashableId;
|
||||||
|
|
||||||
|
class Restock extends Model implements HasMedia
|
||||||
|
{
|
||||||
|
use HasFactory, HashableId, InteractsWithMedia, SoftDeletes;
|
||||||
|
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'restock_date' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Models/RestockItem.php
Normal file
46
app/Models/RestockItem.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
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 RestockItem extends Model
|
||||||
|
{
|
||||||
|
use HashableId, SoftDeletes;
|
||||||
|
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quantity' => '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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -182,7 +182,7 @@ public function boot(): void
|
|||||||
'can' => 'view purchase',
|
'can' => 'view purchase',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'label' => 'Restock Toko',
|
'label' => 'Restock Outlet',
|
||||||
'icon' => 'arrows-right-left',
|
'icon' => 'arrows-right-left',
|
||||||
'route' => 'studio.manage.restock.index',
|
'route' => 'studio.manage.restock.index',
|
||||||
'match' => 'studio.manage.restock.*',
|
'match' => 'studio.manage.restock.*',
|
||||||
|
|||||||
112
app/Traits/Restock/WithRestockAddItem.php
Normal file
112
app/Traits/Restock/WithRestockAddItem.php
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits\Restock;
|
||||||
|
|
||||||
|
use App\Models\Bottle;
|
||||||
|
use App\Models\Perfume;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\RestockItem;
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
|
||||||
|
trait WithRestockAddItem
|
||||||
|
{
|
||||||
|
public function addItem(string $type): void
|
||||||
|
{
|
||||||
|
$this->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 = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Traits/Restock/WithRestockDeleteItem.php
Normal file
18
app/Traits/Restock/WithRestockDeleteItem.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits\Restock;
|
||||||
|
|
||||||
|
use App\Models\RestockItem;
|
||||||
|
|
||||||
|
trait WithRestockDeleteItem
|
||||||
|
{
|
||||||
|
public function deleteItem(RestockItem $item): void
|
||||||
|
{
|
||||||
|
$this->canOrAbort('create restock');
|
||||||
|
|
||||||
|
$item->delete();
|
||||||
|
|
||||||
|
$this->restockItems = $this->loadRestockItems();
|
||||||
|
$this->toast('Item berhasil dihapus dari keranjang.');
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Traits/Restock/WithRestockItems.php
Normal file
19
app/Traits/Restock/WithRestockItems.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits\Restock;
|
||||||
|
|
||||||
|
use App\Models\RestockItem;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
trait WithRestockItems
|
||||||
|
{
|
||||||
|
public function loadRestockItems(): Collection
|
||||||
|
{
|
||||||
|
return $this->restockItems = RestockItem::query()
|
||||||
|
->currentUser()
|
||||||
|
->where('user_id', auth()->id())
|
||||||
|
->whereNull('restock_id')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/Traits/Restock/WithRestockStock.php
Normal file
169
app/Traits/Restock/WithRestockStock.php
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits\Restock;
|
||||||
|
|
||||||
|
use App\Models\Bottle;
|
||||||
|
use App\Models\Perfume;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\Restock;
|
||||||
|
use App\Services\StockActivityLogService;
|
||||||
|
|
||||||
|
trait WithRestockStock
|
||||||
|
{
|
||||||
|
public function transferStockFromWarehouseToOutlet($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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Traits/Restock/WithRestockUpdateItem.php
Normal file
84
app/Traits/Restock/WithRestockUpdateItem.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits\Restock;
|
||||||
|
|
||||||
|
use App\Models\Bottle;
|
||||||
|
use App\Models\Perfume;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\RestockItem;
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
|
||||||
|
trait WithRestockUpdateItem
|
||||||
|
{
|
||||||
|
public string $modalTitle = '';
|
||||||
|
|
||||||
|
public function updateItem(): void
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
database/factories/RestockFactory.php
Normal file
31
database/factories/RestockFactory.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Outlet;
|
||||||
|
use App\Models\Restock;
|
||||||
|
use App\Models\Warehouse;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Restock>
|
||||||
|
*/
|
||||||
|
class RestockFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Restock::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'warehouse_id' => Warehouse::factory(),
|
||||||
|
'outlet_id' => Outlet::factory(),
|
||||||
|
'restock_date' => fake()->dateTimeBetween('-1 month', 'now'),
|
||||||
|
'note' => fake()->optional()->sentence(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
38
database/factories/RestockItemFactory.php
Normal file
38
database/factories/RestockItemFactory.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Bottle;
|
||||||
|
use App\Models\Perfume;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\Restock;
|
||||||
|
use App\Models\RestockItem;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\RestockItem>
|
||||||
|
*/
|
||||||
|
class RestockItemFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = RestockItem::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?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('restocks', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?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('restock_items', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user