feat: add stock transfer management functionality
- Implemented stock transfer creation and management with Livewire components. - Created StockTransfer and StockTransferItem models with necessary relationships. - Added migration files for stock_transfers and stock_transfer_items tables. - Developed traits for handling stock transfer items (add, delete, update). - Integrated stock transfer logging in StockActivityLogService. - Updated ViewServiceProvider to include stock transfer permissions and routes. - Created frontend views for stock transfer form and index with appropriate UI components. - Added role permissions for stock transfer actions in RolePermissionSeeder.
This commit is contained in:
parent
156eca433c
commit
dffc9f9e72
78
app/Livewire/Datatable/Manage/StockTransfersTable.php
Normal file
78
app/Livewire/Datatable/Manage/StockTransfersTable.php
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Datatable\Manage;
|
||||||
|
|
||||||
|
use App\Models\StockTransfer;
|
||||||
|
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 StockTransfersTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
use WithAppendColumn, WithConfiguration, WithPrependColumn;
|
||||||
|
|
||||||
|
protected $model = StockTransfer::class;
|
||||||
|
|
||||||
|
protected string $sortColumn = 'transfer_date';
|
||||||
|
|
||||||
|
protected string $sortDirection = 'desc';
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make('Outlet Asal', 'sourceOutlet.name')->searchable(),
|
||||||
|
|
||||||
|
Column::make('Outlet Tujuan', 'destinationOutlet.name')->searchable(),
|
||||||
|
|
||||||
|
Column::make('Tanggal Transfer', 'transfer_date')
|
||||||
|
->format(fn ($value) => formatDateLocalized($value))
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
Column::make('Catatan', 'note')->searchable(),
|
||||||
|
|
||||||
|
ArrayColumn::make('Item')
|
||||||
|
->data(
|
||||||
|
fn ($value, $row) => $row->items->map(fn ($item) => [
|
||||||
|
'name' => $item->transferable?->name,
|
||||||
|
'quantity' => formatCurrencyNumber($item->quantity, ''),
|
||||||
|
])->toArray()
|
||||||
|
)
|
||||||
|
->outputFormat(
|
||||||
|
fn ($index, $value) => "
|
||||||
|
<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 stock transfer')) {
|
||||||
|
$actions .= view('components.actions.table.delete', [
|
||||||
|
'id' => $row->hash,
|
||||||
|
])->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
})
|
||||||
|
->html()
|
||||||
|
->hideIf(auth()->user()->cannot('update stock transfer')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return StockTransfer::select('stock_transfers.id', 'source_outlet_id', 'destination_outlet_id', 'transfer_date', 'note')
|
||||||
|
->with(['sourceOutlet', 'destinationOutlet', 'items', 'items.transferable'])
|
||||||
|
->whereIn('source_outlet_id', auth()->user()->outlets->pluck('id')->toArray())
|
||||||
|
->orWhereIn('destination_outlet_id', auth()->user()->outlets->pluck('id')->toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Livewire/Forms/Studio/Manage/StockTransferForm.php
Normal file
129
app/Livewire/Forms/Studio/Manage/StockTransferForm.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Forms\Studio\Manage;
|
||||||
|
|
||||||
|
use App\Models\StockTransfer;
|
||||||
|
use App\Traits\Media\WithMediaHandler;
|
||||||
|
use App\Traits\StockTransfer\WithStockTransferItems;
|
||||||
|
use App\Traits\StockTransfer\WithStockTransferStock;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Form;
|
||||||
|
|
||||||
|
class StockTransferForm extends Form
|
||||||
|
{
|
||||||
|
use WithMediaHandler, WithStockTransferItems, WithStockTransferStock;
|
||||||
|
|
||||||
|
public ?StockTransfer $stockTransfer = null;
|
||||||
|
|
||||||
|
public ?string $note = null;
|
||||||
|
|
||||||
|
public string $transfer_date = '';
|
||||||
|
|
||||||
|
public string $source_outlet_id = '';
|
||||||
|
|
||||||
|
public string $destination_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'],
|
||||||
|
'transfer_date' => ['required', 'date', 'before_or_equal:today'],
|
||||||
|
'source_outlet_id' => ['required', Rule::exists('outlets', 'id')],
|
||||||
|
'destination_outlet_id' => ['required', Rule::exists('outlets', 'id'), 'different:source_outlet_id'],
|
||||||
|
'image' => ['nullable', 'array', 'max:1'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validationAttributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'note' => 'catatan',
|
||||||
|
'transfer_date' => 'tanggal transfer',
|
||||||
|
'source_outlet_id' => 'outlet asal',
|
||||||
|
'destination_outlet_id' => 'outlet tujuan',
|
||||||
|
'image' => 'gambar',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStockTransfer(StockTransfer $stockTransfer): void
|
||||||
|
{
|
||||||
|
$this->stockTransfer = $stockTransfer;
|
||||||
|
|
||||||
|
$this->note = $stockTransfer->note;
|
||||||
|
$this->transfer_date = $stockTransfer->transfer_date;
|
||||||
|
$this->source_outlet_id = $stockTransfer->source_outlet_id;
|
||||||
|
$this->destination_outlet_id = $stockTransfer->destination_outlet_id;
|
||||||
|
|
||||||
|
$this->image = $this->mapMediaCollection($stockTransfer->getMedia('image'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$stockTransferItems = $this->loadStockTransferItems();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($stockTransferItems) {
|
||||||
|
$data = $this->prepareSavedData();
|
||||||
|
|
||||||
|
$stockTransfer = StockTransfer::create($data);
|
||||||
|
|
||||||
|
$stockTransfer->load(['sourceOutlet', 'destinationOutlet']);
|
||||||
|
|
||||||
|
$this->uploadMedia($this->image, $stockTransfer, 'image');
|
||||||
|
|
||||||
|
foreach ($stockTransferItems as $item) {
|
||||||
|
if ($item->quantity <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->update(['stock_transfer_id' => $stockTransfer->id]);
|
||||||
|
|
||||||
|
$this->transferStockBetweenOutlets($stockTransfer->sourceOutlet, $stockTransfer->destinationOutlet, $item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
DB::transaction(function () {
|
||||||
|
$data = $this->prepareSavedData();
|
||||||
|
|
||||||
|
$this->stockTransfer->update($data);
|
||||||
|
|
||||||
|
$this->syncMedia($this->image, $this->stockTransfer, 'image');
|
||||||
|
$this->uploadMedia($this->image, $this->stockTransfer, 'image');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prepareSavedData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'note' => $this->note,
|
||||||
|
'source_outlet_id' => $this->source_outlet_id,
|
||||||
|
'destination_outlet_id' => $this->destination_outlet_id,
|
||||||
|
'transfer_date' => $this->transfer_date,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/Livewire/Studio/Manage/StockTransfer/Create.php
Normal file
130
app/Livewire/Studio/Manage/StockTransfer/Create.php
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Studio\Manage\StockTransfer;
|
||||||
|
|
||||||
|
use App\Livewire\Forms\Studio\Manage\StockTransferForm;
|
||||||
|
use App\Models\Outlet;
|
||||||
|
use App\Traits\Authorization\WithAuthorization;
|
||||||
|
use App\Traits\Components\WithConfirmation;
|
||||||
|
use App\Traits\Components\WithToast;
|
||||||
|
use App\Traits\StockTransfer\WithStockTransferAddItem;
|
||||||
|
use App\Traits\StockTransfer\WithStockTransferDeleteItem;
|
||||||
|
use App\Traits\StockTransfer\WithStockTransferItems;
|
||||||
|
use App\Traits\StockTransfer\WithStockTransferUpdateItem;
|
||||||
|
use App\Traits\Utilities\WithUpdatedData;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Title('Tambah Transfer Stok Antar Outlet')]
|
||||||
|
class Create extends Component
|
||||||
|
{
|
||||||
|
use WithAuthorization, WithConfirmation, WithStockTransferAddItem, WithStockTransferDeleteItem, WithStockTransferItems, WithStockTransferUpdateItem, WithToast, WithUpdatedData;
|
||||||
|
|
||||||
|
public StockTransferForm $form;
|
||||||
|
|
||||||
|
public array $sourceOutlets = [];
|
||||||
|
|
||||||
|
public array $destinationOutlets = [];
|
||||||
|
|
||||||
|
public array $perfumes = [];
|
||||||
|
|
||||||
|
public array $products = [];
|
||||||
|
|
||||||
|
public array $bottles = [];
|
||||||
|
|
||||||
|
public $stockTransferItems;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->sourceOutlets = auth()->user()->outlets->pluck('name', 'id')->toArray();
|
||||||
|
$this->destinationOutlets = Outlet::orderBy('name')->pluck('name', 'id')->toArray();
|
||||||
|
|
||||||
|
$this->stockTransferItems = $this->loadStockTransferItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updated($property, $value): void
|
||||||
|
{
|
||||||
|
if ($property === 'form.source_outlet_id' || $property === 'form.destination_outlet_id') {
|
||||||
|
$this->loadCreateItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadCreateItems(): void
|
||||||
|
{
|
||||||
|
$this->reset(['perfumes', 'products', 'bottles']);
|
||||||
|
|
||||||
|
$this->form->reset(
|
||||||
|
'perfume_id',
|
||||||
|
'quantity_perfume',
|
||||||
|
'product_id',
|
||||||
|
'quantity_product',
|
||||||
|
'bottle_id',
|
||||||
|
'quantity_bottle'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->form->source_outlet_id || ! $this->form->destination_outlet_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceOutlet = Outlet::find($this->form->source_outlet_id);
|
||||||
|
|
||||||
|
if (! $sourceOutlet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->perfumes = $sourceOutlet->perfumes()
|
||||||
|
->wherePivot('stock', '>', 0)
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$this->products = $sourceOutlet->products()
|
||||||
|
->wherePivot('stock', '>', 0)
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$this->bottles = $sourceOutlet->bottles()
|
||||||
|
->wherePivot('stock', '>', 0)
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->canOrAbort('create stock transfer');
|
||||||
|
|
||||||
|
if ($this->stockTransferItems->isEmpty()) {
|
||||||
|
$this->toast('Keranjang transfer stok tidak boleh kosong.', 'Gagal', 'danger');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->form->store();
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->toast($e->getMessage(), 'Gagal', 'danger');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirectRoute('studio.manage.stock_transfer.index', [
|
||||||
|
'notification' => 'Transfer stok berhasil diproses.',
|
||||||
|
], navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.studio.manage.stock-transfer.form', [
|
||||||
|
'pageTitle' => 'Tambah Transfer Stok Antar Outlet',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Livewire/Studio/Manage/StockTransfer/Index.php
Normal file
49
app/Livewire/Studio/Manage/StockTransfer/Index.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Studio\Manage\StockTransfer;
|
||||||
|
|
||||||
|
use App\Models\StockTransfer;
|
||||||
|
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\StockTransfer\WithStockTransferStock;
|
||||||
|
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('Transfer Stok')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
use WithAuthorization, WithCloseModal, WithConfirmation, WithRequestNotification, WithStockTransferStock, WithSubscribeNotification, WithToast;
|
||||||
|
|
||||||
|
public function delete(StockTransfer $stockTransfer): void
|
||||||
|
{
|
||||||
|
$this->canOrAbort('delete stock transfer');
|
||||||
|
|
||||||
|
DB::transaction(function () use ($stockTransfer) {
|
||||||
|
foreach ($stockTransfer->items as $item) {
|
||||||
|
$this->reverseStockTransferBetweenOutlets($stockTransfer->sourceOutlet, $stockTransfer->destinationOutlet, $item);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stockTransfer->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->dispatch('refreshDatatable');
|
||||||
|
|
||||||
|
$this->toast('Transfer stok berhasil dihapus.');
|
||||||
|
|
||||||
|
Flux::modals()->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.studio.manage.stock-transfer.index', [
|
||||||
|
'pageTitle' => 'Transfer Stok Antar Outlet',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Models/StockTransfer.php
Normal file
55
app/Models/StockTransfer.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 StockTransfer extends Model implements HasMedia
|
||||||
|
{
|
||||||
|
use HasFactory, HashableId, InteractsWithMedia, SoftDeletes;
|
||||||
|
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'transfer_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 sourceOutlet(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Outlet::class, 'source_outlet_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destinationOutlet(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Outlet::class, 'destination_outlet_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(StockTransferItem::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Models/StockTransferItem.php
Normal file
46
app/Models/StockTransferItem.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 StockTransferItem 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 stockTransfer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(StockTransfer::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transferable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -188,6 +188,13 @@ public function boot(): void
|
|||||||
'match' => 'studio.manage.restock.*',
|
'match' => 'studio.manage.restock.*',
|
||||||
'can' => 'view restock',
|
'can' => 'view restock',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Transfer Stok',
|
||||||
|
'icon' => 'clipboard-document-list',
|
||||||
|
'route' => 'studio.manage.stock_transfer.index',
|
||||||
|
'match' => 'studio.manage.stock_transfer.*',
|
||||||
|
'can' => 'view stock transfer',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'label' => 'Stock Opname',
|
'label' => 'Stock Opname',
|
||||||
'icon' => 'clipboard-document-check',
|
'icon' => 'clipboard-document-check',
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\Purchase;
|
use App\Models\Purchase;
|
||||||
use App\Models\Restock;
|
use App\Models\Restock;
|
||||||
use App\Models\StockOpname;
|
use App\Models\StockOpname;
|
||||||
|
use App\Models\StockTransfer;
|
||||||
use App\Models\Warehouse;
|
use App\Models\Warehouse;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
@ -20,6 +21,8 @@ class StockActivityLogService
|
|||||||
|
|
||||||
public const LOG_STOCK_OPNAME = 'stock_opname';
|
public const LOG_STOCK_OPNAME = 'stock_opname';
|
||||||
|
|
||||||
|
public const LOG_TRANSFER = 'transfer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log purchase activity (Incoming to Warehouse).
|
* Log purchase activity (Incoming to Warehouse).
|
||||||
*/
|
*/
|
||||||
@ -159,6 +162,57 @@ public static function stockOpname(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log transfer activity (Outlet to Outlet).
|
||||||
|
* This logs two events: Out from Source Outlet, In to Destination Outlet.
|
||||||
|
*/
|
||||||
|
public static function transfer(
|
||||||
|
StockTransfer $stockTransfer,
|
||||||
|
Model $product,
|
||||||
|
int $quantity,
|
||||||
|
int $sourcePreviousStock,
|
||||||
|
int $sourceNewStock,
|
||||||
|
int $destinationPreviousStock,
|
||||||
|
int $destinationNewStock,
|
||||||
|
?Model $causedBy = null
|
||||||
|
): void {
|
||||||
|
// Log Source Outlet Out
|
||||||
|
self::logOutlet(
|
||||||
|
logName: self::LOG_TRANSFER,
|
||||||
|
event: 'decrease',
|
||||||
|
description: "Stock Transfer #{$stockTransfer->hash_id}: Moved {$quantity} stock from Source to Destination Outlet.",
|
||||||
|
performedOn: $product,
|
||||||
|
outlet: $stockTransfer->sourceOutlet,
|
||||||
|
quantityChange: -1 * abs($quantity),
|
||||||
|
previousStock: $sourcePreviousStock,
|
||||||
|
newStock: $sourceNewStock,
|
||||||
|
extraProperties: [
|
||||||
|
'stock_transfer_id' => $stockTransfer->id,
|
||||||
|
'stock_transfer_hash' => $stockTransfer->hash_id,
|
||||||
|
'destination_outlet_id' => $stockTransfer->destination_outlet_id,
|
||||||
|
],
|
||||||
|
causedBy: $causedBy
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log Destination Outlet In
|
||||||
|
self::logOutlet(
|
||||||
|
logName: self::LOG_TRANSFER,
|
||||||
|
event: 'increase',
|
||||||
|
description: "Stock Transfer #{$stockTransfer->hash_id}: Received {$quantity} stock from Source Outlet.",
|
||||||
|
performedOn: $product,
|
||||||
|
outlet: $stockTransfer->destinationOutlet,
|
||||||
|
quantityChange: abs($quantity),
|
||||||
|
previousStock: $destinationPreviousStock,
|
||||||
|
newStock: $destinationNewStock,
|
||||||
|
extraProperties: [
|
||||||
|
'stock_transfer_id' => $stockTransfer->id,
|
||||||
|
'stock_transfer_hash' => $stockTransfer->hash_id,
|
||||||
|
'source_outlet_id' => $stockTransfer->source_outlet_id,
|
||||||
|
],
|
||||||
|
causedBy: $causedBy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log stock related activity for Outlet.
|
* Log stock related activity for Outlet.
|
||||||
*/
|
*/
|
||||||
|
|||||||
112
app/Traits/StockTransfer/WithStockTransferAddItem.php
Normal file
112
app/Traits/StockTransfer/WithStockTransferAddItem.php
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits\StockTransfer;
|
||||||
|
|
||||||
|
use App\Models\Bottle;
|
||||||
|
use App\Models\Outlet;
|
||||||
|
use App\Models\Perfume;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\StockTransferItem;
|
||||||
|
|
||||||
|
trait WithStockTransferAddItem
|
||||||
|
{
|
||||||
|
public function addItem(string $type): void
|
||||||
|
{
|
||||||
|
$this->canOrAbort('create stock transfer');
|
||||||
|
|
||||||
|
if (! $this->form->source_outlet_id) {
|
||||||
|
$this->toast('Silakan pilih outlet asal terlebih dahulu.', 'Gagal', 'warning');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceOutlet = Outlet::find($this->form->source_outlet_id);
|
||||||
|
|
||||||
|
if (! $sourceOutlet) {
|
||||||
|
$this->toast('Outlet asal tidak ditemukan.', 'Gagal', 'danger');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = match ($type) {
|
||||||
|
'parfum' => [
|
||||||
|
'transferable_type' => Perfume::class,
|
||||||
|
'transferable_id' => $this->form->perfume_id,
|
||||||
|
'quantity' => parseRupiahToInt($this->form->quantity_perfume),
|
||||||
|
],
|
||||||
|
'produk' => [
|
||||||
|
'transferable_type' => Product::class,
|
||||||
|
'transferable_id' => $this->form->product_id,
|
||||||
|
'quantity' => parseRupiahToInt($this->form->quantity_product),
|
||||||
|
],
|
||||||
|
'botol' => [
|
||||||
|
'transferable_type' => Bottle::class,
|
||||||
|
'transferable_id' => $this->form->bottle_id,
|
||||||
|
'quantity' => parseRupiahToInt($this->form->quantity_bottle),
|
||||||
|
],
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (! $data || ! $data['transferable_id'] || $data['quantity'] <= 0) {
|
||||||
|
$this->toast('Data item tidak valid.', 'Gagal', 'danger');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate stock availability
|
||||||
|
$relationName = match ($type) {
|
||||||
|
'parfum' => 'perfumes',
|
||||||
|
'produk' => 'products',
|
||||||
|
'botol' => 'bottles',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($relationName) {
|
||||||
|
$sourceStock = $sourceOutlet->{$relationName}()
|
||||||
|
->where($data['transferable_type']::make()->getTable().'.id', $data['transferable_id'])
|
||||||
|
->first()
|
||||||
|
?->pivot
|
||||||
|
?->stock ?? 0;
|
||||||
|
|
||||||
|
// Is item already in cart?
|
||||||
|
$existing = StockTransferItem::where([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'transferable_id' => $data['transferable_id'],
|
||||||
|
'transferable_type' => $data['transferable_type'],
|
||||||
|
])->whereNull('stock_transfer_id')->first();
|
||||||
|
|
||||||
|
$currentCartQty = $existing ? $existing->quantity : 0;
|
||||||
|
$requestedQty = $data['quantity'];
|
||||||
|
|
||||||
|
if (($currentCartQty + $requestedQty) > $sourceStock) {
|
||||||
|
$this->toast("Stok tidak mencukupi. Stok di outlet asal: {$sourceStock}. Sudah di keranjang: {$currentCartQty}.", 'Gagal', 'danger');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$existing->increment('quantity', $requestedQty);
|
||||||
|
} else {
|
||||||
|
StockTransferItem::create(array_merge($data, ['user_id' => auth()->id()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetFormItem($type);
|
||||||
|
$this->stockTransferItems = $this->loadStockTransferItems();
|
||||||
|
$this->toast('Item berhasil ditambahkan ke keranjang.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resetFormItem(string $type): void
|
||||||
|
{
|
||||||
|
if ($type === 'parfum') {
|
||||||
|
$this->form->perfume_id = '';
|
||||||
|
$this->form->quantity_perfume = '';
|
||||||
|
} elseif ($type === 'produk') {
|
||||||
|
$this->form->product_id = '';
|
||||||
|
$this->form->quantity_product = '';
|
||||||
|
} elseif ($type === 'botol') {
|
||||||
|
$this->form->bottle_id = '';
|
||||||
|
$this->form->quantity_bottle = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Traits/StockTransfer/WithStockTransferDeleteItem.php
Normal file
18
app/Traits/StockTransfer/WithStockTransferDeleteItem.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits\StockTransfer;
|
||||||
|
|
||||||
|
use App\Models\StockTransferItem;
|
||||||
|
|
||||||
|
trait WithStockTransferDeleteItem
|
||||||
|
{
|
||||||
|
public function deleteItem(StockTransferItem $item): void
|
||||||
|
{
|
||||||
|
$this->canOrAbort('create stock transfer');
|
||||||
|
|
||||||
|
$item->delete();
|
||||||
|
|
||||||
|
$this->stockTransferItems = $this->loadStockTransferItems();
|
||||||
|
$this->toast('Item berhasil dihapus dari keranjang.');
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Traits/StockTransfer/WithStockTransferItems.php
Normal file
19
app/Traits/StockTransfer/WithStockTransferItems.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits\StockTransfer;
|
||||||
|
|
||||||
|
use App\Models\StockTransferItem;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
trait WithStockTransferItems
|
||||||
|
{
|
||||||
|
public function loadStockTransferItems(): Collection
|
||||||
|
{
|
||||||
|
return $this->stockTransferItems = StockTransferItem::query()
|
||||||
|
->currentUser()
|
||||||
|
->where('user_id', auth()->id())
|
||||||
|
->whereNull('stock_transfer_id')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/Traits/StockTransfer/WithStockTransferStock.php
Normal file
169
app/Traits/StockTransfer/WithStockTransferStock.php
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits\StockTransfer;
|
||||||
|
|
||||||
|
use App\Models\Bottle;
|
||||||
|
use App\Models\Perfume;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\StockTransfer;
|
||||||
|
use App\Services\StockActivityLogService;
|
||||||
|
|
||||||
|
trait WithStockTransferStock
|
||||||
|
{
|
||||||
|
public function transferStockBetweenOutlets($sourceOutlet, $destinationOutlet, $item): void
|
||||||
|
{
|
||||||
|
$quantity = $item->quantity;
|
||||||
|
|
||||||
|
switch ($item->transferable_type) {
|
||||||
|
case Perfume::class:
|
||||||
|
$relation = 'perfumes';
|
||||||
|
break;
|
||||||
|
case Product::class:
|
||||||
|
$relation = 'products';
|
||||||
|
break;
|
||||||
|
case Bottle::class:
|
||||||
|
$relation = 'bottles';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrease source outlet stock
|
||||||
|
$sourceItem = $sourceOutlet->{$relation}()
|
||||||
|
->where("{$relation}.id", $item->transferable_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->withPivot('stock')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$sourcePreviousStock = 0;
|
||||||
|
|
||||||
|
if ($sourceItem) {
|
||||||
|
$sourcePreviousStock = $sourceItem->pivot->stock ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourcePreviousStock < $quantity) {
|
||||||
|
throw new \Exception("Stok {$item->transferable->name} di outlet asal tidak mencukupi. Tersisa {$sourcePreviousStock}, diminta {$quantity}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceNewStock = $sourcePreviousStock - $quantity;
|
||||||
|
|
||||||
|
$sourceOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
|
||||||
|
'stock' => $sourceNewStock,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Increase destination outlet stock
|
||||||
|
$destinationItem = $destinationOutlet->{$relation}()
|
||||||
|
->where("{$relation}.id", $item->transferable_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->withPivot('stock')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($destinationItem) {
|
||||||
|
$destinationPreviousStock = $destinationItem->pivot->stock ?? 0;
|
||||||
|
$destinationOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
|
||||||
|
'stock' => $destinationPreviousStock + $quantity,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$destinationPreviousStock = 0;
|
||||||
|
$destinationOutlet->{$relation}()->attach($item->transferable_id, [
|
||||||
|
'stock' => $quantity,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$destinationNewStock = $destinationPreviousStock + $quantity;
|
||||||
|
|
||||||
|
$stockTransfer = $item->stockTransfer ?? StockTransfer::find($item->stock_transfer_id);
|
||||||
|
|
||||||
|
if ($stockTransfer) {
|
||||||
|
StockActivityLogService::transfer(
|
||||||
|
stockTransfer: $stockTransfer,
|
||||||
|
product: $item->transferable,
|
||||||
|
quantity: $quantity,
|
||||||
|
sourcePreviousStock: $sourcePreviousStock,
|
||||||
|
sourceNewStock: $sourceNewStock,
|
||||||
|
destinationPreviousStock: $destinationPreviousStock,
|
||||||
|
destinationNewStock: $destinationNewStock
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reverseStockTransferBetweenOutlets($sourceOutlet, $destinationOutlet, $item): void
|
||||||
|
{
|
||||||
|
$quantity = $item->quantity;
|
||||||
|
|
||||||
|
switch ($item->transferable_type) {
|
||||||
|
case Perfume::class:
|
||||||
|
$relation = 'perfumes';
|
||||||
|
break;
|
||||||
|
case Product::class:
|
||||||
|
$relation = 'products';
|
||||||
|
break;
|
||||||
|
case Bottle::class:
|
||||||
|
$relation = 'bottles';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return items to source outlet
|
||||||
|
$sourcePivot = $sourceOutlet->{$relation}()
|
||||||
|
->where("{$relation}.id", $item->transferable_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$currentSourceStock = $sourcePivot?->pivot?->stock ?? 0;
|
||||||
|
$newSourceStock = $currentSourceStock + $quantity;
|
||||||
|
|
||||||
|
if ($sourcePivot) {
|
||||||
|
$sourceOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
|
||||||
|
'stock' => $newSourceStock,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$sourceOutlet->{$relation}()->attach($item->transferable_id, [
|
||||||
|
'stock' => $quantity,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stockTransfer = $item->stockTransfer ?? StockTransfer::find($item->stock_transfer_id);
|
||||||
|
|
||||||
|
if ($stockTransfer) {
|
||||||
|
StockActivityLogService::logOutlet(
|
||||||
|
logName: StockActivityLogService::LOG_TRANSFER,
|
||||||
|
event: 'increase',
|
||||||
|
description: "Stock Transfer #{$stockTransfer->hash_id} Deleted: Returned {$quantity} stock to source outlet.",
|
||||||
|
performedOn: $item->transferable,
|
||||||
|
outlet: $sourceOutlet,
|
||||||
|
quantityChange: $quantity,
|
||||||
|
previousStock: $currentSourceStock,
|
||||||
|
newStock: $newSourceStock,
|
||||||
|
extraProperties: ['stock_transfer_id' => $stockTransfer->id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove items from destination outlet
|
||||||
|
$destinationPivot = $destinationOutlet->{$relation}()
|
||||||
|
->where("{$relation}.id", $item->transferable_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$currentDestinationStock = $destinationPivot?->pivot?->stock ?? 0;
|
||||||
|
$newDestinationStock = max(0, $currentDestinationStock - $quantity);
|
||||||
|
|
||||||
|
if ($destinationPivot) {
|
||||||
|
$destinationOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
|
||||||
|
'stock' => $newDestinationStock,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stockTransfer) {
|
||||||
|
StockActivityLogService::logOutlet(
|
||||||
|
logName: StockActivityLogService::LOG_TRANSFER,
|
||||||
|
event: 'decrease',
|
||||||
|
description: "Stock Transfer #{$stockTransfer->hash_id} Deleted: Removed {$quantity} stock from destination outlet.",
|
||||||
|
performedOn: $item->transferable,
|
||||||
|
outlet: $destinationOutlet,
|
||||||
|
quantityChange: -1 * abs($quantity),
|
||||||
|
previousStock: $currentDestinationStock,
|
||||||
|
newStock: $newDestinationStock,
|
||||||
|
extraProperties: ['stock_transfer_id' => $stockTransfer->id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Traits/StockTransfer/WithStockTransferUpdateItem.php
Normal file
84
app/Traits/StockTransfer/WithStockTransferUpdateItem.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits\StockTransfer;
|
||||||
|
|
||||||
|
use App\Models\Bottle;
|
||||||
|
use App\Models\Outlet;
|
||||||
|
use App\Models\Perfume;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\StockTransferItem;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
|
||||||
|
trait WithStockTransferUpdateItem
|
||||||
|
{
|
||||||
|
public string $modalTitle = '';
|
||||||
|
|
||||||
|
public function updateItem(): void
|
||||||
|
{
|
||||||
|
$this->canOrAbort('create stock transfer');
|
||||||
|
|
||||||
|
if (! $this->form->source_outlet_id) {
|
||||||
|
$this->toast('Silakan pilih outlet asal terlebih dahulu.', 'Gagal', 'warning');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceOutlet = Outlet::find($this->form->source_outlet_id);
|
||||||
|
|
||||||
|
if (! $sourceOutlet) {
|
||||||
|
$this->toast('Outlet asal tidak ditemukan.', 'Gagal', 'danger');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = StockTransferItem::find($this->form->item_id);
|
||||||
|
|
||||||
|
if ($item) {
|
||||||
|
$newQuantity = parseRupiahToInt($this->form->quantity_edit);
|
||||||
|
|
||||||
|
if ($newQuantity <= 0) {
|
||||||
|
$this->toast('Jumlah harus lebih dari 0.', 'Gagal', 'danger');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relationName = match ($item->transferable_type) {
|
||||||
|
Perfume::class => 'perfumes',
|
||||||
|
Product::class => 'products',
|
||||||
|
Bottle::class => 'bottles',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($relationName) {
|
||||||
|
$sourceStock = $sourceOutlet->{$relationName}()
|
||||||
|
->where($item->transferable_type::make()->getTable().'.id', $item->transferable_id)
|
||||||
|
->first()
|
||||||
|
?->pivot
|
||||||
|
?->stock ?? 0;
|
||||||
|
|
||||||
|
if ($newQuantity > $sourceStock) {
|
||||||
|
$this->toast("Stok tidak mencukupi (Update). Stok di outlet asal: {$sourceStock}.", 'Gagal', 'danger');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->update([
|
||||||
|
'quantity' => $newQuantity,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stockTransferItems = $this->loadStockTransferItems();
|
||||||
|
$this->toast('Item berhasil diperbarui.');
|
||||||
|
$this->dispatch('modal:close', 'form-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('modal:open')]
|
||||||
|
public function openModal($item): void
|
||||||
|
{
|
||||||
|
$item = StockTransferItem::find($item['item']);
|
||||||
|
$this->form->item_id = $item->id;
|
||||||
|
$this->form->quantity_edit = formatCurrencyNumber($item->quantity, '');
|
||||||
|
$this->modalTitle = $item->transferable->name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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('stock_transfers', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('source_outlet_id')->constrained('outlets')->cascadeOnDelete();
|
||||||
|
$table->foreignId('destination_outlet_id')->constrained('outlets')->cascadeOnDelete();
|
||||||
|
$table->date('transfer_date');
|
||||||
|
$table->string('note', 100)->nullable();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('stock_transfers');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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('stock_transfer_items', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('stock_transfer_id')->nullable()->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->morphs('transferable');
|
||||||
|
$table->unsignedInteger('quantity');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('stock_transfer_items');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -139,6 +139,11 @@ public function run(): void
|
|||||||
'update restock',
|
'update restock',
|
||||||
'delete restock',
|
'delete restock',
|
||||||
|
|
||||||
|
'view stock transfer',
|
||||||
|
'create stock transfer',
|
||||||
|
'update stock transfer',
|
||||||
|
'delete stock transfer',
|
||||||
|
|
||||||
'view reward',
|
'view reward',
|
||||||
'create reward',
|
'create reward',
|
||||||
'update reward',
|
'update reward',
|
||||||
@ -399,6 +404,11 @@ public function run(): void
|
|||||||
'update restock',
|
'update restock',
|
||||||
'delete restock',
|
'delete restock',
|
||||||
|
|
||||||
|
'view stock transfer',
|
||||||
|
'create stock transfer',
|
||||||
|
'update stock transfer',
|
||||||
|
'delete stock transfer',
|
||||||
|
|
||||||
'view reward',
|
'view reward',
|
||||||
'create reward',
|
'create reward',
|
||||||
'update reward',
|
'update reward',
|
||||||
|
|||||||
@ -0,0 +1,213 @@
|
|||||||
|
<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_transfer.index') }}" wire:navigate class="text-sm">
|
||||||
|
Kembali
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<flux:callout icon="megaphone" color="blue" class="mb-6">
|
||||||
|
<flux:callout.heading>Informasi Transfer Stok</flux:callout.heading>
|
||||||
|
<flux:callout.text>
|
||||||
|
Proses transfer stok akan **mengurangi** stok di Outlet Asal dan **menambah** stok di Outlet Tujuan yang dipilih.
|
||||||
|
</flux:callout.text>
|
||||||
|
</flux:callout>
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row gap-4">
|
||||||
|
<div class="w-full lg:w-3/4 space-y-4">
|
||||||
|
<flux:card class="space-y-6 p-6">
|
||||||
|
<flux:input label="Catatan" placeholder="Transfer stok antar outlet" wire:model="form.note"
|
||||||
|
autocomplete="off" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>Tanggal Transfer <span class="text-red-500 ms-1">*</span></flux:label>
|
||||||
|
<flux:date-picker with-today wire:model="form.transfer_date" placeholder="Pilih Tanggal"
|
||||||
|
autocomplete="off" locale="id-ID" selectable-header />
|
||||||
|
<flux:error name="form.transfer_date" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>Outlet Asal <span class="text-red-500 ms-1">*</span></flux:label>
|
||||||
|
<flux:select variant="listbox" searchable placeholder="Pilih Outlet Asal"
|
||||||
|
wire:model.live="form.source_outlet_id">
|
||||||
|
@foreach ($sourceOutlets as $key => $value)
|
||||||
|
<flux:select.option value="{{ $key }}">{{ $value }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:error name="form.source_outlet_id" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>Outlet Tujuan <span class="text-red-500 ms-1">*</span></flux:label>
|
||||||
|
<flux:select variant="listbox" searchable placeholder="Pilih Outlet Tujuan"
|
||||||
|
wire:model.live="form.destination_outlet_id">
|
||||||
|
@foreach ($destinationOutlets as $key => $value)
|
||||||
|
<flux:select.option value="{{ $key }}">{{ $value }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:error name="form.destination_outlet_id" />
|
||||||
|
</flux:field>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
@if ($form->source_outlet_id && $form->destination_outlet_id)
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<flux:card class="space-y-6 p-6">
|
||||||
|
<flux:select label="Parfum" variant="listbox" searchable placeholder="Cari parfum..."
|
||||||
|
wire:model="form.perfume_id">
|
||||||
|
@foreach ($perfumes as $key => $perfume)
|
||||||
|
<flux:select.option value="{{ $key }}">{{ $perfume }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:input label="Kuantitas" placeholder="500" wire:model="form.quantity_perfume"
|
||||||
|
autocomplete="off" x-mask:dynamic="$money($input, ',')" />
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<flux:button variant="primary" color="zinc" wire:click="addItem('parfum')">
|
||||||
|
Tambah
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
<flux:card class="space-y-6 p-6">
|
||||||
|
<flux:select label="Produk" variant="listbox" searchable placeholder="Cari produk..."
|
||||||
|
wire:model="form.product_id">
|
||||||
|
@foreach ($products as $key => $product)
|
||||||
|
<flux:select.option value="{{ $key }}">{{ $product }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:input label="Kuantitas" placeholder="10" wire:model="form.quantity_product"
|
||||||
|
autocomplete="off" x-mask:dynamic="$money($input, ',')" />
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<flux:button variant="primary" color="zinc" wire:click="addItem('produk')">
|
||||||
|
Tambah
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
|
||||||
|
<flux:card class="space-y-6 p-6">
|
||||||
|
<flux:select label="Botol" variant="listbox" searchable placeholder="Cari botol..."
|
||||||
|
wire:model="form.bottle_id">
|
||||||
|
@foreach ($bottles as $key => $bottle)
|
||||||
|
<flux:select.option value="{{ $key }}">{{ $bottle }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:input label="Kuantitas" placeholder="50" wire:model="form.quantity_bottle"
|
||||||
|
autocomplete="off" x-mask:dynamic="$money($input, ',')" />
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<flux:button variant="primary" color="zinc" wire:click="addItem('botol')">
|
||||||
|
Tambah
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<flux:card class="p-6 text-center text-zinc-500 italic">
|
||||||
|
Silakan pilih Outlet Asal dan Outlet Tujuan terlebih dahulu untuk menampilkan daftar item.
|
||||||
|
</flux:card>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full lg:w-1/3 space-y-4">
|
||||||
|
<flux:card class="space-y-6 p-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="text-sm font-medium">Gambar</h3>
|
||||||
|
<div class="dropzone-wrapper">
|
||||||
|
<livewire:dropzone wire:model="form.image" :rules="['image', 'mimes:png,jpeg', 'max:10420']" :max-files="1"
|
||||||
|
:key="'image'" :files="$form->image" />
|
||||||
|
@error('form.image')
|
||||||
|
<div class="mt-3 text-sm font-medium text-red-500 dark:text-red-400">
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
<flux:card class="space-y-6 p-6">
|
||||||
|
<h3 class="text-sm font-medium">Keranjang Transfer Stok</h3>
|
||||||
|
|
||||||
|
<div class="divide-y text-sm">
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column>Item</flux:table.column>
|
||||||
|
<flux:table.column>Qty</flux:table.column>
|
||||||
|
<flux:table.column>Aksi</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse($stockTransferItems as $item)
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:heading>
|
||||||
|
<div>{{ $item->transferable->name }}</div>
|
||||||
|
</flux:heading>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
{{ formatCurrencyNumber($item->quantity, '') }}
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell class="space-x-2">
|
||||||
|
<flux:modal.trigger name="form-modal">
|
||||||
|
<flux:tooltip content="Ubah">
|
||||||
|
<flux:button variant="primary" color="yellow" icon="pencil-square"
|
||||||
|
size="sm"
|
||||||
|
wire:click="$dispatch('modal:open', {'item': '{{ $item->id }}'})">
|
||||||
|
</flux:button>
|
||||||
|
</flux:tooltip>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
|
||||||
|
<flux:button variant="danger" icon="trash" size="sm"
|
||||||
|
wire:click="deleteItem('{{ $item->id }}')">
|
||||||
|
</flux:button>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="3" class="text-center">
|
||||||
|
<span class="text-sm text-zinc-500 italic">Keranjang Kosong...</span>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-start mt-4">
|
||||||
|
<flux:button variant="primary" class="sm:w-auto cursor-pointer" wire:click="save">
|
||||||
|
Simpan
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:modal name="form-modal" class="w-[95%] max-w-sm md:max-w-xl mx-auto" @close="closeModal('form-modal')">
|
||||||
|
<div class="p-4 space-y-6">
|
||||||
|
<flux:heading size="lg">Ubah {{ $modalTitle }}</flux:heading>
|
||||||
|
|
||||||
|
<flux:input label="Kuantitas" placeholder="Masukkan kuantitas" wire:model="form.quantity_edit"
|
||||||
|
autocomplete="off" x-mask:dynamic="$money($input, ',')" />
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button variant="primary" color="zinc" class="sm:w-auto cursor-pointer"
|
||||||
|
wire:click="updateItem">
|
||||||
|
Perbarui
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
</flux:main>
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
<flux:main>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
@can('create stock transfer')
|
||||||
|
<div>
|
||||||
|
<flux:button href="{{ route('studio.manage.stock_transfer.create') }}" variant="primary" wire:navigate
|
||||||
|
class="text-sm">
|
||||||
|
Tambah
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<flux:callout icon="megaphone" color="blue" class="mb-6">
|
||||||
|
<flux:callout.heading>Informasi</flux:callout.heading>
|
||||||
|
<flux:callout.text>
|
||||||
|
Menghapus data transfer stok otomatis mengembalikan stok ke outlet asal.
|
||||||
|
</flux:callout.text>
|
||||||
|
</flux:callout>
|
||||||
|
|
||||||
|
<livewire:datatable.manage.stock-transfers-table />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@include('components.modals.confirmation', [
|
||||||
|
'modalName' => 'delete-confirmation',
|
||||||
|
'modalTitle' => 'Apakah Anda yakin?',
|
||||||
|
'modalMessage' => 'Data transfer stok akan dihapus dari riwayat.',
|
||||||
|
'buttonVariant' => 'primary',
|
||||||
|
'buttonColor' => 'danger',
|
||||||
|
'buttonText' => 'Ya, Hapus',
|
||||||
|
])
|
||||||
|
</flux:main>
|
||||||
@ -42,6 +42,8 @@
|
|||||||
use App\Livewire\Studio\Manage\StockOpname\Index as StockOpnameIndex;
|
use App\Livewire\Studio\Manage\StockOpname\Index as StockOpnameIndex;
|
||||||
use App\Livewire\Studio\Manage\StockOpname\Manage as StockOpnameManage;
|
use App\Livewire\Studio\Manage\StockOpname\Manage as StockOpnameManage;
|
||||||
use App\Livewire\Studio\Manage\StockOpname\Show as StockOpnameShow;
|
use App\Livewire\Studio\Manage\StockOpname\Show as StockOpnameShow;
|
||||||
|
use App\Livewire\Studio\Manage\StockTransfer\Create as StockTransferCreate;
|
||||||
|
use App\Livewire\Studio\Manage\StockTransfer\Index as StockTransferIndex;
|
||||||
use App\Livewire\Studio\Master\Category as CategoryComponent;
|
use App\Livewire\Studio\Master\Category as CategoryComponent;
|
||||||
use App\Livewire\Studio\Master\Formula as FormulaComponent;
|
use App\Livewire\Studio\Master\Formula as FormulaComponent;
|
||||||
use App\Livewire\Studio\Master\Outlet\Create as OutletCreate;
|
use App\Livewire\Studio\Master\Outlet\Create as OutletCreate;
|
||||||
@ -172,6 +174,11 @@
|
|||||||
Route::get('/create', RestockCreate::class)->name('create')->middleware('can:create restock');
|
Route::get('/create', RestockCreate::class)->name('create')->middleware('can:create restock');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::prefix('stock-transfers')->name('stock_transfer.')->group(function () {
|
||||||
|
Route::get('/', StockTransferIndex::class)->name('index')->middleware('can:view stock transfer');
|
||||||
|
Route::get('/create', StockTransferCreate::class)->name('create')->middleware('can:create stock transfer');
|
||||||
|
});
|
||||||
|
|
||||||
Route::prefix('stock-opname')->name('stock_opname.')->group(function () {
|
Route::prefix('stock-opname')->name('stock_opname.')->group(function () {
|
||||||
Route::get('/', StockOpnameIndex::class)->name('index')->middleware('can:view stock opname');
|
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}/show', StockOpnameShow::class)->name('show')->middleware('can:show stock opname');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user